Rename application identifier to space.rcmd.android.sizzle
Global rename of the app/package identifier from com.reactorcoremeltdown.sizzletracker to space.rcmd.android.sizzle: - build.gradle.kts: applicationId + namespace. - Move the Kotlin source tree (main + test) and rewrite every package/import. - native_audio.cpp: JNI symbol names (Java_space_rcmd_android_sizzle_...) so the Oboe callback still links against the relocated NativeAudioBridge. - docs: package/file-map header. The desktop-project GitHub URLs (github.com/reactorcoremeltdown/sizzletracker) and the Reactorcoremeltdown copyright/SPDX author identity are intentionally left unchanged. Note: the new applicationId installs as a separate package (existing installs won't upgrade in place). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
145
app/src/main/java/space/rcmd/android/sizzle/MainActivity.kt
Normal file
145
app/src/main/java/space/rcmd/android/sizzle/MainActivity.kt
Normal file
@@ -0,0 +1,145 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import space.rcmd.android.sizzle.input.GamepadInput
|
||||
import space.rcmd.android.sizzle.input.KeyboardInput
|
||||
import space.rcmd.android.sizzle.input.MidiInput
|
||||
import space.rcmd.android.sizzle.playback.PlaybackService
|
||||
import space.rcmd.android.sizzle.ui.AppViewModel
|
||||
import space.rcmd.android.sizzle.ui.SizzleApp as SizzleAppUi
|
||||
import space.rcmd.android.sizzle.ui.theme.SizzleTheme
|
||||
|
||||
/**
|
||||
* The single Activity. Its jobs are narrow and clear:
|
||||
* 1. build the [AppViewModel] wired to the app-wide singletons,
|
||||
* 2. host the Compose UI, themed by the ViewModel's current palette,
|
||||
* 3. forward hardware key / motion events into the keyboard & gamepad handlers,
|
||||
* 4. start MIDI listening and the background playback service.
|
||||
*
|
||||
* All four input methods converge on the same InputRouter -> ViewModel path, so
|
||||
* there is no per-input special-casing beyond the translation done here.
|
||||
*/
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private val app get() = application as SizzleApp
|
||||
|
||||
// Handlers are app-wide singletons (shared with the Settings rebind UI).
|
||||
private val keyboard: KeyboardInput get() = app.keyboardInput
|
||||
private val gamepad: GamepadInput get() = app.gamepadInput
|
||||
private val midi: MidiInput get() = app.midiInput
|
||||
|
||||
private val viewModel: AppViewModel by lazy {
|
||||
ViewModelProvider(
|
||||
this,
|
||||
viewModelFactory {
|
||||
initializer {
|
||||
AppViewModel(
|
||||
app.project, app.audioEngine, app.inputRouter,
|
||||
app.gamepadInput, app.midiInput, app.keyboardInput,
|
||||
app.bindingStore, app.presetLibrary, app.songLibrary,
|
||||
app.settingsStore, app.themeLibrary, app.applicationContext,
|
||||
)
|
||||
}
|
||||
},
|
||||
)[AppViewModel::class.java]
|
||||
}
|
||||
|
||||
private val requestPermissions = registerForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions(),
|
||||
) { /* result ignored: features degrade gracefully if denied */ }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
askForPermissions()
|
||||
|
||||
setContent {
|
||||
// viewModel.palette is Compose state, so reading it here recomposes
|
||||
// the whole theme when the user picks a new colour scheme in Settings.
|
||||
SizzleTheme(palette = viewModel.palette) {
|
||||
SizzleAppUi(viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
midi.start()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// Start the playback service only once we're definitively in the
|
||||
// foreground — a plain startService() from a not-yet-foreground state
|
||||
// throws BackgroundServiceStartNotAllowedException on Android 12+.
|
||||
runCatching { PlaybackService.start(this) }
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
// Autosave the project whenever we leave the foreground, so a later
|
||||
// system-kill (or crash) resumes from here on next launch.
|
||||
app.projectStore.save(app.project)
|
||||
midi.stop()
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
// ---- Hardware input forwarding ----
|
||||
// Gamepad events are intercepted at DISPATCH time — before Compose's focus
|
||||
// system can consume D-pad / button keys — so USB *and* Bluetooth pads work
|
||||
// reliably. The gamepad handler ignores non-gamepad events, so anything else
|
||||
// (keyboard keys, text-field typing, UI) flows through the normal path below.
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
val handledByPad = when (event.action) {
|
||||
KeyEvent.ACTION_DOWN -> gamepad.onKeyDown(event.keyCode, event)
|
||||
KeyEvent.ACTION_UP -> gamepad.onKeyUp(event.keyCode, event)
|
||||
else -> false
|
||||
}
|
||||
return handledByPad || super.dispatchKeyEvent(event)
|
||||
}
|
||||
|
||||
// Analog sticks / triggers / D-pad hats arrive as generic motion events;
|
||||
// intercept them the same way (the handler ignores non-joystick motion).
|
||||
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean =
|
||||
gamepad.onMotion(event) || super.dispatchGenericMotionEvent(event)
|
||||
|
||||
// Physical keyboard reaches here via the normal path (gamepad events were
|
||||
// already handled in dispatchKeyEvent and never arrive here).
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean =
|
||||
keyboard.onKeyDown(keyCode, event) || super.onKeyDown(keyCode, event)
|
||||
|
||||
override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean =
|
||||
keyboard.onKeyUp(keyCode, event) || super.onKeyUp(keyCode, event)
|
||||
|
||||
// ---- (helper removed; viewModel.palette is read directly in setContent) ----
|
||||
|
||||
// ---- Permissions ----
|
||||
private fun askForPermissions() {
|
||||
val needed = buildList {
|
||||
add(Manifest.permission.RECORD_AUDIO)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
add(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
add(Manifest.permission.BLUETOOTH_CONNECT)
|
||||
}
|
||||
}.filter {
|
||||
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
if (needed.isNotEmpty()) requestPermissions.launch(needed.toTypedArray())
|
||||
}
|
||||
}
|
||||
95
app/src/main/java/space/rcmd/android/sizzle/SizzleApp.kt
Normal file
95
app/src/main/java/space/rcmd/android/sizzle/SizzleApp.kt
Normal file
@@ -0,0 +1,95 @@
|
||||
// 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) }
|
||||
|
||||
/** 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) }
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.audio
|
||||
|
||||
import space.rcmd.android.sizzle.model.AmbiencePresets
|
||||
import space.rcmd.android.sizzle.model.ToolboxSlot
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.exp
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.log10
|
||||
import kotlin.math.tanh
|
||||
|
||||
/**
|
||||
* A mono port of OTODESK's "Ambience 1.0.1" algorithmic reverb — a 16-channel
|
||||
* Feedback Delay Network (FDN). Faithful to the original's architecture within the
|
||||
* limits of this app's mono, per-sample [AudioEffect] chain:
|
||||
*
|
||||
* - 16 delay lines at mutually-prime, log-distributed lengths scaled by Room Size.
|
||||
* - A lossless feedback matrix: Fast Walsh-Hadamard Transform + the original's
|
||||
* fixed sign-flip pattern (orthonormal → the only loss is the damping stage,
|
||||
* so RT60 is set purely by decay/damping and the loop can never run away).
|
||||
* - Per-channel decay gain derived from the Decay (RT60) target, plus a one-pole
|
||||
* HF-damping low-pass and an LF-absorption tap (with per-algorithm intrinsic
|
||||
* tone baked from the plugin's measured RT60 curves).
|
||||
* - A 4-stage input diffuser and a nested allpass per channel (Diffusion).
|
||||
* - Bandlimited-noise LFO modulation of the delay reads (Mod Amount / Mod Rate).
|
||||
* - ISM early-reflection tap patterns per algorithm (Room/Hall only), fed to the
|
||||
* wet mix and blended 15% into the FDN input, as in the original.
|
||||
* - Wet saturation, the original's decay-compensated make-up gain, Wet/Dry mix,
|
||||
* and an input-driven ducking envelope.
|
||||
*
|
||||
* Deliberately omitted (per the port request): the graphic/output EQ, lo/hi cut
|
||||
* filters, and all visualizers; stereo width and Pro-mode per-band controls don't
|
||||
* apply to a mono chain.
|
||||
*/
|
||||
class AmbienceReverb(sampleRate: Int) : AudioEffect {
|
||||
|
||||
private val fs = sampleRate.toFloat()
|
||||
|
||||
// ---- delay lines ----
|
||||
private val preDelay = DelayLine((fs * 0.55f).toInt())
|
||||
private val erLine = DelayLine((fs * 0.6f).toInt())
|
||||
private val diffusers = Array(4) { DelayLine((fs * 0.03f).toInt()) }
|
||||
private val fdn = Array(N) { DelayLine((fs * 0.3f).toInt()) }
|
||||
private val apf = Array(N) { DelayLine((fs * 0.03f).toInt()) }
|
||||
|
||||
// ---- FDN feedback state ----
|
||||
private val fbVec = FloatArray(N)
|
||||
private val fbScratch = FloatArray(N)
|
||||
private val nextFb = FloatArray(N)
|
||||
private val baseDelay = FloatArray(N)
|
||||
private val decayGain = FloatArray(N)
|
||||
private val hfState = FloatArray(N)
|
||||
private val lfState = FloatArray(N)
|
||||
private val freqMod = FloatArray(N) { 0.5f + (1f - it / (N - 1f)) }
|
||||
private val apfBase = FloatArray(N) { (2.3f + it * 0.37f) * 0.001f * fs }
|
||||
private val diffuserSmp = FloatArray(4) { (3f + it * 2f) * 0.001f * fs }
|
||||
|
||||
// ---- per-channel noise LFO ----
|
||||
private val lfoState = IntArray(N) { 12345 + it * 9876 }
|
||||
private val lfoSmooth = FloatArray(N)
|
||||
private val lfoCoeff = FloatArray(N)
|
||||
private val lfoRateMul = FloatArray(N) { val a = it * PHI; 0.8f + (a - floor(a)) * 0.4f }
|
||||
|
||||
// ---- resolved routing (recomputed on structural change) ----
|
||||
private var hfCoeff = 1f
|
||||
private var lfCoeff = 0f
|
||||
private var lfAmt = 0f
|
||||
private var apfGain = 0.5f
|
||||
private var diffusionSens = 1f
|
||||
private var bypassER = false
|
||||
private var makeup = 1f
|
||||
private var satMul = 1f
|
||||
private var modDepthScale = 1f
|
||||
private var erCount = 0
|
||||
private val erDelaySmp = FloatArray(MAX_ER)
|
||||
private val erGain = FloatArray(MAX_ER)
|
||||
|
||||
// ---- per-block scalars ----
|
||||
private var wetLin = 0.63f
|
||||
private var dryLin = 1f
|
||||
private var preDelaySmp = 480f
|
||||
private var modDepth = 0f
|
||||
private var diffGain = 0.63f
|
||||
private var effApfGain = 0.4f
|
||||
private var satAmt = 0f
|
||||
private var erLevel = 0.6f
|
||||
// ducking
|
||||
private var duckAmtDb = 0f
|
||||
private var duckThreshDb = -20f
|
||||
private var duckThreshLin = 0.1f
|
||||
private var duckAtt = 0.01f
|
||||
private var duckRel = 0.001f
|
||||
private var duckEnv = 0f
|
||||
|
||||
private var dc1 = 0f
|
||||
private var dcY = 0f
|
||||
|
||||
// Last structural params seen, for a no-allocation dirty check (a String key here
|
||||
// would allocate on the audio thread every block).
|
||||
private var lastAlgo = -1
|
||||
private var lastSize = Float.NaN
|
||||
private var lastDecay = Float.NaN
|
||||
private var lastHf = Float.NaN
|
||||
private var lastLf = Float.NaN
|
||||
private var lastDiffusion = Float.NaN
|
||||
|
||||
override fun update(slot: ToolboxSlot, tempoBpm: Float) {
|
||||
val algo = AmbiencePresets.ALGORITHMS.indexOf(slot.string("algo", "Room 1")).coerceIn(0, 6)
|
||||
val size = slot.float("roomsize", 1f).coerceIn(0.3f, 2f)
|
||||
val decay = slot.float("decay", 1.5f).coerceIn(0.1f, 20f)
|
||||
val hf = slot.float("hfdamp", 0f).coerceIn(0f, 1f)
|
||||
val lf = slot.float("lfabsorb", 0f).coerceIn(0f, 1f)
|
||||
val diffusion = slot.float("diffusion", 0.7f).coerceIn(0f, 1f)
|
||||
|
||||
// Cheap per-block scalars.
|
||||
wetLin = db2lin(slot.float("wet", -4f))
|
||||
dryLin = db2lin(slot.float("dry", 0f))
|
||||
preDelaySmp = slot.float("predelay", 10f).coerceIn(0f, 500f) * 0.001f * fs
|
||||
erLevel = slot.float("erlevel", 0.6f).coerceIn(0f, 1f)
|
||||
satAmt = slot.float("saturation", 0f).coerceIn(0f, 1f)
|
||||
val modAmt = slot.float("modamt", 0.25f).coerceIn(0f, 1f)
|
||||
val modRate = slot.float("modrate", 0.5f).coerceIn(0.05f, 2f)
|
||||
modDepth = modAmt * modAmt * 0.001f * fs * modDepthScale
|
||||
for (i in 0 until N) {
|
||||
lfoCoeff[i] = (1f - exp(-TWO_PI * modRate * lfoRateMul[i] / fs)).coerceIn(0.0001f, 0.9999f)
|
||||
}
|
||||
// Ducking.
|
||||
duckAmtDb = slot.float("duckamt", 0f).coerceIn(0f, 20f)
|
||||
duckThreshDb = slot.float("duckthresh", -20f)
|
||||
duckThreshLin = db2lin(duckThreshDb)
|
||||
duckAtt = 1f - exp(-1f / (fs * slot.float("duckattack", 10f).coerceAtLeast(0.1f) * 0.001f))
|
||||
duckRel = 1f - exp(-1f / (fs * slot.float("duckrelease", 200f).coerceAtLeast(0.1f) * 0.001f))
|
||||
val effDiff = diffusion * diffusionSens
|
||||
diffGain = 0.25f + effDiff * 0.55f
|
||||
effApfGain = apfGain * (0.6f + effDiff * 0.4f) * 0.78f
|
||||
|
||||
// Structural recompute only when a delay/tone-affecting param changes
|
||||
// (primitive compare — no per-block allocation).
|
||||
if (algo != lastAlgo || size != lastSize || decay != lastDecay ||
|
||||
hf != lastHf || lf != lastLf || diffusion != lastDiffusion) {
|
||||
lastAlgo = algo; lastSize = size; lastDecay = decay
|
||||
lastHf = hf; lastLf = lf; lastDiffusion = diffusion
|
||||
recompute(algo, size, decay, hf, lf, diffusion)
|
||||
// effApfGain/diffGain depend on diffusionSens which recompute() may change.
|
||||
val d2 = diffusion * diffusionSens
|
||||
diffGain = 0.25f + d2 * 0.55f
|
||||
effApfGain = apfGain * (0.6f + d2 * 0.4f) * 0.78f
|
||||
}
|
||||
}
|
||||
|
||||
private fun recompute(algo: Int, size: Float, decay: Float, hf: Float, lf: Float, diffusion: Float) {
|
||||
// Topology routing (per the original updateTopologyAndRouting()). Room 1/2
|
||||
// use 0.3; halls 0.618; the non-room algos their own values.
|
||||
when (algo) {
|
||||
0, 1 -> { apfGain = 0.3f; diffusionSens = 1f; bypassER = false }
|
||||
2, 3 -> { apfGain = 0.618f; diffusionSens = 1f; bypassER = false }
|
||||
4 -> { apfGain = 0.7f; diffusionSens = 0.7f; bypassER = true }
|
||||
5 -> { apfGain = 0.5f; diffusionSens = 0.5f; bypassER = true }
|
||||
else -> { apfGain = 0.75f; diffusionSens = 0.8f; bypassER = true }
|
||||
}
|
||||
|
||||
// Prime delay lengths: log-distributed between size-dependent bounds.
|
||||
val sizeCoeff = (size + 0.5f).coerceIn(0.5f, 2f)
|
||||
val minMs = 15f + sizeCoeff * 7.5f
|
||||
val maxMs = 50f + sizeCoeff * 75f
|
||||
val minS = kotlin.math.max(11, (minMs * 0.001f * fs).toInt())
|
||||
val maxS = (maxMs * 0.001f * fs).toInt().coerceAtLeast(minS + 1)
|
||||
val logMin = kotlin.math.ln(minS.toFloat())
|
||||
val logMax = kotlin.math.ln(maxS.toFloat())
|
||||
val used = IntArray(N)
|
||||
for (i in 0 until N) {
|
||||
val t = i / (N - 1f)
|
||||
val target = exp(logMin + t * (logMax - logMin)).toInt()
|
||||
val p = nearestUniquePrime(target, used, i)
|
||||
used[i] = p
|
||||
baseDelay[i] = p.toFloat().coerceAtMost(fdn[i].maxDelay())
|
||||
}
|
||||
|
||||
// Decay → per-channel broadband feedback gain: g = 10^(-3 T / RT60).
|
||||
for (i in 0 until N) {
|
||||
val tSec = baseDelay[i] / fs
|
||||
decayGain[i] = pow10(-3f * tSec / decay).coerceIn(0f, 0.9995f)
|
||||
}
|
||||
|
||||
// HF damping = user + algorithm-intrinsic; maps to a one-pole LP cutoff.
|
||||
val totalHf = (hf + ALGO_HF[algo]).coerceIn(0f, 0.95f)
|
||||
val hfCut = 16000f * (1f - totalHf) + 700f * totalHf
|
||||
hfCoeff = (1f - exp(-TWO_PI * hfCut / fs)).coerceIn(0.02f, 1f)
|
||||
lfCoeff = (1f - exp(-TWO_PI * 200f / fs)).coerceIn(0f, 1f)
|
||||
lfAmt = lf * 0.9f
|
||||
|
||||
// Modulation deepens with longer decay (anti-metallic), as in the original.
|
||||
modDepthScale = 1f + ((decay - 1f) * 0.5f).coerceIn(0f, 2f)
|
||||
|
||||
// Make-up gain: base 16 dB + decay compensation + per-algorithm offset.
|
||||
val decayCompDb = 7f * log10(decay.coerceAtLeast(0.1f))
|
||||
makeup = db2lin(16f + decayCompDb + ALGO_OFFSET_DB[algo])
|
||||
satMul = ALGO_SAT_MUL[algo]
|
||||
|
||||
// Early reflections (Room/Hall only), scaled by room size.
|
||||
val pattern = ER_PATTERNS[algo]
|
||||
erCount = if (bypassER) 0 else minOf(pattern.size / 2, MAX_ER_ACTIVE)
|
||||
val erSizeScale = 0.5f + size
|
||||
for (t in 0 until erCount) {
|
||||
erDelaySmp[t] = (pattern[t * 2] * 0.001f * fs * erSizeScale).coerceIn(1f, erLine.maxDelay())
|
||||
erGain[t] = pattern[t * 2 + 1]
|
||||
}
|
||||
}
|
||||
|
||||
override fun process(x: Float): Float {
|
||||
// Pre-delay feeds both ER and FDN paths.
|
||||
preDelay.write(x)
|
||||
val din = if (preDelaySmp > 0.5f) preDelay.read(preDelaySmp) else x
|
||||
|
||||
// Ducking envelope from the dry input.
|
||||
val peak = abs(x)
|
||||
duckEnv += (peak - duckEnv) * (if (peak > duckEnv) duckAtt else duckRel)
|
||||
var duckGain = 1f
|
||||
if (duckAmtDb > 0.001f && duckEnv > duckThreshLin) {
|
||||
val overDb = 20f * log10(kotlin.math.max(duckEnv, 1e-6f)) - duckThreshDb
|
||||
duckGain = db2lin(-kotlin.math.min(overDb, duckAmtDb))
|
||||
}
|
||||
|
||||
// Input diffuser (4 series allpasses).
|
||||
var fdnIn = din
|
||||
for (i in 0 until 4) {
|
||||
val d = diffusers[i].read(diffuserSmp[i])
|
||||
val w = fdnIn + diffGain * d
|
||||
diffusers[i].write(w)
|
||||
fdnIn = d - diffGain * w
|
||||
}
|
||||
|
||||
// Early reflections.
|
||||
var er = 0f
|
||||
if (!bypassER) {
|
||||
erLine.write(din)
|
||||
for (t in 0 until erCount) er += erLine.read(erDelaySmp[t]) * erGain[t] * 0.5f
|
||||
fdnIn += er * 0.15f
|
||||
}
|
||||
|
||||
// Lossless feedback matrix on the previous frame's output.
|
||||
System.arraycopy(fbVec, 0, fbScratch, 0, N)
|
||||
fwht(fbScratch)
|
||||
for (i in 0 until N) fbScratch[i] *= SIGN_FLIP[i]
|
||||
|
||||
val inPerCh = fdnIn * 0.25f
|
||||
var out = 0f
|
||||
for (i in 0 until N) {
|
||||
val lfo = nextLfo(i)
|
||||
var d = fdn[i].read(baseDelay[i] + lfo * modDepth * freqMod[i])
|
||||
// Broadband decay + HF damping (DC-unity LP) + LF absorption.
|
||||
d *= decayGain[i]
|
||||
hfState[i] += hfCoeff * (d - hfState[i]); d = hfState[i]
|
||||
lfState[i] += lfCoeff * (d - lfState[i]); d -= lfAmt * lfState[i]
|
||||
// Nested allpass (late-field density).
|
||||
val ad = apf[i].read(apfBase[i] + lfo * modDepth * 0.1f * freqMod[i])
|
||||
val aw = d + effApfGain * ad
|
||||
apf[i].write(aw)
|
||||
val apfOut = ad - effApfGain * aw
|
||||
|
||||
nextFb[i] = apfOut
|
||||
fdn[i].write(inPerCh + fbScratch[i])
|
||||
out += apfOut
|
||||
}
|
||||
System.arraycopy(nextFb, 0, fbVec, 0, N)
|
||||
|
||||
// Late field: sum → make-up gain → saturation. LATE_SCALE keeps the summed
|
||||
// level consistent regardless of the FDN size N (so halving N doesn't halve
|
||||
// the wet signal).
|
||||
var late = out * LATE_SCALE * makeup
|
||||
val amt = (satAmt * satMul).coerceIn(0f, 1f)
|
||||
if (amt > 0.0001f) late += amt * (tanh(late * 2f) * 0.5f - late)
|
||||
|
||||
// DC blocker on the wet output.
|
||||
val wetRaw = (if (bypassER) 0f else er * erLevel) + late
|
||||
val dcOut = wetRaw - dc1 + 0.999f * dcY
|
||||
dc1 = wetRaw; dcY = dcOut
|
||||
|
||||
val wet = softClip(dcOut * wetLin * duckGain)
|
||||
return x * dryLin + wet
|
||||
}
|
||||
|
||||
// xorshift white noise → one-pole lowpass, per channel.
|
||||
private fun nextLfo(i: Int): Float {
|
||||
var s = lfoState[i]
|
||||
s = s xor (s shl 13); s = s xor (s ushr 17); s = s xor (s shl 5)
|
||||
lfoState[i] = s
|
||||
val n = (s.toLong() and 0xFFFFFFFFL).toFloat() * 2.3283064e-10f * 2f - 1f
|
||||
lfoSmooth[i] += (n - lfoSmooth[i]) * lfoCoeff[i]
|
||||
return lfoSmooth[i]
|
||||
}
|
||||
|
||||
/** A power-of-two circular buffer with fractional (linear-interpolated) reads. */
|
||||
private class DelayLine(size: Int) {
|
||||
private val buf: FloatArray
|
||||
private val mask: Int
|
||||
private var w = 0
|
||||
init {
|
||||
var p = 1
|
||||
while (p < size) p *= 2
|
||||
buf = FloatArray(p)
|
||||
mask = p - 1
|
||||
}
|
||||
fun maxDelay(): Float = (mask - 2).toFloat()
|
||||
fun write(x: Float) { buf[w] = x; w = (w + 1) and mask }
|
||||
fun read(delay: Float): Float {
|
||||
val d = delay.coerceIn(1f, mask.toFloat() - 1f)
|
||||
val rp = w - d
|
||||
val i0f = floor(rp)
|
||||
val frac = rp - i0f
|
||||
val i0 = i0f.toInt() and mask
|
||||
val i1 = (i0 + 1) and mask
|
||||
return buf[i0] * (1f - frac) + buf[i1] * frac
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// Feedback-delay-network size. The original port used 16 lines, but on a
|
||||
// mid-range phone that render cost (16 interpolated delay reads + nested
|
||||
// allpass + one-poles + LFO per sample, thrashing >1 MB of delay buffers)
|
||||
// overran the real-time budget on its own. 8 lines halve the dominant loop
|
||||
// while still giving a dense, smooth tail. FWHT stays orthonormal via
|
||||
// FWHT_NORM = 1/sqrt(N), so the loop remains lossless.
|
||||
const val N = 8
|
||||
val FWHT_NORM = 1f / kotlin.math.sqrt(N.toFloat())
|
||||
// Late-sum scale, level-matched to the original 16-line version (16→0.125).
|
||||
val LATE_SCALE = 2f / N
|
||||
const val MAX_ER = 12
|
||||
// Cap on early-reflection taps evaluated per sample (Room/Hall). The patterns
|
||||
// list up to 12; the later, quietest taps cost more than they add.
|
||||
const val MAX_ER_ACTIVE = 6
|
||||
const val PHI = 1.6180339887f
|
||||
const val TWO_PI = 6.28318530718f
|
||||
|
||||
// Per-algorithm intrinsic HF damping, derived from the plugin's measured
|
||||
// RT60 curves (rt60[8kHz]/rt60[500Hz]): darker spaces damp highs more.
|
||||
val ALGO_HF = floatArrayOf(0.00f, 0.26f, 0.26f, 0.22f, 0.36f, 0.14f, 0.30f)
|
||||
// Make-up gain offsets and saturation multipliers, per the original.
|
||||
val ALGO_OFFSET_DB = floatArrayOf(0.8f, 0.9f, 0.5f, 0.5f, 1.5f, 0.6f, 0.6f)
|
||||
val ALGO_SAT_MUL = floatArrayOf(0.90f, 0.90f, 0.93f, 0.93f, 1.00f, 1.05f, 1.02f)
|
||||
|
||||
// Fixed sign-flip pattern for the FWHT feedback matrix (one per FDN line).
|
||||
val SIGN_FLIP = floatArrayOf(
|
||||
1f, -1f, 1f, -1f, -1f, 1f, -1f, 1f,
|
||||
)
|
||||
|
||||
// ISM early-reflection tap patterns (delayMs, gain)… per algorithm. Plate,
|
||||
// Spring and Goldfoil are non-rooms → no ER (empty).
|
||||
val ER_PATTERNS: Array<FloatArray> = arrayOf(
|
||||
// Room 1
|
||||
floatArrayOf(
|
||||
5.2f, 0.65f, 8.7f, 0.58f, 12.4f, 0.52f, 15.8f, 0.46f, 19.3f, 0.42f, 24.1f, 0.36f,
|
||||
28.6f, 0.32f, 33.5f, 0.28f, 38.9f, 0.24f, 45.2f, 0.20f, 52.8f, 0.16f, 62.4f, 0.13f,
|
||||
),
|
||||
// Room 2
|
||||
floatArrayOf(
|
||||
7.5f, 0.62f, 12.3f, 0.55f, 17.1f, 0.49f, 22.8f, 0.43f, 28.5f, 0.38f, 34.2f, 0.33f,
|
||||
41.6f, 0.28f, 49.3f, 0.24f, 57.8f, 0.20f, 67.5f, 0.17f, 78.2f, 0.14f, 90.6f, 0.11f,
|
||||
),
|
||||
// Hall 1
|
||||
floatArrayOf(
|
||||
12.0f, 0.58f, 18.5f, 0.52f, 25.7f, 0.47f, 33.4f, 0.42f, 42.1f, 0.38f, 51.6f, 0.33f,
|
||||
62.3f, 0.29f, 73.9f, 0.25f, 86.5f, 0.21f, 99.8f, 0.18f, 113.4f, 0.15f, 128.7f, 0.12f,
|
||||
),
|
||||
// Hall 2
|
||||
floatArrayOf(
|
||||
16.5f, 0.55f, 24.8f, 0.50f, 33.7f, 0.45f, 43.5f, 0.40f, 54.2f, 0.36f, 65.8f, 0.32f,
|
||||
78.4f, 0.28f, 92.1f, 0.24f, 107.3f, 0.21f, 123.6f, 0.18f, 141.2f, 0.15f, 160.5f, 0.12f,
|
||||
),
|
||||
floatArrayOf(), // Plate
|
||||
floatArrayOf(), // Spring
|
||||
floatArrayOf(), // Goldfoil
|
||||
)
|
||||
|
||||
fun db2lin(db: Float): Float = pow10(db / 20f)
|
||||
fun pow10(x: Float): Float = exp(x * 2.302585093f)
|
||||
|
||||
/** In-place size-N Fast Walsh-Hadamard transform, normalised (÷√N) → orthonormal. */
|
||||
fun fwht(v: FloatArray) {
|
||||
var h = 1
|
||||
while (h < N) {
|
||||
var i = 0
|
||||
while (i < N) {
|
||||
var j = i
|
||||
while (j < i + h) {
|
||||
val a = v[j]; val b = v[j + h]
|
||||
v[j] = a + b; v[j + h] = a - b
|
||||
j++
|
||||
}
|
||||
i += h * 2
|
||||
}
|
||||
h *= 2
|
||||
}
|
||||
for (k in 0 until N) v[k] *= FWHT_NORM
|
||||
}
|
||||
|
||||
fun isPrime(n: Int): Boolean {
|
||||
if (n < 2) return false
|
||||
if (n == 2) return true
|
||||
if (n % 2 == 0) return false
|
||||
var i = 3
|
||||
while (i * i <= n) { if (n % i == 0) return false; i += 2 }
|
||||
return true
|
||||
}
|
||||
|
||||
fun nearestUniquePrime(target: Int, used: IntArray, count: Int): Int {
|
||||
val t = kotlin.math.max(target, 2)
|
||||
var offset = 0
|
||||
while (offset < 100000) {
|
||||
val hi = t + offset
|
||||
if (isPrime(hi) && (0 until count).none { used[it] == hi }) return hi
|
||||
val lo = t - offset
|
||||
if (offset > 0 && lo >= 2 && isPrime(lo) && (0 until count).none { used[it] == lo }) return lo
|
||||
offset++
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
fun softClip(x: Float): Float = when {
|
||||
x > 1f -> 1f - 1f / (x + 1f)
|
||||
x < -1f -> -1f + 1f / (-x + 1f)
|
||||
else -> x
|
||||
}
|
||||
}
|
||||
}
|
||||
1154
app/src/main/java/space/rcmd/android/sizzle/audio/AudioEngine.kt
Normal file
1154
app/src/main/java/space/rcmd/android/sizzle/audio/AudioEngine.kt
Normal file
File diff suppressed because it is too large
Load Diff
324
app/src/main/java/space/rcmd/android/sizzle/audio/Effects.kt
Normal file
324
app/src/main/java/space/rcmd/android/sizzle/audio/Effects.kt
Normal file
@@ -0,0 +1,324 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.audio
|
||||
|
||||
import space.rcmd.android.sizzle.model.DelayDivisions
|
||||
import space.rcmd.android.sizzle.model.ToolboxSlot
|
||||
import space.rcmd.android.sizzle.model.ToolboxType
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.exp
|
||||
import kotlin.math.max
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.tanh
|
||||
|
||||
/**
|
||||
* Real-time audio effect processors used by the mixer's per-channel insert
|
||||
* chains. Each effect reads its settings from the owning [ToolboxSlot] once per
|
||||
* audio block (via [update]) and then processes the signal one sample at a time
|
||||
* (via [process]) on the audio thread — so [process] must never allocate.
|
||||
*
|
||||
* The MIDI effects (arpeggiator / transposer / LFO) are NOT here: they alter note
|
||||
* generation rather than the audio signal, and hook into the sequencer instead.
|
||||
* Only the signal-processing effect types return a processor from [create].
|
||||
*/
|
||||
interface AudioEffect {
|
||||
/** Refresh cached parameters from the slot. Called once per block. */
|
||||
fun update(slot: ToolboxSlot, tempoBpm: Float)
|
||||
/** Process one mono sample. */
|
||||
fun process(x: Float): Float
|
||||
|
||||
companion object {
|
||||
fun create(type: ToolboxType, sampleRate: Int): AudioEffect? = when (type) {
|
||||
ToolboxType.DELAY -> TapeDelay(sampleRate)
|
||||
ToolboxType.AMBIENCE -> AmbienceReverb(sampleRate)
|
||||
ToolboxType.FILTER -> BiquadFilterEffect(sampleRate)
|
||||
ToolboxType.BITCRUSHER -> Bitcrusher()
|
||||
ToolboxType.EQ10 -> GraphicEq(sampleRate)
|
||||
else -> null // arpeggiator / transposer / LFO are handled by the sequencer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multi-tap tape delay: one shared delay line read by up to four independent
|
||||
// "tape heads", each with its own tempo-synced division and on/off switch. A tape
|
||||
// character control adds saturation, per-repeat darkening and wow/flutter.
|
||||
//
|
||||
// The wet signal is the AVERAGE of the active heads and feedback is hard-capped
|
||||
// below 1, so the round-trip loop gain can never exceed the feedback amount — a
|
||||
// runaway positive-feedback loop is impossible regardless of how many heads are on.
|
||||
// ---------------------------------------------------------------------------
|
||||
private class TapeDelay(private val sampleRate: Int) : AudioEffect {
|
||||
private val buffer = FloatArray(sampleRate * 4) // up to 4 s of delay line
|
||||
private var writeIndex = 0
|
||||
private val delaySamples = IntArray(DelayDivisions.HEADS) { sampleRate / 2 }
|
||||
private val headOn = BooleanArray(DelayDivisions.HEADS)
|
||||
private var feedback = 0.4f
|
||||
private var dryWet = 0.35f
|
||||
// Tape character:
|
||||
private var satBlend = 0f // 0 = clean, 1 = full soft saturation on peaks
|
||||
private var lpAlpha = 1f // feedback-path one-pole LP: 1 = bright, →0 = dark
|
||||
private var lpState = 0f
|
||||
private var flutterInc = 0.0
|
||||
private var flutterDepth = 0f
|
||||
private var flutterPhase = 0.0
|
||||
|
||||
override fun update(slot: ToolboxSlot, tempoBpm: Float) {
|
||||
val samplesPerBeat = (60.0 / tempoBpm) * sampleRate
|
||||
for (h in 0 until DelayDivisions.HEADS) {
|
||||
// Precomputed key strings — building "head${h}On" here would allocate on
|
||||
// the audio thread every block (this runs from fillBlock).
|
||||
headOn[h] = slot.float(HEAD_ON_KEYS[h], if (h == 0) 1f else 0f) >= 0.5f
|
||||
val idx = slot.float(HEAD_DIV_KEYS[h], DelayDivisions.DEFAULT.toFloat()).toInt()
|
||||
delaySamples[h] = (DelayDivisions.beatFraction(idx) * samplesPerBeat)
|
||||
.toInt().coerceIn(1, buffer.size - 2)
|
||||
}
|
||||
feedback = slot.float("feedback", 0.4f).coerceIn(0f, DelayDivisions.MAX_FEEDBACK)
|
||||
dryWet = slot.float("drywet", 0.35f).coerceIn(0f, 1f)
|
||||
val tape = slot.float("tape", 0.3f).coerceIn(0f, 1f)
|
||||
satBlend = tape // more tape → more saturation
|
||||
lpAlpha = 1f - tape * 0.85f // more tape → darker repeats
|
||||
flutterInc = 2.0 * PI * FLUTTER_HZ / sampleRate
|
||||
flutterDepth = tape * sampleRate * 0.0015f // up to ~1.5 ms wow/flutter
|
||||
}
|
||||
|
||||
override fun process(x: Float): Float {
|
||||
flutterPhase += flutterInc
|
||||
if (flutterPhase > 2 * PI) flutterPhase -= 2 * PI
|
||||
val flutter = sin(flutterPhase).toFloat() * flutterDepth
|
||||
|
||||
// Read each active head (fractional read + linear interpolation for flutter).
|
||||
// The wet signal is the AVERAGE of the active heads, so the summed level — and
|
||||
// the feedback loop gain — stays independent of how many heads are on.
|
||||
var wet = 0f
|
||||
var active = 0
|
||||
for (h in 0 until DelayDivisions.HEADS) {
|
||||
if (!headOn[h]) continue
|
||||
active++
|
||||
var pos = writeIndex - delaySamples[h] - flutter
|
||||
while (pos < 0f) pos += buffer.size
|
||||
val i0 = pos.toInt() % buffer.size
|
||||
val i1 = (i0 + 1) % buffer.size
|
||||
val frac = pos - pos.toInt()
|
||||
wet += buffer[i0] + (buffer[i1] - buffer[i0]) * frac
|
||||
}
|
||||
if (active > 0) wet /= active
|
||||
|
||||
var fb = wet * feedback
|
||||
fb += satBlend * (tanh(fb) - fb)
|
||||
lpState += lpAlpha * (fb - lpState)
|
||||
fb = lpState
|
||||
|
||||
buffer[writeIndex] = x + fb
|
||||
writeIndex = (writeIndex + 1) % buffer.size
|
||||
return x * (1f - dryWet) + wet * dryWet
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val FLUTTER_HZ = 5.0
|
||||
val HEAD_ON_KEYS = Array(DelayDivisions.HEADS) { "head${it}On" }
|
||||
val HEAD_DIV_KEYS = Array(DelayDivisions.HEADS) { "head${it}Div" }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RBJ biquad filter with selectable LP / HP / BP response, an ADSR cutoff envelope
|
||||
// (triggered by signal activity on the channel) and analog-style warmth saturation.
|
||||
// ---------------------------------------------------------------------------
|
||||
private class BiquadFilterEffect(private val sampleRate: Int) : AudioEffect {
|
||||
private var b0 = 1.0; private var b1 = 0.0; private var b2 = 0.0
|
||||
private var a1 = 0.0; private var a2 = 0.0
|
||||
private var z1 = 0.0; private var z2 = 0.0 // transposed direct form II state
|
||||
|
||||
private val sr = sampleRate.toFloat()
|
||||
private var type = "LPF"
|
||||
private var baseCutoff = 0.7f
|
||||
private var q = 1.0
|
||||
private var warmth = 0f
|
||||
private var envAmount = 0f
|
||||
|
||||
// Cutoff ADSR (0..1), retriggered by an input-level gate.
|
||||
private enum class Phase { IDLE, ATTACK, DECAY, SUSTAIN, RELEASE }
|
||||
private var phase = Phase.IDLE
|
||||
private var env = 0f
|
||||
private var attackInc = 1f
|
||||
private var decayInc = 1f
|
||||
private var sustainLevel = 1f
|
||||
private var releaseSeconds = 0.2f
|
||||
private var releaseInc = 1f
|
||||
private var levelEnv = 0f
|
||||
private var levelCoeff = 0f
|
||||
private var gateOpen = false
|
||||
private var ctrl = 0
|
||||
|
||||
override fun update(slot: ToolboxSlot, tempoBpm: Float) {
|
||||
type = slot.string("type", "LPF")
|
||||
baseCutoff = slot.float("cutoff", 0.7f).coerceIn(0f, 1f)
|
||||
q = (0.5 + slot.float("resonance", 0.2f) * 8f).toDouble()
|
||||
warmth = slot.float("warmth", 0f).coerceIn(0f, 1f)
|
||||
envAmount = slot.float("envamt", 0f).coerceIn(0f, 1f)
|
||||
sustainLevel = slot.float("sustain", 1f).coerceIn(0f, 1f)
|
||||
attackInc = 1f / (max(slot.float("attack", 0.005f), 0.0005f) * sr)
|
||||
val decay = slot.float("decay", 0.10f)
|
||||
decayInc = if (decay <= 0f) 1f else (1f - sustainLevel) / (decay * sr)
|
||||
releaseSeconds = slot.float("release", 0.20f)
|
||||
levelCoeff = 1f - exp(-1f / (sr * 0.005f)) // ~5 ms input-level follower
|
||||
ctrl = 0 // force a coefficient refresh on the next sample
|
||||
}
|
||||
|
||||
private fun computeCoefficients(type: String, freq: Double, q: Double) {
|
||||
val w0 = 2.0 * PI * freq / sampleRate
|
||||
val cosW = cos(w0)
|
||||
val alpha = sin(w0) / (2.0 * q)
|
||||
val a0: Double
|
||||
when (type) {
|
||||
"HPF" -> {
|
||||
b0 = (1 + cosW) / 2; b1 = -(1 + cosW); b2 = (1 + cosW) / 2
|
||||
a0 = 1 + alpha; a1 = -2 * cosW; a2 = 1 - alpha
|
||||
}
|
||||
"BPF" -> {
|
||||
b0 = alpha; b1 = 0.0; b2 = -alpha
|
||||
a0 = 1 + alpha; a1 = -2 * cosW; a2 = 1 - alpha
|
||||
}
|
||||
else -> { // LPF
|
||||
b0 = (1 - cosW) / 2; b1 = 1 - cosW; b2 = (1 - cosW) / 2
|
||||
a0 = 1 + alpha; a1 = -2 * cosW; a2 = 1 - alpha
|
||||
}
|
||||
}
|
||||
// Normalise by a0.
|
||||
b0 /= a0; b1 /= a0; b2 /= a0; a1 /= a0; a2 /= a0
|
||||
}
|
||||
|
||||
override fun process(x: Float): Float {
|
||||
// Gate the ADSR from the input level (hysteresis avoids chatter). A rising
|
||||
// edge above GATE_HI (re)triggers attack; falling below GATE_LO releases.
|
||||
levelEnv += (abs(x) - levelEnv) * levelCoeff
|
||||
if (!gateOpen && levelEnv > GATE_HI) {
|
||||
gateOpen = true; phase = Phase.ATTACK
|
||||
} else if (gateOpen && levelEnv < GATE_LO) {
|
||||
gateOpen = false
|
||||
releaseInc = if (releaseSeconds <= 0f) 1f else env / (releaseSeconds * sr)
|
||||
if (releaseInc <= 0f) releaseInc = 1f
|
||||
phase = Phase.RELEASE
|
||||
}
|
||||
when (phase) {
|
||||
Phase.ATTACK -> { env += attackInc; if (env >= 1f) { env = 1f; phase = Phase.DECAY } }
|
||||
Phase.DECAY -> { env -= decayInc; if (env <= sustainLevel) { env = sustainLevel; phase = Phase.SUSTAIN } }
|
||||
Phase.SUSTAIN -> {}
|
||||
Phase.RELEASE -> { env -= releaseInc; if (env <= 0f) { env = 0f; phase = Phase.IDLE } }
|
||||
Phase.IDLE -> {}
|
||||
}
|
||||
|
||||
// Recompute the biquad at control rate as the envelope opens the cutoff (the
|
||||
// envelope lifts the cutoff from its base toward fully open by Env Amount).
|
||||
if (ctrl <= 0) {
|
||||
ctrl = CTRL_INTERVAL
|
||||
val eff = (baseCutoff + envAmount * env * (1f - baseCutoff)).coerceIn(0f, 1f)
|
||||
val freq = 20.0 * 900.0.pow(eff.toDouble())
|
||||
computeCoefficients(type, freq.coerceIn(20.0, sampleRate * 0.45), q)
|
||||
}
|
||||
ctrl--
|
||||
|
||||
val input = x.toDouble()
|
||||
val out = b0 * input + z1
|
||||
z1 = b1 * input - a1 * out + z2
|
||||
z2 = b2 * input - a2 * out
|
||||
var y = out.toFloat()
|
||||
|
||||
// Warmth: soft saturation blended in so the small-signal gain (and level)
|
||||
// stays put — it only rounds off louder peaks, adding gentle harmonics.
|
||||
if (warmth > 0.0001f) y += warmth * (tanh(y * 1.8f) / 1.8f - y)
|
||||
return y
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val CTRL_INTERVAL = 32 // samples between cutoff-coefficient refreshes
|
||||
const val GATE_HI = 0.010f // input level that opens the envelope gate
|
||||
const val GATE_LO = 0.004f // input level that releases it (hysteresis)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bitcrusher: bit-depth reduction + sample-rate reduction + drive.
|
||||
// ---------------------------------------------------------------------------
|
||||
private class Bitcrusher : AudioEffect {
|
||||
private var levels = 256f
|
||||
private var downsample = 1
|
||||
private var drive = 1f
|
||||
private var counter = 0
|
||||
private var held = 0f
|
||||
|
||||
override fun update(slot: ToolboxSlot, tempoBpm: Float) {
|
||||
val bits = slot.float("bits", 8f).coerceIn(1f, 16f)
|
||||
levels = 2f.pow(bits)
|
||||
downsample = slot.float("downsample", 1f).toInt().coerceAtLeast(1)
|
||||
drive = 1f + slot.float("drive", 0.2f) * 4f
|
||||
}
|
||||
|
||||
override fun process(x: Float): Float {
|
||||
if (counter <= 0) {
|
||||
counter = downsample
|
||||
// Quantise to the reduced bit depth after applying drive.
|
||||
val driven = tanh(x * drive)
|
||||
held = (driven * levels).roundToInt() / levels
|
||||
}
|
||||
counter--
|
||||
return held
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 10-band graphic EQ: ten peaking biquads at octave-spaced centre frequencies.
|
||||
// ---------------------------------------------------------------------------
|
||||
private class GraphicEq(private val sampleRate: Int) : AudioEffect {
|
||||
private val centers = floatArrayOf(31f, 63f, 125f, 250f, 500f, 1000f, 2000f, 4000f, 8000f, 16000f)
|
||||
private val bands = Array(10) { PeakBiquad() }
|
||||
// Precomputed keys — "band$i" would allocate on the audio thread every block.
|
||||
private val bandKeys = Array(10) { "band$it" }
|
||||
|
||||
override fun update(slot: ToolboxSlot, tempoBpm: Float) {
|
||||
for (i in bands.indices) {
|
||||
val gainDb = slot.float(bandKeys[i], 0f).coerceIn(-1f, 1f) * 12f // -12..+12 dB
|
||||
bands[i].set(sampleRate, centers[i], gainDb)
|
||||
}
|
||||
}
|
||||
|
||||
override fun process(x: Float): Float {
|
||||
var y = x
|
||||
for (b in bands) y = b.process(y)
|
||||
return y
|
||||
}
|
||||
|
||||
private class PeakBiquad {
|
||||
private var b0 = 1.0; private var b1 = 0.0; private var b2 = 0.0
|
||||
private var a1 = 0.0; private var a2 = 0.0
|
||||
private var z1 = 0.0; private var z2 = 0.0
|
||||
|
||||
fun set(sampleRate: Int, freq: Float, gainDb: Float) {
|
||||
if (freq >= sampleRate * 0.45f) return
|
||||
val a = 10.0.pow(gainDb / 40.0)
|
||||
val w0 = 2.0 * PI * freq / sampleRate
|
||||
val cosW = cos(w0)
|
||||
val alpha = sin(w0) / (2.0 * 1.0) // Q ~ 1 per band
|
||||
val a0 = 1 + alpha / a
|
||||
b0 = (1 + alpha * a) / a0
|
||||
b1 = (-2 * cosW) / a0
|
||||
b2 = (1 - alpha * a) / a0
|
||||
a1 = (-2 * cosW) / a0
|
||||
a2 = (1 - alpha / a) / a0
|
||||
}
|
||||
|
||||
fun process(x: Float): Float {
|
||||
val input = x.toDouble()
|
||||
val out = b0 * input + z1
|
||||
z1 = b1 * input - a1 * out + z2
|
||||
z2 = b2 * input - a2 * out
|
||||
return out.toFloat()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.audio
|
||||
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
/**
|
||||
* Records the engine's master mono output to a 16-bit PCM WAV file.
|
||||
*
|
||||
* The audio thread pushes one float per output sample via [write] into a
|
||||
* preallocated ring buffer (single producer). A dedicated writer thread drains the
|
||||
* ring to a temp WAV file (single consumer), so NO file I/O or allocation ever
|
||||
* happens on the audio thread — the whole point, given how sensitive this engine is
|
||||
* to audio-thread stalls.
|
||||
*
|
||||
* Lifecycle: [begin] opens the temp file and starts the writer. [endCapture] tells
|
||||
* the writer to drain whatever is left, patch the WAV header sizes (via a
|
||||
* RandomAccessFile seek — a `content://` stream isn't seekable, which is why we
|
||||
* stage to a local temp file first) and then invoke the completion callback with the
|
||||
* finished file. The caller copies that temp file to its final destination.
|
||||
*
|
||||
* The ring is single-producer / single-consumer with monotonically increasing
|
||||
* positions, so plain @Volatile longs give correct visibility without locks:
|
||||
* the audio thread only writes [writePos] and reads [readPos]; the writer thread
|
||||
* only writes [readPos] and reads [writePos].
|
||||
*/
|
||||
class MasterRecorder(private val sampleRate: Int) {
|
||||
|
||||
// Interleaved stereo (L,R,L,R…); ~1 s of headroom. Positions count samples.
|
||||
private val ring = FloatArray((sampleRate * 2).coerceAtLeast(2))
|
||||
@Volatile private var writePos = 0L // samples produced by the audio thread
|
||||
@Volatile private var readPos = 0L // samples consumed by the writer thread
|
||||
@Volatile private var capturing = false
|
||||
@Volatile private var active = false // begin()..completion; guards re-entry
|
||||
private var writer: Thread? = null
|
||||
|
||||
/** True from [begin] until the writer thread has fully finalized the file. */
|
||||
val isActive: Boolean get() = active
|
||||
|
||||
/**
|
||||
* Start a new recording into [temp]. Returns false if one is already running
|
||||
* (so a fast replay during a previous take's tail can't clobber it).
|
||||
* [onComplete] runs on the writer thread with the finished file (or null on
|
||||
* failure).
|
||||
*/
|
||||
fun begin(temp: File, onComplete: (File?) -> Unit): Boolean {
|
||||
if (active) return false
|
||||
writePos = 0L
|
||||
readPos = 0L
|
||||
active = true
|
||||
capturing = true
|
||||
writer = thread(name = "sizzle-master-rec", isDaemon = true) {
|
||||
val file = runCatching { writeLoop(temp) }.getOrNull()
|
||||
active = false
|
||||
onComplete(file)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Audio thread: append one stereo frame (L,R). Never allocates; drops the frame
|
||||
* if the ring is full (only possible if the writer thread starved). */
|
||||
fun write(left: Float, right: Float) {
|
||||
if (!capturing) return
|
||||
val w = writePos
|
||||
if (w - readPos > ring.size - 2) return
|
||||
ring[(w % ring.size).toInt()] = left
|
||||
ring[((w + 1) % ring.size).toInt()] = right
|
||||
writePos = w + 2
|
||||
}
|
||||
|
||||
/** Audio thread: stop accepting samples; the writer drains the rest and finalizes. */
|
||||
fun endCapture() { capturing = false }
|
||||
|
||||
private fun writeLoop(temp: File): File {
|
||||
val raf = RandomAccessFile(temp, "rw")
|
||||
try {
|
||||
raf.setLength(0)
|
||||
writeHeader(raf, 0) // placeholder sizes; patched at the end
|
||||
var samples = 0L // total interleaved 16-bit samples written
|
||||
val chunk = 4096
|
||||
val bytes = ByteArray(chunk * 2)
|
||||
while (true) {
|
||||
val w = writePos
|
||||
val r = readPos
|
||||
if (r < w) {
|
||||
val n = minOf(w - r, chunk.toLong()).toInt()
|
||||
for (i in 0 until n) {
|
||||
val s = ring[((r + i) % ring.size).toInt()].coerceIn(-1f, 1f)
|
||||
val v = (s * 32767f).toInt()
|
||||
bytes[i * 2] = (v and 0xFF).toByte()
|
||||
bytes[i * 2 + 1] = ((v shr 8) and 0xFF).toByte()
|
||||
}
|
||||
raf.write(bytes, 0, n * 2)
|
||||
samples += n
|
||||
readPos = r + n
|
||||
} else if (!capturing) {
|
||||
break // capture ended and the ring is fully drained
|
||||
} else {
|
||||
Thread.sleep(5) // wait for the audio thread to produce more
|
||||
}
|
||||
}
|
||||
writeHeader(raf, samples) // rewrite the 44-byte header with the real sizes
|
||||
} finally {
|
||||
raf.close()
|
||||
}
|
||||
return temp
|
||||
}
|
||||
|
||||
// ---- WAV (stereo, 16-bit PCM) header. [sampleCount] = total interleaved samples. ----
|
||||
private fun writeHeader(raf: RandomAccessFile, sampleCount: Long) {
|
||||
val dataSize = sampleCount * 2 // 2 bytes per sample
|
||||
raf.seek(0)
|
||||
raf.writeBytes("RIFF")
|
||||
writeIntLE(raf, (36 + dataSize).toInt())
|
||||
raf.writeBytes("WAVE")
|
||||
raf.writeBytes("fmt ")
|
||||
writeIntLE(raf, 16) // PCM fmt chunk size
|
||||
writeShortLE(raf, 1) // format = PCM
|
||||
writeShortLE(raf, 2) // channels = stereo
|
||||
writeIntLE(raf, sampleRate)
|
||||
writeIntLE(raf, sampleRate * 4) // byte rate = sampleRate * channels * bytesPerSample
|
||||
writeShortLE(raf, 4) // block align = channels * bytesPerSample
|
||||
writeShortLE(raf, 16) // bits per sample
|
||||
raf.writeBytes("data")
|
||||
writeIntLE(raf, dataSize.toInt())
|
||||
}
|
||||
|
||||
private fun writeIntLE(raf: RandomAccessFile, v: Int) {
|
||||
raf.write(v and 0xFF); raf.write((v shr 8) and 0xFF)
|
||||
raf.write((v shr 16) and 0xFF); raf.write((v shr 24) and 0xFF)
|
||||
}
|
||||
|
||||
private fun writeShortLE(raf: RandomAccessFile, v: Int) {
|
||||
raf.write(v and 0xFF); raf.write((v shr 8) and 0xFF)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.audio
|
||||
|
||||
/**
|
||||
* Bridges the native Oboe (AAudio) output backend to the Kotlin [AudioEngine].
|
||||
*
|
||||
* The C++ side (see `src/main/cpp/native_audio.cpp`) opens a low-latency Oboe
|
||||
* stream whose real-time data callback calls [renderAudio] via JNI, which simply
|
||||
* delegates to [AudioEngine.fillBlock]. So the exact same synth/sequencer/mixer
|
||||
* code produces the audio; only the *delivery* path changes (a native callback
|
||||
* pulling data, vs. AudioTrack blocking writes), which is what lowers latency.
|
||||
*
|
||||
* If the native library fails to load (e.g. the NDK build was skipped) the whole
|
||||
* feature is inert and the engine transparently uses its AudioTrack loop.
|
||||
*/
|
||||
class NativeAudioBridge(private val engine: AudioEngine) {
|
||||
|
||||
/** Invoked from the native audio callback thread. Must not allocate. */
|
||||
@Suppress("unused") // called via JNI
|
||||
fun renderAudio(buffer: FloatArray, frames: Int) {
|
||||
engine.fillBlock(buffer, frames)
|
||||
}
|
||||
|
||||
fun start(sampleRate: Int, framesPerCallback: Int): Boolean =
|
||||
if (LIB_LOADED) nativeStart(this, sampleRate, framesPerCallback) else false
|
||||
|
||||
fun stop() {
|
||||
if (LIB_LOADED) nativeStop()
|
||||
}
|
||||
|
||||
private external fun nativeStart(callback: NativeAudioBridge, sampleRate: Int, framesPerCallback: Int): Boolean
|
||||
private external fun nativeStop()
|
||||
|
||||
companion object {
|
||||
private val LIB_LOADED = runCatching { System.loadLibrary("sizzle_native") }.isSuccess
|
||||
|
||||
/** Whether the native Oboe backend is available on this build/device. */
|
||||
fun isAvailable(): Boolean = LIB_LOADED
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.audio
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaRecorder
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
/**
|
||||
* Ad-hoc sample recorder. Captures audio from the microphone (or a connected USB
|
||||
* audio input, which the platform routes to the default source) into a mono float
|
||||
* buffer that becomes a [SampleStore.Sample].
|
||||
*
|
||||
* Requires the RECORD_AUDIO permission (requested at app launch). Recording runs
|
||||
* on its own thread so it never blocks the UI or the playback engine.
|
||||
*/
|
||||
class SampleRecorder(private val sampleRate: Int = 44_100) {
|
||||
|
||||
@Volatile private var recording = false
|
||||
private var worker: Thread? = null
|
||||
private val chunks = ArrayList<FloatArray>()
|
||||
|
||||
/** Preferred capture device (e.g. a USB audio input), or null for the default. */
|
||||
var preferredInput: android.media.AudioDeviceInfo? = null
|
||||
|
||||
val isRecording: Boolean get() = recording
|
||||
|
||||
@SuppressLint("MissingPermission") // caller ensures RECORD_AUDIO is granted
|
||||
fun start(): Boolean {
|
||||
if (recording) return true
|
||||
val minBuf = AudioRecord.getMinBufferSize(
|
||||
sampleRate, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_FLOAT,
|
||||
)
|
||||
if (minBuf <= 0) return false
|
||||
val record = AudioRecord(
|
||||
MediaRecorder.AudioSource.MIC,
|
||||
sampleRate, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_FLOAT,
|
||||
minBuf * 2,
|
||||
)
|
||||
if (record.state != AudioRecord.STATE_INITIALIZED) { record.release(); return false }
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) {
|
||||
record.setPreferredDevice(preferredInput)
|
||||
}
|
||||
|
||||
chunks.clear()
|
||||
recording = true
|
||||
record.startRecording()
|
||||
worker = thread(name = "sizzle-rec") {
|
||||
val buf = FloatArray(minBuf)
|
||||
while (recording) {
|
||||
val n = record.read(buf, 0, buf.size, AudioRecord.READ_BLOCKING)
|
||||
if (n > 0) chunks.add(buf.copyOf(n))
|
||||
// Safety cap: stop after ~30 s to avoid unbounded memory.
|
||||
if (chunks.sumOf { it.size } > sampleRate * 30) recording = false
|
||||
}
|
||||
record.stop()
|
||||
record.release()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Stop and return the recorded audio (null if nothing was captured). */
|
||||
fun stop(): SampleStore.Sample? {
|
||||
if (!recording && worker == null) return null
|
||||
recording = false
|
||||
worker?.join(1000)
|
||||
worker = null
|
||||
val total = chunks.sumOf { it.size }
|
||||
if (total == 0) return null
|
||||
val out = FloatArray(total)
|
||||
var pos = 0
|
||||
for (c in chunks) { c.copyInto(out, pos); pos += c.size }
|
||||
chunks.clear()
|
||||
return SampleStore.Sample(out, sampleRate)
|
||||
}
|
||||
}
|
||||
143
app/src/main/java/space/rcmd/android/sizzle/audio/SampleStore.kt
Normal file
143
app/src/main/java/space/rcmd/android/sizzle/audio/SampleStore.kt
Normal file
@@ -0,0 +1,143 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.audio
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* A process-wide cache of decoded audio samples used by the Sampler instrument.
|
||||
*
|
||||
* Decoding (reading a file, parsing WAV) happens on a normal thread in the UI
|
||||
* layer, which then [put]s the finished [Sample] here under a string id. The
|
||||
* audio thread only ever [get]s already-decoded samples — it never touches the
|
||||
* filesystem — so real-time playback stays allocation- and IO-free.
|
||||
*
|
||||
* The id stored in a toolbox slot's `samplePath` parameter is the key here.
|
||||
*/
|
||||
object SampleStore {
|
||||
/** Decoded mono PCM plus the rate it was recorded at. */
|
||||
class Sample(val data: FloatArray, val sampleRate: Int)
|
||||
|
||||
private val samples = ConcurrentHashMap<String, Sample>()
|
||||
|
||||
fun put(id: String, sample: Sample) { samples[id] = sample }
|
||||
fun get(id: String): Sample? = if (id.isEmpty()) null else samples[id]
|
||||
|
||||
/**
|
||||
* Decode a WAV file (the common uncompressed formats) into a mono [Sample].
|
||||
* Supports 8/16/24-bit PCM and 32-bit float, mono or stereo (stereo is
|
||||
* down-mixed). Returns null if the bytes are not a WAV we understand.
|
||||
*/
|
||||
fun decodeWav(bytes: ByteArray): Sample? = try {
|
||||
decodeWavChecked(bytes)
|
||||
} catch (_: Exception) {
|
||||
// Any malformed/truncated file (bad chunk sizes, short buffers, …) is
|
||||
// rejected rather than crashing the caller — content validation.
|
||||
null
|
||||
}
|
||||
|
||||
private fun decodeWavChecked(bytes: ByteArray): Sample? {
|
||||
if (bytes.size < 44) return null
|
||||
val bb = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN)
|
||||
if (bytes[0].toInt().toChar() != 'R' || bytes[1].toInt().toChar() != 'I') return null // "RIFF"
|
||||
bb.position(8)
|
||||
if (readTag(bb) != "WAVE") return null
|
||||
|
||||
var audioFormat = 1
|
||||
var channels = 1
|
||||
var sampleRate = 44100
|
||||
var bitsPerSample = 16
|
||||
var dataOffset = -1
|
||||
var dataLength = 0
|
||||
|
||||
// Walk the chunks looking for "fmt " and "data".
|
||||
while (bb.remaining() >= 8) {
|
||||
val tag = readTag(bb)
|
||||
val size = bb.int
|
||||
if (size < 0) break // corrupt chunk size — stop scanning, use what we have
|
||||
val next = bb.position() + size
|
||||
when (tag) {
|
||||
"fmt " -> {
|
||||
audioFormat = bb.short.toInt() and 0xFFFF
|
||||
channels = (bb.short.toInt() and 0xFFFF).coerceAtLeast(1)
|
||||
sampleRate = bb.int
|
||||
bb.int // byteRate
|
||||
bb.short // blockAlign
|
||||
bitsPerSample = bb.short.toInt() and 0xFFFF
|
||||
}
|
||||
"data" -> { dataOffset = bb.position(); dataLength = size }
|
||||
}
|
||||
if (next in 0..bb.limit()) bb.position(next + (size and 1)) else break // word-aligned
|
||||
}
|
||||
if (dataOffset < 0) return null
|
||||
// A header can over-report the data length; never read past the actual bytes.
|
||||
dataLength = dataLength.coerceIn(0, bytes.size - dataOffset)
|
||||
|
||||
val bytesPerSample = bitsPerSample / 8
|
||||
if (bytesPerSample == 0) return null
|
||||
val frameCount = dataLength / (bytesPerSample * channels)
|
||||
if (frameCount <= 0) return null
|
||||
val out = FloatArray(frameCount)
|
||||
val data = ByteBuffer.wrap(bytes, dataOffset, dataLength).order(ByteOrder.LITTLE_ENDIAN)
|
||||
|
||||
for (f in 0 until frameCount) {
|
||||
var acc = 0f
|
||||
for (c in 0 until channels) {
|
||||
acc += when {
|
||||
audioFormat == 3 && bitsPerSample == 32 -> data.float
|
||||
bitsPerSample == 16 -> data.short / 32768f
|
||||
bitsPerSample == 8 -> ((data.get().toInt() and 0xFF) - 128) / 128f
|
||||
bitsPerSample == 24 -> {
|
||||
val b0 = data.get().toInt() and 0xFF
|
||||
val b1 = data.get().toInt() and 0xFF
|
||||
val b2 = data.get().toInt() // signed high byte
|
||||
((b2 shl 16) or (b1 shl 8) or b0) / 8388608f
|
||||
}
|
||||
bitsPerSample == 32 -> data.int / 2147483648f
|
||||
else -> return null
|
||||
}
|
||||
}
|
||||
out[f] = acc / channels // down-mix to mono
|
||||
}
|
||||
return Sample(out, sampleRate)
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a [Sample] to a 16-bit mono PCM WAV file (the inverse of
|
||||
* [decodeWav]). Used when a Sampler preset is saved so the audio travels with
|
||||
* the preset as a plain, portable `.wav`.
|
||||
*/
|
||||
fun encodeWav(sample: Sample): ByteArray {
|
||||
val frames = sample.data.size
|
||||
val bytesPerSample = 2
|
||||
val channels = 1
|
||||
val dataSize = frames * bytesPerSample * channels
|
||||
val bb = ByteBuffer.allocate(44 + dataSize).order(ByteOrder.LITTLE_ENDIAN)
|
||||
bb.put("RIFF".toByteArray(Charsets.US_ASCII))
|
||||
bb.putInt(36 + dataSize)
|
||||
bb.put("WAVE".toByteArray(Charsets.US_ASCII))
|
||||
bb.put("fmt ".toByteArray(Charsets.US_ASCII))
|
||||
bb.putInt(16) // PCM fmt chunk size
|
||||
bb.putShort(1) // audioFormat = PCM
|
||||
bb.putShort(channels.toShort())
|
||||
bb.putInt(sample.sampleRate)
|
||||
bb.putInt(sample.sampleRate * channels * bytesPerSample) // byteRate
|
||||
bb.putShort((channels * bytesPerSample).toShort()) // blockAlign
|
||||
bb.putShort(16) // bitsPerSample
|
||||
bb.put("data".toByteArray(Charsets.US_ASCII))
|
||||
bb.putInt(dataSize)
|
||||
for (v in sample.data) {
|
||||
bb.putShort((v.coerceIn(-1f, 1f) * 32767f).toInt().toShort())
|
||||
}
|
||||
return bb.array()
|
||||
}
|
||||
|
||||
private fun readTag(bb: ByteBuffer): String {
|
||||
val b = ByteArray(4)
|
||||
bb.get(b)
|
||||
return String(b, Charsets.US_ASCII)
|
||||
}
|
||||
}
|
||||
152
app/src/main/java/space/rcmd/android/sizzle/audio/SampleVoice.kt
Normal file
152
app/src/main/java/space/rcmd/android/sizzle/audio/SampleVoice.kt
Normal file
@@ -0,0 +1,152 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.audio
|
||||
|
||||
import kotlin.math.exp
|
||||
|
||||
/**
|
||||
* Plays back a decoded [SampleStore.Sample] as a pitched, one-shot voice for the
|
||||
* Sampler / SoundFont instruments. The note's distance from the root note sets the
|
||||
* playback rate (so a single sample is pitch-stretched across the keyboard), and
|
||||
* the slice start/end markers bound the region that plays.
|
||||
*
|
||||
* Amplitude is shaped by a linear **ADSR envelope** (attack → decay → sustain →
|
||||
* release), so a held note swells/sustains and a note-off releases it. The Sampler
|
||||
* pads pass one-shot defaults (near-instant attack, full sustain, quick release) so
|
||||
* they behave exactly as before; the SoundFont loader exposes the envelope as
|
||||
* user-tunable sliders. A separate short end-of-sample fade prevents a click when
|
||||
* the sample data runs out before the release finishes.
|
||||
*
|
||||
* Runs on the audio thread: [render] must not allocate.
|
||||
*/
|
||||
class SampleVoice(private val engineSampleRate: Int) {
|
||||
private var sample: SampleStore.Sample? = null
|
||||
private var position = 0.0 // fractional read index into sample.data
|
||||
private var rate = 1.0 // samples advanced per output sample
|
||||
private var startIndex = 0
|
||||
private var endIndex = 0
|
||||
private var amp = 0f
|
||||
var pitch = -1; private set
|
||||
|
||||
// ---- ADSR envelope (linear ramps; levels 0..1) ----
|
||||
private enum class Phase { ATTACK, DECAY, SUSTAIN, RELEASE }
|
||||
private var phase = Phase.ATTACK
|
||||
private var env = 0f
|
||||
private var attackInc = 1f
|
||||
private var decayInc = 1f
|
||||
private var sustainLevel = 1f
|
||||
private var releaseSeconds = 0.02f
|
||||
private var releaseInc = 1f
|
||||
|
||||
// ---- anti-click end-of-sample fade (independent of the ADSR) ----
|
||||
private var endGain = 1f
|
||||
|
||||
// ---- declick: when a still-sounding voice is stolen/retriggered, its last
|
||||
// output is carried as a fast-decaying offset so the hand-off has no
|
||||
// discontinuity (an abrupt reset would zero a loud voice in one sample = click).
|
||||
private var lastY = 0f
|
||||
private var declick = 0f
|
||||
private val declickCoeff = exp(-1f / (engineSampleRate * 0.003f)) // ~3 ms decay
|
||||
|
||||
val isActive: Boolean get() = sample != null
|
||||
|
||||
/**
|
||||
* @param root MIDI note at which the sample plays at its natural pitch.
|
||||
* @param sliceStart/[sliceEnd] 0..1 fractions of the sample to play.
|
||||
* @param volume 0..1 instrument volume.
|
||||
* @param attack/[decay]/[release] envelope stage times in seconds.
|
||||
* @param sustain sustain level 0..1 held after the decay stage.
|
||||
*/
|
||||
fun noteOn(
|
||||
s: SampleStore.Sample, midi: Int, velocity: Int, root: Int,
|
||||
sliceStart: Float, sliceEnd: Float, volume: Float,
|
||||
attack: Float = 0.002f, decay: Float = 0f, sustain: Float = 1f, release: Float = 0.02f,
|
||||
) {
|
||||
// Carry the level we're interrupting so the new note starts continuously
|
||||
// (the new note fades in from ~0; without this the sum would jump from the
|
||||
// old voice's level straight to ~0 → a click when stealing a loud voice).
|
||||
declick += lastY
|
||||
sample = s
|
||||
pitch = midi
|
||||
val len = s.data.size
|
||||
val a = sliceStart.coerceIn(0f, 1f)
|
||||
val b = sliceEnd.coerceIn(0f, 1f)
|
||||
startIndex = (minOf(a, b) * len).toInt().coerceIn(0, (len - 1).coerceAtLeast(0))
|
||||
endIndex = (maxOf(a, b) * len).toInt().coerceIn(startIndex + 1, len)
|
||||
position = startIndex.toDouble()
|
||||
// Pitch ratio * sample-rate conversion.
|
||||
val semis = (midi - root) / 12.0
|
||||
rate = Math.pow(2.0, semis) * (s.sampleRate.toDouble() / engineSampleRate)
|
||||
amp = (velocity / 127f).coerceIn(0f, 1f) * volume
|
||||
|
||||
// Envelope: enforce a tiny minimum attack so the onset never clicks.
|
||||
val sr = engineSampleRate.toFloat()
|
||||
sustainLevel = sustain.coerceIn(0f, 1f)
|
||||
attackInc = 1f / (maxOf(attack, MIN_ATTACK) * sr)
|
||||
decayInc = if (decay <= 0f) 1f else (1f - sustainLevel) / (decay * sr)
|
||||
releaseSeconds = release
|
||||
env = 0f
|
||||
phase = Phase.ATTACK
|
||||
endGain = 1f
|
||||
}
|
||||
|
||||
/** Enter the release stage: fade from the current level to 0 over the release time. */
|
||||
fun noteOff() {
|
||||
val sr = engineSampleRate.toFloat()
|
||||
releaseInc = if (releaseSeconds <= 0f) 1f else env / (releaseSeconds * sr)
|
||||
if (releaseInc <= 0f) releaseInc = 1f
|
||||
phase = Phase.RELEASE
|
||||
}
|
||||
|
||||
fun kill() { sample = null; pitch = -1; env = 0f; endGain = 1f; lastY = 0f; declick = 0f }
|
||||
|
||||
/** One sample of the decaying declick offset (used on the paths that stop the
|
||||
* voice, so any carried level rings out smoothly instead of snapping to 0). */
|
||||
private fun declickTail(): Float {
|
||||
if (declick == 0f) { lastY = 0f; return 0f }
|
||||
val y = declick
|
||||
declick *= declickCoeff
|
||||
if (declick < 1e-5f && declick > -1e-5f) declick = 0f
|
||||
lastY = y
|
||||
return y
|
||||
}
|
||||
|
||||
fun render(): Float {
|
||||
val s = sample ?: return declickTail() // idle → 0; ring out any declick tail
|
||||
val data = s.data
|
||||
val i = position.toInt()
|
||||
if (i >= endIndex - 1 || i >= data.size - 1) { sample = null; pitch = -1; return declickTail() }
|
||||
|
||||
// Anti-click fade as the slice end approaches (in OUTPUT samples, so it holds
|
||||
// regardless of playback rate).
|
||||
if ((endIndex - i) / rate <= FADE_SAMPLES) {
|
||||
endGain = (endGain - FADE_STEP).coerceAtLeast(0f)
|
||||
if (endGain <= 0f) { sample = null; pitch = -1; return declickTail() }
|
||||
}
|
||||
|
||||
// Advance the ADSR envelope one output sample.
|
||||
when (phase) {
|
||||
Phase.ATTACK -> { env += attackInc; if (env >= 1f) { env = 1f; phase = Phase.DECAY } }
|
||||
Phase.DECAY -> { env -= decayInc; if (env <= sustainLevel) { env = sustainLevel; phase = Phase.SUSTAIN } }
|
||||
Phase.SUSTAIN -> {}
|
||||
Phase.RELEASE -> { env -= releaseInc; if (env <= 0f) { sample = null; pitch = -1; return declickTail() } }
|
||||
}
|
||||
|
||||
// Linear interpolation between neighbouring samples.
|
||||
val frac = (position - i).toFloat()
|
||||
val out = data[i] + (data[i + 1] - data[i]) * frac
|
||||
|
||||
position += rate
|
||||
val y = out * amp * env * endGain + declick
|
||||
declick *= declickCoeff
|
||||
lastY = y
|
||||
return y
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val FADE_STEP = 0.01f // ~2 ms end fade at 48 kHz
|
||||
private const val FADE_SAMPLES = 1.0 / FADE_STEP // output samples the fade spans
|
||||
private const val MIN_ATTACK = 0.0005f // ~0.5 ms minimum onset ramp (anti-click)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.audio
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
/**
|
||||
* Loads a SoundFont (`.sf2`) or FastTracker II instrument (`.xi`) file and extracts
|
||||
* a single playable PCM sample plus the MIDI note at which it plays natural pitch.
|
||||
* The result feeds the existing [SampleStore] / [SampleVoice] pitched-playback path
|
||||
* (the same one the Sampler uses), so a SoundFont slot plays like a pitched
|
||||
* single-sample instrument across the keyboard.
|
||||
*
|
||||
* This is intentionally pragmatic rather than a full synthesiser: SF2 preset/zone
|
||||
* layering, per-key sample maps and loop points are not honoured — we take the
|
||||
* file's first sample (SF2 `shdr` / XI first sample) and pitch it from its root.
|
||||
* A plain WAV is also accepted as a convenience. Returns null if the bytes are not
|
||||
* a format we understand (the engine then keeps its synth fallback).
|
||||
*/
|
||||
object SoundFontLoader {
|
||||
|
||||
class Loaded(val sample: SampleStore.Sample, val rootNote: Int)
|
||||
|
||||
/** Frames cap so a pathologically large sample region can't exhaust memory. */
|
||||
private const val MAX_FRAMES = 48_000 * 30
|
||||
|
||||
fun load(bytes: ByteArray): Loaded? = when {
|
||||
looksLikeSf2(bytes) -> runCatching { loadSf2(bytes) }.getOrNull()
|
||||
looksLikeXi(bytes) -> runCatching { loadXi(bytes) }.getOrNull()
|
||||
else -> SampleStore.decodeWav(bytes)?.let { Loaded(it, 60) }
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- SF2
|
||||
|
||||
private fun looksLikeSf2(b: ByteArray): Boolean =
|
||||
b.size > 12 && b.tagAt(0) == "RIFF" && b.tagAt(8) == "sfbk"
|
||||
|
||||
private fun loadSf2(bytes: ByteArray): Loaded? {
|
||||
val bb = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN)
|
||||
if (readTag(bb) != "RIFF") return null
|
||||
bb.int // riff size
|
||||
if (readTag(bb) != "sfbk") return null
|
||||
|
||||
var smplOff = -1; var shdrOff = -1; var shdrBytes = 0
|
||||
while (bb.remaining() >= 8) {
|
||||
val id = readTag(bb)
|
||||
val size = bb.int
|
||||
val chunkEnd = (bb.position() + size).coerceAtMost(bb.limit())
|
||||
if (id == "LIST" && bb.remaining() >= 4) {
|
||||
val listType = readTag(bb)
|
||||
while (bb.position() + 8 <= chunkEnd) {
|
||||
val sid = readTag(bb)
|
||||
val ssize = bb.int
|
||||
val dataPos = bb.position()
|
||||
if (listType == "sdta" && sid == "smpl") smplOff = dataPos
|
||||
if (listType == "pdta" && sid == "shdr") { shdrOff = dataPos; shdrBytes = ssize }
|
||||
bb.position((dataPos + ssize + (ssize and 1)).coerceAtMost(bb.limit()))
|
||||
}
|
||||
}
|
||||
bb.position((chunkEnd + (size and 1)).coerceAtMost(bb.limit()))
|
||||
}
|
||||
if (smplOff < 0 || shdrOff < 0 || shdrBytes < SHDR_REC) return null
|
||||
|
||||
// First sample header record (46 bytes): 20-byte name, then the fields.
|
||||
val h = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).apply { position(shdrOff + 20) }
|
||||
val start = h.int.toLong() and 0xFFFFFFFFL
|
||||
val end = h.int.toLong() and 0xFFFFFFFFL
|
||||
h.int; h.int // start/end loop (unused)
|
||||
val sampleRate = h.int
|
||||
val originalKey = h.get().toInt() and 0xFF
|
||||
if (end <= start) return null
|
||||
|
||||
val frames = (end - start).toInt().coerceAtMost(MAX_FRAMES)
|
||||
val out = FloatArray(frames)
|
||||
val db = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN)
|
||||
.apply { position((smplOff + start.toInt() * 2).coerceIn(0, bytes.size)) }
|
||||
var i = 0
|
||||
while (i < frames && db.remaining() >= 2) { out[i] = db.short / 32768f; i++ }
|
||||
|
||||
val root = if (originalKey in 1..127) originalKey else 60
|
||||
val rate = if (sampleRate in 8000..192000) sampleRate else 44100
|
||||
return Loaded(SampleStore.Sample(out, rate), root)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- XI
|
||||
|
||||
private fun looksLikeXi(b: ByteArray): Boolean =
|
||||
b.size > 300 && String(b, 0, 15, Charsets.US_ASCII) == "Extended Instru"
|
||||
|
||||
private fun loadXi(bytes: ByteArray): Loaded? {
|
||||
// Fixed XI header layout: number-of-samples word at 296, first 40-byte
|
||||
// sample header at 298, then delta-encoded sample data after all headers.
|
||||
val numSamples = u16(bytes, 296)
|
||||
if (numSamples < 1) return null
|
||||
val hp = 298
|
||||
if (hp + 40 > bytes.size) return null
|
||||
val length = u32(bytes, hp).toInt() // sample length in bytes
|
||||
val type = bytes[hp + 14].toInt() and 0xFF
|
||||
val relativeNote = bytes[hp + 16].toInt() // signed
|
||||
val is16 = (type and 0x10) != 0
|
||||
|
||||
val dataStart = hp + numSamples * 40
|
||||
if (length <= 0 || dataStart >= bytes.size) return null
|
||||
val byteLen = minOf(length, bytes.size - dataStart)
|
||||
|
||||
val out: FloatArray
|
||||
if (is16) {
|
||||
val n = (byteLen / 2).coerceAtMost(MAX_FRAMES)
|
||||
out = FloatArray(n)
|
||||
val db = ByteBuffer.wrap(bytes, dataStart, byteLen).order(ByteOrder.LITTLE_ENDIAN)
|
||||
var old = 0
|
||||
for (k in 0 until n) {
|
||||
old = (old + db.short.toInt()) and 0xFFFF
|
||||
out[k] = old.toShort() / 32768f
|
||||
}
|
||||
} else {
|
||||
val n = byteLen.coerceAtMost(MAX_FRAMES)
|
||||
out = FloatArray(n)
|
||||
var old = 0
|
||||
for (k in 0 until n) {
|
||||
old = (old + bytes[dataStart + k]) and 0xFF
|
||||
out[k] = old.toByte() / 128f
|
||||
}
|
||||
}
|
||||
// XI/XM samples have no stored rate; C-4 plays at 8363 Hz by convention.
|
||||
// relativeNote offsets the pitch: root = 60 - relativeNote so playing that
|
||||
// note yields the sample's natural pitch and it transposes from there.
|
||||
val root = (60 - relativeNote).coerceIn(0, 127)
|
||||
return Loaded(SampleStore.Sample(out, 8363), root)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- helpers
|
||||
|
||||
private const val SHDR_REC = 46
|
||||
|
||||
private fun readTag(bb: ByteBuffer): String {
|
||||
val b = ByteArray(4)
|
||||
bb.get(b)
|
||||
return String(b, Charsets.US_ASCII)
|
||||
}
|
||||
|
||||
private fun ByteArray.tagAt(off: Int): String =
|
||||
if (off + 4 <= size) String(this, off, 4, Charsets.US_ASCII) else ""
|
||||
|
||||
private fun u16(b: ByteArray, off: Int): Int =
|
||||
(b[off].toInt() and 0xFF) or ((b[off + 1].toInt() and 0xFF) shl 8)
|
||||
|
||||
private fun u32(b: ByteArray, off: Int): Long =
|
||||
((b[off].toInt() and 0xFF).toLong()) or
|
||||
((b[off + 1].toInt() and 0xFF).toLong() shl 8) or
|
||||
((b[off + 2].toInt() and 0xFF).toLong() shl 16) or
|
||||
((b[off + 3].toInt() and 0xFF).toLong() shl 24)
|
||||
}
|
||||
105
app/src/main/java/space/rcmd/android/sizzle/audio/SynthVoice.kt
Normal file
105
app/src/main/java/space/rcmd/android/sizzle/audio/SynthVoice.kt
Normal file
@@ -0,0 +1,105 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.audio
|
||||
|
||||
/**
|
||||
* One playing note (a "voice"). This is a deliberately simple NES/Famicom-style
|
||||
* tone generator: a few band-limited-ish waveforms plus a linear ADSR envelope.
|
||||
* It renders sample-by-sample into the mix buffer.
|
||||
*
|
||||
* Everything here runs on the audio thread and must never allocate or block.
|
||||
*/
|
||||
class SynthVoice(private val sampleRate: Int) {
|
||||
|
||||
enum class Wave { PULSE_12, PULSE_25, PULSE_50, TRIANGLE, NOISE }
|
||||
|
||||
private enum class Stage { IDLE, ATTACK, DECAY, SUSTAIN, RELEASE }
|
||||
|
||||
// --- Note / oscillator state ---
|
||||
private var wave = Wave.PULSE_50
|
||||
private var phase = 0.0 // 0..1
|
||||
private var phaseInc = 0.0 // per-sample phase advance
|
||||
private var noiseReg = 0x7FFF // linear-feedback shift register for noise
|
||||
var pitch = -1; private set // MIDI note this voice is playing, -1 idle
|
||||
|
||||
// --- Envelope (times are in seconds) ---
|
||||
private var stage = Stage.IDLE
|
||||
private var env = 0.0
|
||||
private var attack = 0.01; private var decay = 0.15
|
||||
private var sustainLevel = 0.6; private var release = 0.1
|
||||
private var amp = 0.0 // note velocity as 0..1
|
||||
|
||||
val isActive: Boolean get() = stage != Stage.IDLE
|
||||
|
||||
/** Start (or retrigger) this voice. Called from the sequencer/live input. */
|
||||
fun noteOn(midi: Int, velocity: Int, w: Wave, a: Double, d: Double, s: Double, r: Double) {
|
||||
pitch = midi
|
||||
wave = w
|
||||
attack = a.coerceAtLeast(0.001); decay = d; sustainLevel = s; release = r
|
||||
amp = (velocity / 127.0).coerceIn(0.0, 1.0)
|
||||
phaseInc = midiToHz(midi) / sampleRate
|
||||
stage = Stage.ATTACK
|
||||
// env is intentionally NOT reset to 0 on retrigger to avoid clicks.
|
||||
}
|
||||
|
||||
fun noteOff() {
|
||||
if (stage != Stage.IDLE) stage = Stage.RELEASE
|
||||
}
|
||||
|
||||
/** Hard stop with no release tail (used on transport stop / voice steal). */
|
||||
fun kill() {
|
||||
stage = Stage.IDLE; env = 0.0; pitch = -1
|
||||
}
|
||||
|
||||
/** Render one sample and return its signed float value (roughly -1..1). */
|
||||
fun render(): Float {
|
||||
if (stage == Stage.IDLE) return 0f
|
||||
advanceEnvelope()
|
||||
val osc = oscillator()
|
||||
// advance oscillator phase
|
||||
phase += phaseInc
|
||||
if (phase >= 1.0) phase -= 1.0
|
||||
return (osc * env * amp).toFloat()
|
||||
}
|
||||
|
||||
private fun oscillator(): Double = when (wave) {
|
||||
Wave.PULSE_12 -> if (phase < 0.125) 1.0 else -1.0
|
||||
Wave.PULSE_25 -> if (phase < 0.25) 1.0 else -1.0
|
||||
Wave.PULSE_50 -> if (phase < 0.5) 1.0 else -1.0
|
||||
Wave.TRIANGLE -> 1.0 - 4.0 * kotlin.math.abs((phase % 1.0) - 0.5) // -1..1
|
||||
Wave.NOISE -> {
|
||||
// 15-bit LFSR clocked at the note frequency for a pitched-noise feel.
|
||||
if (phase < phaseInc) {
|
||||
val bit = (noiseReg xor (noiseReg shr 1)) and 1
|
||||
noiseReg = (noiseReg shr 1) or (bit shl 14)
|
||||
}
|
||||
if (noiseReg and 1 == 0) 1.0 else -1.0
|
||||
}
|
||||
}
|
||||
|
||||
private fun advanceEnvelope() {
|
||||
val dt = 1.0 / sampleRate
|
||||
when (stage) {
|
||||
Stage.ATTACK -> {
|
||||
env += dt / attack
|
||||
if (env >= 1.0) { env = 1.0; stage = Stage.DECAY }
|
||||
}
|
||||
Stage.DECAY -> {
|
||||
env -= dt / decay.coerceAtLeast(0.001) * (1.0 - sustainLevel)
|
||||
if (env <= sustainLevel) { env = sustainLevel; stage = Stage.SUSTAIN }
|
||||
}
|
||||
Stage.SUSTAIN -> { /* hold until noteOff */ }
|
||||
Stage.RELEASE -> {
|
||||
env -= dt / release.coerceAtLeast(0.001) * sustainLevel.coerceAtLeast(0.001)
|
||||
if (env <= 0.0) kill()
|
||||
}
|
||||
Stage.IDLE -> {}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Equal-tempered MIDI note -> frequency in Hz (A4 = 69 = 440 Hz). */
|
||||
fun midiToHz(midi: Int): Double = 440.0 * Math.pow(2.0, (midi - 69) / 12.0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.audio
|
||||
|
||||
/**
|
||||
* A small, immutable snapshot of "where playback is" that the audio thread
|
||||
* publishes and the UI reads to draw the playhead. Kept as a plain value class so
|
||||
* copying it across threads is cheap and lock-free.
|
||||
*/
|
||||
data class TransportState(
|
||||
val isPlaying: Boolean = false,
|
||||
/** Absolute line index within the currently-playing pattern. */
|
||||
val currentLine: Int = 0,
|
||||
/** Beat index within the arrangement (0 until the canvas width in beats). */
|
||||
val currentBeat: Int = 0,
|
||||
)
|
||||
@@ -0,0 +1,88 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.input
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
/** The single DataStore file backing all persisted input bindings. */
|
||||
private val Context.bindingDataStore: DataStore<Preferences> by preferencesDataStore(name = "input_bindings")
|
||||
|
||||
/**
|
||||
* Persists the three user-editable binding maps — keyboard hotkeys, gamepad
|
||||
* buttons/chords, and MIDI CCs — so custom bindings survive an app restart.
|
||||
*
|
||||
* Each device kind is stored as one small text blob (one `key=actionId` line
|
||||
* apiece) in a Preferences [DataStore]. The in-memory maps on the input handlers
|
||||
* remain the source of truth at runtime: [loadInto] seeds them once at startup
|
||||
* and [save] rewrites the blobs after any edit. Only a device kind that has
|
||||
* actually been stored replaces its defaults, so a fresh install keeps the
|
||||
* built-in layout untouched.
|
||||
*/
|
||||
class BindingStore(private val context: Context) {
|
||||
|
||||
/** Overwrite each handler's binding map with the persisted one, if present.
|
||||
* Call once at startup, before any hardware event can arrive. */
|
||||
suspend fun loadInto(keyboard: KeyboardInput, gamepad: GamepadInput, midi: MidiInput) {
|
||||
val prefs = context.bindingDataStore.data.first()
|
||||
prefs[KEYBOARD_KEY]?.let { keyboard.bindings = decodeIntMap(it) }
|
||||
prefs[MIDI_KEY]?.let { midi.ccBindings = decodeIntMap(it) }
|
||||
prefs[GAMEPAD_KEY]?.let { gamepad.bindings = decodeChordMap(it) }
|
||||
}
|
||||
|
||||
/** Snapshot all three binding maps to disk. Call after any binding change. */
|
||||
suspend fun save(keyboard: KeyboardInput, gamepad: GamepadInput, midi: MidiInput) {
|
||||
context.bindingDataStore.edit { prefs ->
|
||||
prefs[KEYBOARD_KEY] = encodeIntMap(keyboard.bindings)
|
||||
prefs[MIDI_KEY] = encodeIntMap(midi.ccBindings)
|
||||
prefs[GAMEPAD_KEY] = encodeChordMap(gamepad.bindings)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- (de)serialization: one "key=actionId" line per entry ----
|
||||
|
||||
private fun encodeIntMap(m: Map<Int, String>): String =
|
||||
m.entries.joinToString("\n") { "${it.key}=${it.value}" }
|
||||
|
||||
private fun decodeIntMap(s: String): MutableMap<Int, String> {
|
||||
val out = mutableMapOf<Int, String>()
|
||||
for (line in s.lineSequence()) {
|
||||
val eq = line.indexOf('=')
|
||||
if (eq <= 0) continue
|
||||
val code = line.substring(0, eq).toIntOrNull() ?: continue
|
||||
out[code] = line.substring(eq + 1)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Gamepad keys serialize as "modifier:code=actionId" (modifier 0 == none).
|
||||
private fun encodeChordMap(m: Map<GamepadChord, String>): String =
|
||||
m.entries.joinToString("\n") { "${it.key.modifier}:${it.key.code}=${it.value}" }
|
||||
|
||||
private fun decodeChordMap(s: String): MutableMap<GamepadChord, String> {
|
||||
val out = mutableMapOf<GamepadChord, String>()
|
||||
for (line in s.lineSequence()) {
|
||||
val eq = line.indexOf('=')
|
||||
if (eq <= 0) continue
|
||||
val chord = line.substring(0, eq)
|
||||
val colon = chord.indexOf(':')
|
||||
if (colon <= 0) continue
|
||||
val mod = chord.substring(0, colon).toIntOrNull() ?: continue
|
||||
val code = chord.substring(colon + 1).toIntOrNull() ?: continue
|
||||
out[GamepadChord(mod, code)] = line.substring(eq + 1)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val KEYBOARD_KEY = stringPreferencesKey("keyboard")
|
||||
val GAMEPAD_KEY = stringPreferencesKey("gamepad")
|
||||
val MIDI_KEY = stringPreferencesKey("midi")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.input
|
||||
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
|
||||
/**
|
||||
* A gamepad binding trigger: a main control [code], optionally combined with a
|
||||
* held [modifier] control. [modifier] == [NONE] means "no modifier" (a plain,
|
||||
* single-control binding). This lets one control act as a "shift key": e.g. bind
|
||||
* A+Up and A+Down to different actions, and (by clearing A's own plain binding) A
|
||||
* becomes a pure modifier that does nothing on its own.
|
||||
*
|
||||
* A "control" is a button key-code, a D-pad direction (the DPAD_* key-codes, used
|
||||
* whether the pad reports as keys or as a hat), OR an analog-stick direction (the
|
||||
* synthetic `CODE_*STICK_*` codes). All three are interchangeable in chords.
|
||||
*/
|
||||
data class GamepadChord(val modifier: Int, val code: Int) {
|
||||
companion object {
|
||||
/** Sentinel for "no modifier". KEYCODE_UNKNOWN (0) is never a real control. */
|
||||
const val NONE = 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates USB / Bluetooth gamepad input into [InputAction]s. Buttons arrive as
|
||||
* [KeyEvent]s; sticks, triggers and the D-pad hat arrive as [MotionEvent]s. Every
|
||||
* one is funnelled through a single press/release pipeline ([controlDown] /
|
||||
* [controlUp]) so buttons, the D-pad AND the analog sticks are all rebindable and
|
||||
* can be freely mixed into chords (e.g. "A + Left-stick-up", "L1 + D-pad-left").
|
||||
*
|
||||
* Analog directions are edge-detected with hysteresis: pushing a stick/hat past
|
||||
* [AXIS_ON] is a "press", returning inside [AXIS_OFF] is a "release".
|
||||
*
|
||||
* The map is user-rebindable ([bindings]); a sensible default layout is provided.
|
||||
*/
|
||||
class GamepadInput(
|
||||
private val router: InputRouter,
|
||||
var bindings: MutableMap<GamepadChord, String> = defaultBindings(),
|
||||
) {
|
||||
// Current sign (-1/0/+1) of each directional axis, for edge detection. The
|
||||
// D-pad hat reuses the DPAD_* key-codes so hat- and key-reported D-pads behave
|
||||
// identically; the sticks use the synthetic CODE_*STICK_* codes.
|
||||
private var hatX = 0
|
||||
private var hatY = 0
|
||||
private var lStickX = 0
|
||||
private var lStickY = 0
|
||||
private var rStickX = 0
|
||||
private var rStickY = 0
|
||||
|
||||
/** Controls physically held right now, in press order (first held = preferred
|
||||
* modifier). Kept current by [controlDown]/[controlUp]. */
|
||||
private val heldButtons = LinkedHashSet<Int>()
|
||||
|
||||
/** During gamepad-learn, the ordered controls pressed in this capture so we can
|
||||
* reconstruct "modifier then main" once they're released. */
|
||||
private val learnSeq = ArrayList<Int>()
|
||||
|
||||
fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
|
||||
if (!isGamepad(keyCode, event)) return false
|
||||
return controlDown(keyCode)
|
||||
}
|
||||
|
||||
fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean {
|
||||
if (!isGamepad(keyCode, event)) return false
|
||||
return controlUp(keyCode)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the analog sticks and the D-pad hat. Each axis direction is
|
||||
* edge-detected and driven through the SAME press/release path as buttons, so
|
||||
* the D-pad and sticks can be bound and used in chords exactly like buttons.
|
||||
*/
|
||||
fun onMotion(event: MotionEvent): Boolean {
|
||||
if (!event.isFromSource(InputDevice.SOURCE_JOYSTICK)) return false
|
||||
hatX = axis(event.getAxisValue(MotionEvent.AXIS_HAT_X), hatX, KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT)
|
||||
hatY = axis(event.getAxisValue(MotionEvent.AXIS_HAT_Y), hatY, KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN)
|
||||
lStickX = axis(event.getAxisValue(MotionEvent.AXIS_X), lStickX, CODE_LSTICK_LEFT, CODE_LSTICK_RIGHT)
|
||||
lStickY = axis(event.getAxisValue(MotionEvent.AXIS_Y), lStickY, CODE_LSTICK_UP, CODE_LSTICK_DOWN)
|
||||
rStickX = axis(event.getAxisValue(MotionEvent.AXIS_Z), rStickX, CODE_RSTICK_LEFT, CODE_RSTICK_RIGHT)
|
||||
rStickY = axis(event.getAxisValue(MotionEvent.AXIS_RZ), rStickY, CODE_RSTICK_UP, CODE_RSTICK_DOWN)
|
||||
return true
|
||||
}
|
||||
|
||||
// ---- core press/release pipeline (shared by buttons, D-pad and sticks) ----
|
||||
|
||||
/** @return true if the press was consumed (learning, or a bound control). */
|
||||
private fun controlDown(code: Int): Boolean {
|
||||
// Learn mode: record the press sequence and consume it (no action fires).
|
||||
if (learningGamepad()) {
|
||||
if (code !in learnSeq) learnSeq.add(code)
|
||||
heldButtons.add(code)
|
||||
return true
|
||||
}
|
||||
// Chord first: any already-held control may be the modifier.
|
||||
for (mod in heldButtons) {
|
||||
val id = bindings[GamepadChord(mod, code)]
|
||||
if (id != null) {
|
||||
heldButtons.add(code)
|
||||
InputActions.fromId(id)?.let { router.dispatch(it) }
|
||||
return true
|
||||
}
|
||||
}
|
||||
heldButtons.add(code)
|
||||
// Otherwise the plain (no-modifier) binding.
|
||||
bindings[GamepadChord(GamepadChord.NONE, code)]?.let { id ->
|
||||
InputActions.fromId(id)?.let { router.dispatch(it); return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun controlUp(code: Int): Boolean {
|
||||
heldButtons.remove(code)
|
||||
// Finalize a learn capture once every control in this combo is released.
|
||||
if (learningGamepad() && learnSeq.isNotEmpty() && heldButtons.none { it in learnSeq }) {
|
||||
val main = learnSeq.last()
|
||||
val mod = if (learnSeq.size >= 2) learnSeq.first() else GamepadChord.NONE
|
||||
router.dispatch(InputAction.RawControl(InputSource.GAMEPAD, main, 1f, modifier = mod))
|
||||
learnSeq.clear()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Edge-detect one analog axis with hysteresis and drive [controlDown]/
|
||||
* [controlUp] on direction changes. [neg] / [pos] are the control codes for the
|
||||
* negative / positive directions (for vertical axes, negative == up). Returns
|
||||
* the axis's new sign to store back.
|
||||
*/
|
||||
private fun axis(value: Float, last: Int, neg: Int, pos: Int): Int {
|
||||
val sign = when {
|
||||
value <= -AXIS_ON -> -1
|
||||
value >= AXIS_ON -> 1
|
||||
value > -AXIS_OFF && value < AXIS_OFF -> 0
|
||||
else -> last // dead band between OFF and ON: hold to avoid chatter
|
||||
}
|
||||
if (sign != last) {
|
||||
when (last) { -1 -> controlUp(neg); 1 -> controlUp(pos) }
|
||||
when (sign) { -1 -> controlDown(neg); 1 -> controlDown(pos) }
|
||||
}
|
||||
return sign
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a key event is from a gamepad. Besides the SOURCE_GAMEPAD/JOYSTICK
|
||||
* flags, we accept any gamepad-button key code ([KeyEvent.isGamepadButton]):
|
||||
* several Bluetooth controllers deliver their face/shoulder buttons with only a
|
||||
* keyboard/HID source flag set. D-pad key-codes still require a gamepad/joystick
|
||||
* source so a real keyboard's arrow keys are not captured here.
|
||||
*/
|
||||
private fun isGamepad(keyCode: Int, event: KeyEvent): Boolean =
|
||||
event.isFromSource(InputDevice.SOURCE_GAMEPAD) ||
|
||||
event.isFromSource(InputDevice.SOURCE_JOYSTICK) ||
|
||||
KeyEvent.isGamepadButton(keyCode)
|
||||
|
||||
private fun learningGamepad(): Boolean =
|
||||
router.learnMode && router.learnSource == InputSource.GAMEPAD
|
||||
|
||||
companion object {
|
||||
private const val AXIS_ON = 0.6f // push past this = press
|
||||
private const val AXIS_OFF = 0.4f // return inside this = release
|
||||
|
||||
// Synthetic control codes for analog-stick directions, well above the real
|
||||
// Android key-code range so they never collide with a button/D-pad code.
|
||||
const val CODE_LSTICK_LEFT = 0x20001
|
||||
const val CODE_LSTICK_RIGHT = 0x20002
|
||||
const val CODE_LSTICK_UP = 0x20003
|
||||
const val CODE_LSTICK_DOWN = 0x20004
|
||||
const val CODE_RSTICK_LEFT = 0x20005
|
||||
const val CODE_RSTICK_RIGHT = 0x20006
|
||||
const val CODE_RSTICK_UP = 0x20007
|
||||
const val CODE_RSTICK_DOWN = 0x20008
|
||||
|
||||
/** Readable name for any gamepad control code (button, D-pad or stick). */
|
||||
fun controlLabel(code: Int): String = when (code) {
|
||||
CODE_LSTICK_LEFT -> "LS←"; CODE_LSTICK_RIGHT -> "LS→"
|
||||
CODE_LSTICK_UP -> "LS↑"; CODE_LSTICK_DOWN -> "LS↓"
|
||||
CODE_RSTICK_LEFT -> "RS←"; CODE_RSTICK_RIGHT -> "RS→"
|
||||
CODE_RSTICK_UP -> "RS↑"; CODE_RSTICK_DOWN -> "RS↓"
|
||||
else -> KeyEvent.keyCodeToString(code)
|
||||
.removePrefix("KEYCODE_").removePrefix("BUTTON_")
|
||||
.replace("DPAD_", "").replace('_', ' ')
|
||||
}
|
||||
|
||||
/** Default Xbox/standard-layout mapping; every value is an action id. */
|
||||
fun defaultBindings(): MutableMap<GamepadChord, String> = mutableMapOf(
|
||||
GamepadChord(GamepadChord.NONE, KeyEvent.KEYCODE_BUTTON_A) to "playPause",
|
||||
GamepadChord(GamepadChord.NONE, KeyEvent.KEYCODE_BUTTON_B) to "stop",
|
||||
GamepadChord(GamepadChord.NONE, KeyEvent.KEYCODE_BUTTON_X) to "clear",
|
||||
GamepadChord(GamepadChord.NONE, KeyEvent.KEYCODE_BUTTON_Y) to "loop",
|
||||
GamepadChord(GamepadChord.NONE, KeyEvent.KEYCODE_BUTTON_L1) to "prevTab",
|
||||
GamepadChord(GamepadChord.NONE, KeyEvent.KEYCODE_BUTTON_R1) to "nextTab",
|
||||
GamepadChord(GamepadChord.NONE, KeyEvent.KEYCODE_DPAD_UP) to "up",
|
||||
GamepadChord(GamepadChord.NONE, KeyEvent.KEYCODE_DPAD_DOWN) to "down",
|
||||
GamepadChord(GamepadChord.NONE, KeyEvent.KEYCODE_DPAD_LEFT) to "left",
|
||||
GamepadChord(GamepadChord.NONE, KeyEvent.KEYCODE_DPAD_RIGHT) to "right",
|
||||
// Left stick mirrors the D-pad; right stick edits the focused value.
|
||||
GamepadChord(GamepadChord.NONE, CODE_LSTICK_UP) to "up",
|
||||
GamepadChord(GamepadChord.NONE, CODE_LSTICK_DOWN) to "down",
|
||||
GamepadChord(GamepadChord.NONE, CODE_LSTICK_LEFT) to "left",
|
||||
GamepadChord(GamepadChord.NONE, CODE_LSTICK_RIGHT) to "right",
|
||||
GamepadChord(GamepadChord.NONE, CODE_RSTICK_UP) to "increment",
|
||||
GamepadChord(GamepadChord.NONE, CODE_RSTICK_DOWN) to "decrement",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.input
|
||||
|
||||
/**
|
||||
* The heart of the "four equal input methods" requirement. Touch, physical
|
||||
* keyboard, gamepad and MIDI never talk to the app directly — each is translated
|
||||
* into one of these neutral [InputAction]s and fed through the same [InputRouter].
|
||||
* That means a feature only has to be implemented once (in the router/handler)
|
||||
* and it instantly works from every input device.
|
||||
*
|
||||
* If you want to support a new gesture or control, add a case here first, then
|
||||
* teach each source how to produce it and the handler how to react.
|
||||
*/
|
||||
sealed interface InputAction {
|
||||
|
||||
// ----- Grid navigation (cursor movement in the tracker) -----
|
||||
data object NavUp : InputAction
|
||||
data object NavDown : InputAction
|
||||
data object NavLeft : InputAction
|
||||
data object NavRight : InputAction
|
||||
/** Move to the next / previous editable column within the focused track. */
|
||||
data object NextColumn : InputAction
|
||||
data object PrevColumn : InputAction
|
||||
|
||||
// ----- Value editing on the focused cell -----
|
||||
/** +1 step: pitch cycles within the active scale; velocity/channel +1. */
|
||||
data class Increment(val amount: Int = 1) : InputAction
|
||||
data class Decrement(val amount: Int = 1) : InputAction
|
||||
/** Clear the focused cell / place a note-off. */
|
||||
data object ClearCell : 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
|
||||
|
||||
// ----- Transport -----
|
||||
data object PlayPause : InputAction
|
||||
/** First tap: stop. Second tap while stopped: rewind to start. */
|
||||
data object Stop : InputAction
|
||||
data object ToggleLoop : InputAction
|
||||
|
||||
// ----- Navigation between the four tabs -----
|
||||
data class SelectTab(val index: Int) : InputAction
|
||||
data object NextTab : InputAction
|
||||
data object PrevTab : InputAction
|
||||
|
||||
// ----- A raw, unmapped control (e.g. an unbound MIDI CC). Handlers may
|
||||
// ignore it; the settings screen uses it to "learn" new bindings. For a
|
||||
// gamepad chord (a button pressed while a modifier button is held) [modifier]
|
||||
// carries the held button's key-code; it is 0 for plain, single-control
|
||||
// captures (MIDI CC, keyboard key, or a lone gamepad button). -----
|
||||
data class RawControl(
|
||||
val source: InputSource,
|
||||
val code: Int,
|
||||
val value: Float,
|
||||
val modifier: Int = 0,
|
||||
) : InputAction
|
||||
}
|
||||
|
||||
/** Which physical device an action came from — handy for on-screen feedback. */
|
||||
enum class InputSource { TOUCH, KEYBOARD, GAMEPAD, MIDI }
|
||||
@@ -0,0 +1,36 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.input
|
||||
|
||||
/**
|
||||
* The single source of truth mapping a stable action-id string to the neutral
|
||||
* [InputAction] it produces. Both [GamepadInput] and [KeyboardInput] resolve
|
||||
* their (user-rebindable) binding maps through here, and the Settings screen uses
|
||||
* the same ids as binding keys — so an action id means exactly one thing across
|
||||
* inputs and there is no per-source duplication of the vocabulary.
|
||||
*
|
||||
* A few ids are deliberately NOT here because they mutate input-local state
|
||||
* rather than emitting an action (e.g. the keyboard's "octaveUp"/"octaveDown",
|
||||
* which nudge [KeyboardInput.baseOctave]); those handlers special-case them.
|
||||
*/
|
||||
object InputActions {
|
||||
fun fromId(id: String): InputAction? = when (id) {
|
||||
"playPause" -> InputAction.PlayPause
|
||||
"stop" -> InputAction.Stop
|
||||
"loop" -> InputAction.ToggleLoop
|
||||
"up" -> InputAction.NavUp
|
||||
"down" -> InputAction.NavDown
|
||||
"left" -> InputAction.NavLeft
|
||||
"right" -> InputAction.NavRight
|
||||
"increment" -> InputAction.Increment(1)
|
||||
"decrement" -> InputAction.Decrement(1)
|
||||
"clear" -> InputAction.ClearCell
|
||||
"noteOff" -> InputAction.NoteOffCell
|
||||
"nextColumn" -> InputAction.NextColumn
|
||||
"prevColumn" -> InputAction.PrevColumn
|
||||
"nextTab" -> InputAction.NextTab
|
||||
"prevTab" -> InputAction.PrevTab
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.input
|
||||
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
|
||||
/**
|
||||
* A tiny message bus. Every input source calls [dispatch]; the app's ViewModel
|
||||
* collects [actions] and reacts. Using a flow (rather than a direct callback)
|
||||
* decouples the sources from the UI and lets us buffer bursts of MIDI events
|
||||
* without dropping them.
|
||||
*
|
||||
* When "MIDI learn" mode is on, controls that aren't bound to anything are still
|
||||
* forwarded as [InputAction.RawControl] so the settings screen can capture them.
|
||||
*/
|
||||
class InputRouter {
|
||||
// extraBufferCapacity gives us headroom for fast MIDI/gamepad bursts.
|
||||
private val _actions = MutableSharedFlow<InputAction>(extraBufferCapacity = 128)
|
||||
val actions: SharedFlow<InputAction> = _actions.asSharedFlow()
|
||||
|
||||
/** True while the settings screen is waiting to learn a new binding. */
|
||||
@Volatile var learnMode: Boolean = false
|
||||
|
||||
/** Which device kind is currently being learned, so only that source captures
|
||||
* the next control (a keyboard press doesn't hijack a gamepad-learn, etc.). */
|
||||
@Volatile var learnSource: InputSource? = null
|
||||
|
||||
fun dispatch(action: InputAction) {
|
||||
// tryEmit never suspends; if the buffer is somehow full we drop the
|
||||
// oldest by design (a missed nav key is harmless, a stall is not).
|
||||
_actions.tryEmit(action)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.input
|
||||
|
||||
import android.view.KeyEvent
|
||||
import space.rcmd.android.sizzle.model.Pitch
|
||||
|
||||
/**
|
||||
* Translates physical (USB / Bluetooth) keyboard events into [InputAction]s.
|
||||
* [MainActivity] forwards every hardware key here from its
|
||||
* `onKeyDown` / `onKeyUp` overrides.
|
||||
*
|
||||
* Two layers share the keyboard:
|
||||
* - [bindings]: user-rebindable HOTKEYS (nav / transport / editing), editable
|
||||
* from Settings → Keyboard Hotkeys. Resolved through [InputActions] plus a few
|
||||
* keyboard-local ids ("octaveUp"/"octaveDown", and Shift-aware "nextColumn").
|
||||
* - [pianoMap]: the fixed two-octave "tracker piano" so you can play/record
|
||||
* notes without a MIDI device. This is the instrument layout, not a hotkey, so
|
||||
* it is intentionally not rebindable:
|
||||
* lower octave : Z S X D C V G B H N J M (C .. B)
|
||||
* upper octave : Q 2 W 3 E R 5 T 6 Y 7 U (C .. B, one octave up)
|
||||
*
|
||||
* Hotkeys win over the piano when a key is bound to both, so the defaults avoid
|
||||
* the piano letters.
|
||||
*/
|
||||
class KeyboardInput(
|
||||
private val router: InputRouter,
|
||||
/** Android key-code -> action id, editable from Settings (Keyboard Hotkeys). */
|
||||
var bindings: MutableMap<Int, String> = defaultBindings(),
|
||||
) {
|
||||
|
||||
/** The base octave the keyboard piano plays in; adjustable with +/- keys. */
|
||||
var baseOctave: Int = 4
|
||||
|
||||
// keyCode -> semitone offset from the base octave's C.
|
||||
private val pianoMap: Map<Int, Int> = mapOf(
|
||||
KeyEvent.KEYCODE_Z to 0, KeyEvent.KEYCODE_S to 1, KeyEvent.KEYCODE_X to 2,
|
||||
KeyEvent.KEYCODE_D to 3, KeyEvent.KEYCODE_C to 4, KeyEvent.KEYCODE_V to 5,
|
||||
KeyEvent.KEYCODE_G to 6, KeyEvent.KEYCODE_B to 7, KeyEvent.KEYCODE_H to 8,
|
||||
KeyEvent.KEYCODE_N to 9, KeyEvent.KEYCODE_J to 10, KeyEvent.KEYCODE_M to 11,
|
||||
KeyEvent.KEYCODE_Q to 12, KeyEvent.KEYCODE_2 to 13, KeyEvent.KEYCODE_W to 14,
|
||||
KeyEvent.KEYCODE_3 to 15, KeyEvent.KEYCODE_E to 16, KeyEvent.KEYCODE_R to 17,
|
||||
KeyEvent.KEYCODE_5 to 18, KeyEvent.KEYCODE_T to 19, KeyEvent.KEYCODE_6 to 20,
|
||||
KeyEvent.KEYCODE_Y to 21, KeyEvent.KEYCODE_7 to 22, KeyEvent.KEYCODE_U to 23,
|
||||
)
|
||||
|
||||
/** @return true if we consumed the event. */
|
||||
fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
|
||||
// Keyboard-hotkey learn: capture this key for the armed action, consume it.
|
||||
if (router.learnMode && router.learnSource == InputSource.KEYBOARD) {
|
||||
router.dispatch(InputAction.RawControl(InputSource.KEYBOARD, keyCode, 1f))
|
||||
return true
|
||||
}
|
||||
|
||||
bindings[keyCode]?.let { id -> return handleBoundAction(id, event) }
|
||||
|
||||
// Piano fallback for unbound keys.
|
||||
pianoMap[keyCode]?.let { offset ->
|
||||
router.dispatch(InputAction.NoteOn(pianoPitch(offset), velocity = 100))
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Run a bound hotkey. Handles the keyboard-local ids (octave nudge, and the
|
||||
* Shift-aware column move) and delegates the rest to [InputActions]. */
|
||||
private fun handleBoundAction(id: String, event: KeyEvent): Boolean {
|
||||
when (id) {
|
||||
"octaveDown" -> baseOctave = (baseOctave - 1).coerceIn(0, 8)
|
||||
"octaveUp" -> baseOctave = (baseOctave + 1).coerceIn(0, 8)
|
||||
// The column key doubles as prev-column when Shift is held (classic Tab).
|
||||
"nextColumn" -> router.dispatch(
|
||||
if (event.isShiftPressed) InputAction.PrevColumn else InputAction.NextColumn,
|
||||
)
|
||||
else -> {
|
||||
val action = InputActions.fromId(id) ?: return false
|
||||
router.dispatch(action)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun onKeyUp(keyCode: Int, @Suppress("UNUSED_PARAMETER") event: KeyEvent): Boolean {
|
||||
val offset = pianoMap[keyCode] ?: return false
|
||||
router.dispatch(InputAction.NoteOff(pianoPitch(offset)))
|
||||
return true
|
||||
}
|
||||
|
||||
private fun pianoPitch(offset: Int): Int =
|
||||
(Pitch.MIDDLE_C + (baseOctave - 4) * 12 + offset).coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
|
||||
|
||||
companion object {
|
||||
/** Default hotkey layout, matching the app's historical hard-coded keys.
|
||||
* Kept off the piano letters so note entry is unaffected out of the box. */
|
||||
fun defaultBindings(): MutableMap<Int, String> = mutableMapOf(
|
||||
KeyEvent.KEYCODE_DPAD_UP to "up",
|
||||
KeyEvent.KEYCODE_DPAD_DOWN to "down",
|
||||
KeyEvent.KEYCODE_DPAD_LEFT to "left",
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT to "right",
|
||||
KeyEvent.KEYCODE_TAB to "nextColumn",
|
||||
KeyEvent.KEYCODE_SPACE to "playPause",
|
||||
KeyEvent.KEYCODE_ENTER to "stop",
|
||||
KeyEvent.KEYCODE_L to "loop",
|
||||
KeyEvent.KEYCODE_PAGE_UP to "increment",
|
||||
KeyEvent.KEYCODE_PAGE_DOWN to "decrement",
|
||||
KeyEvent.KEYCODE_DEL to "clear",
|
||||
KeyEvent.KEYCODE_GRAVE to "noteOff",
|
||||
KeyEvent.KEYCODE_MINUS to "octaveDown",
|
||||
KeyEvent.KEYCODE_EQUALS to "octaveUp",
|
||||
)
|
||||
}
|
||||
}
|
||||
121
app/src/main/java/space/rcmd/android/sizzle/input/MidiInput.kt
Normal file
121
app/src/main/java/space/rcmd/android/sizzle/input/MidiInput.kt
Normal file
@@ -0,0 +1,121 @@
|
||||
// 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.MidiManager
|
||||
import android.media.midi.MidiOutputPort
|
||||
import android.media.midi.MidiReceiver
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
|
||||
/**
|
||||
* Bridges hardware MIDI (USB *and* Bluetooth — Android surfaces both through the
|
||||
* same [MidiManager]) into the app's [InputAction] stream.
|
||||
*
|
||||
* MIDI note-on/off become live notes; MIDI Control Change (CC) messages are
|
||||
* matched against [ccBindings] so any knob/pad can drive navigation or transport.
|
||||
* Unmatched CCs are forwarded as [InputAction.RawControl] so the Settings screen
|
||||
* can "learn" them.
|
||||
*
|
||||
* Note: this handles MIDI *input*. Sending MIDI out lives in the audio/midi
|
||||
* output module; both share the same [MidiManager].
|
||||
*/
|
||||
class MidiInput(
|
||||
context: Context,
|
||||
private val router: InputRouter,
|
||||
/** CC number -> action id, editable from Settings (MIDI bindings section). */
|
||||
var ccBindings: MutableMap<Int, String> = mutableMapOf(),
|
||||
) {
|
||||
private val midiManager = context.getSystemService(Context.MIDI_SERVICE) as? MidiManager
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val openPorts = mutableListOf<MidiOutputPort>()
|
||||
|
||||
/** Start listening. Opens every currently-attached input-capable device and
|
||||
* watches for hot-plug events. Safe to call if the device has no MIDI. */
|
||||
fun start() {
|
||||
val mm = midiManager ?: return
|
||||
mm.devices.forEach(::openDevice)
|
||||
mm.registerDeviceCallback(object : MidiManager.DeviceCallback() {
|
||||
override fun onDeviceAdded(device: MidiDeviceInfo) = openDevice(device)
|
||||
}, handler)
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
openPorts.forEach { runCatching { it.close() } }
|
||||
openPorts.clear()
|
||||
}
|
||||
|
||||
private fun openDevice(info: MidiDeviceInfo) {
|
||||
// We want devices that can SEND to us (they expose output ports).
|
||||
if (info.outputPortCount == 0) return
|
||||
midiManager?.openDevice(info, { device ->
|
||||
val port = device?.openOutputPort(0) ?: return@openDevice
|
||||
port.connect(parser)
|
||||
openPorts += port
|
||||
}, handler)
|
||||
}
|
||||
|
||||
/** Parses the raw MIDI byte stream. MIDI status bytes have the high bit set;
|
||||
* the low nibble is the channel, the high nibble the message type. */
|
||||
private val parser = object : MidiReceiver() {
|
||||
override fun onSend(data: ByteArray, offset: Int, count: Int, timestamp: Long) {
|
||||
var i = offset
|
||||
val end = offset + count
|
||||
while (i < end) {
|
||||
val status = data[i].toInt() and 0xFF
|
||||
if (status < 0x80) { i++; continue } // skip stray data bytes
|
||||
val type = status and 0xF0
|
||||
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
|
||||
i += 3
|
||||
}
|
||||
0x80 -> { // Note Off
|
||||
router.dispatch(InputAction.NoteOff(data.getOrZero(i + 1))); i += 3
|
||||
}
|
||||
0xB0 -> { // Control Change
|
||||
val cc = data.getOrZero(i + 1)
|
||||
val value = data.getOrZero(i + 2)
|
||||
handleCc(cc, value); i += 3
|
||||
}
|
||||
0xC0, 0xD0 -> i += 2 // program change / channel pressure: 1 data byte
|
||||
else -> i += 3
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCc(cc: Int, value: Int) {
|
||||
val actionId = ccBindings[cc]
|
||||
if (actionId == null) {
|
||||
router.dispatch(InputAction.RawControl(InputSource.MIDI, cc, value / 127f))
|
||||
return
|
||||
}
|
||||
// Momentary controls fire on the "press" half (value >= 64).
|
||||
val action = when (actionId) {
|
||||
"playPause" -> if (value >= 64) InputAction.PlayPause else null
|
||||
"stop" -> if (value >= 64) InputAction.Stop else null
|
||||
"loop" -> if (value >= 64) InputAction.ToggleLoop else null
|
||||
"up" -> if (value >= 64) InputAction.NavUp else null
|
||||
"down" -> if (value >= 64) InputAction.NavDown else null
|
||||
"left" -> if (value >= 64) InputAction.NavLeft else null
|
||||
"right" -> if (value >= 64) InputAction.NavRight else null
|
||||
"increment" -> InputAction.Increment(1)
|
||||
"decrement" -> InputAction.Decrement(1)
|
||||
"clear" -> if (value >= 64) InputAction.ClearCell else null
|
||||
"nextTab" -> if (value >= 64) InputAction.NextTab else null
|
||||
"prevTab" -> if (value >= 64) InputAction.PrevTab else null
|
||||
else -> null
|
||||
}
|
||||
action?.let(router::dispatch)
|
||||
}
|
||||
|
||||
private fun ByteArray.getOrZero(index: Int): Int =
|
||||
if (index < size) this[index].toInt() and 0xFF else 0
|
||||
}
|
||||
69
app/src/main/java/space/rcmd/android/sizzle/io/PresetIo.kt
Normal file
69
app/src/main/java/space/rcmd/android/sizzle/io/PresetIo.kt
Normal file
@@ -0,0 +1,69 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.io
|
||||
|
||||
import space.rcmd.android.sizzle.model.ToolboxSlot
|
||||
import space.rcmd.android.sizzle.model.ToolboxType
|
||||
|
||||
/**
|
||||
* Reads and writes a single instrument/effect preset as plain, human-readable
|
||||
* text. The format is deliberately trivial so a person can hand-edit it and a
|
||||
* machine can parse it with two lines of code:
|
||||
*
|
||||
* SIZZLE-PRESET 1
|
||||
* TYPE=NES_SYNTH
|
||||
* NAME=Lead
|
||||
* wave=Pulse25
|
||||
* attack=0.01
|
||||
* ...
|
||||
*
|
||||
* The same routine is used for the clipboard copy/paste in the Toolbox tab and
|
||||
* for the on-disk preset library.
|
||||
*/
|
||||
object PresetIo {
|
||||
const val HEADER = "SIZZLE-PRESET 1"
|
||||
|
||||
/** Serialize a slot's device + parameters to text. */
|
||||
fun export(slot: ToolboxSlot): String {
|
||||
val type = slot.type ?: return "$HEADER\nTYPE=EMPTY"
|
||||
return buildString {
|
||||
appendLine(HEADER)
|
||||
appendLine("TYPE=${type.name}")
|
||||
appendLine("NAME=${slot.name}")
|
||||
// Emit params in the type's declared order for stable, readable files.
|
||||
type.params.forEach { spec ->
|
||||
appendLine("${spec.key}=${slot.string(spec.key, spec.default.toString())}")
|
||||
}
|
||||
// Emit any extra string params the engine stored (e.g. samplePath).
|
||||
slot.values.keys
|
||||
.filter { key -> type.params.none { it.key == key } }
|
||||
.forEach { key -> appendLine("$key=${slot.values[key]}") }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse preset [text] into [slot], overwriting its current contents. Unknown
|
||||
* or malformed lines are ignored so a partially-edited file still loads.
|
||||
* @return true if a valid device type was found and applied.
|
||||
*/
|
||||
fun import(slot: ToolboxSlot, text: String): Boolean {
|
||||
val lines = text.lineSequence().map { it.trim() }.filter { it.isNotEmpty() }
|
||||
val map = LinkedHashMap<String, String>()
|
||||
var type: ToolboxType? = null
|
||||
for (line in lines) {
|
||||
if (line == HEADER || !line.contains('=')) continue
|
||||
val (key, value) = line.split('=', limit = 2)
|
||||
when (key) {
|
||||
"TYPE" -> type = ToolboxType.entries.firstOrNull { it.name == value }
|
||||
"NAME" -> slot.name = value
|
||||
else -> map[key] = value
|
||||
}
|
||||
}
|
||||
val t = type ?: return false
|
||||
slot.fill(t) // reset to defaults for the type ...
|
||||
slot.name = slot.name.ifBlank { t.displayName }
|
||||
map.forEach { (k, v) -> slot.set(k, v) } // ... then apply the saved values
|
||||
return true
|
||||
}
|
||||
}
|
||||
339
app/src/main/java/space/rcmd/android/sizzle/io/PresetLibrary.kt
Normal file
339
app/src/main/java/space/rcmd/android/sizzle/io/PresetLibrary.kt
Normal file
@@ -0,0 +1,339 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.io
|
||||
|
||||
import space.rcmd.android.sizzle.audio.SampleStore
|
||||
import space.rcmd.android.sizzle.model.AmbiencePresets
|
||||
import space.rcmd.android.sizzle.model.Project
|
||||
import space.rcmd.android.sizzle.model.SamplerPads
|
||||
import space.rcmd.android.sizzle.model.ToolboxSlot
|
||||
import space.rcmd.android.sizzle.model.ToolboxType
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipInputStream
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
/**
|
||||
* The on-disk preset library, living in the app's scoped storage. Presets are the
|
||||
* same plain, human- and machine-readable [PresetIo] text used for clipboard
|
||||
* copy/paste, laid out one directory per device type so a person browsing the
|
||||
* files finds them sorted by instrument/effect:
|
||||
*
|
||||
* <baseDir>/NES_SYNTH/Lead.szp
|
||||
* <baseDir>/SAMPLER/Kick.szp + <baseDir>/SAMPLER/Kick.wav
|
||||
* <baseDir>/DELAY/Slapback.szp
|
||||
*
|
||||
* The Sampler is special: its preset references the audio as a `sampleFile=` path
|
||||
* (relative to the preset, or absolute), and the actual WAV is stored next to the
|
||||
* preset. That makes a sampler preset self-contained on disk, shareable as a
|
||||
* `.zip` bundle (preset + wav), and importable the same way.
|
||||
*/
|
||||
class PresetLibrary(private val baseDir: File) {
|
||||
|
||||
/** The directory holding presets for one device [type] (created on demand). */
|
||||
fun typeDir(type: ToolboxType): File = File(baseDir, type.name).apply { mkdirs() }
|
||||
|
||||
/**
|
||||
* Write the built-in factory presets to disk (as plain [PresetIo] `.szp` files)
|
||||
* so they appear in the standard preset browser next to user presets. Call once
|
||||
* at startup. Idempotent and non-destructive: a per-type `.seeded` marker means
|
||||
* a preset the user later renames or deletes is NOT resurrected on the next
|
||||
* launch. Currently seeds the Ambience reverb's 21 factory spaces.
|
||||
*/
|
||||
fun seedFactoryPresets() {
|
||||
val type = ToolboxType.AMBIENCE
|
||||
val dir = typeDir(type)
|
||||
val marker = File(dir, SEEDED_MARKER)
|
||||
if (marker.exists()) return
|
||||
AmbiencePresets.FACTORY.forEach { preset ->
|
||||
val file = presetFile(type, preset.name)
|
||||
if (file.exists()) return@forEach
|
||||
val slot = ToolboxSlot(0).apply {
|
||||
fill(type)
|
||||
AmbiencePresets.apply(this, preset)
|
||||
name = preset.name
|
||||
}
|
||||
file.writeText(PresetIo.export(slot))
|
||||
}
|
||||
marker.writeText("1")
|
||||
}
|
||||
|
||||
/** Names (without extension) of the presets stored for [type], alphabetically. */
|
||||
fun list(type: ToolboxType): List<String> =
|
||||
typeDir(type).listFiles { f -> f.isFile && f.name.endsWith(PRESET_EXT) }
|
||||
?.map { it.name.removeSuffix(PRESET_EXT) }
|
||||
?.sorted()
|
||||
?: emptyList()
|
||||
|
||||
fun presetFile(type: ToolboxType, name: String): File =
|
||||
File(typeDir(type), sanitize(name) + PRESET_EXT)
|
||||
|
||||
/**
|
||||
* Save [slot] as a named preset. For the Sampler the referenced sample is
|
||||
* written next to the preset as a WAV and referenced by a relative path, so
|
||||
* the library copy is self-contained. Returns the written preset file.
|
||||
*/
|
||||
fun save(slot: ToolboxSlot, name: String): File {
|
||||
val type = slot.type ?: error("cannot save an empty slot")
|
||||
val dir = typeDir(type)
|
||||
val safe = sanitize(name)
|
||||
slot.name = name
|
||||
val preset = File(dir, "$safe$PRESET_EXT")
|
||||
when (type) {
|
||||
ToolboxType.SAMPLER -> {
|
||||
// Write each assigned pad's sample as its own WAV, referenced
|
||||
// relatively. Record the reference on the slot too so a restored
|
||||
// project can rehydrate the audio from the library at startup.
|
||||
val padRefs = LinkedHashMap<Int, String>()
|
||||
for (p in 0 until SamplerPads.COUNT) {
|
||||
val sample = SampleStore.get(slot.string(SamplerPads.key(p)))
|
||||
if (sample == null) { slot.values.remove(padFileKey(p)); continue }
|
||||
val wavName = "${safe}_pad$p.wav"
|
||||
File(dir, wavName).writeBytes(SampleStore.encodeWav(sample))
|
||||
padRefs[p] = wavName
|
||||
slot.set(padFileKey(p), wavName)
|
||||
}
|
||||
preset.writeText(exportSampler(slot, padRefs))
|
||||
}
|
||||
ToolboxType.SOUNDFONT -> {
|
||||
// The extracted SF2/XI sample travels with the preset as a WAV.
|
||||
val sample = SampleStore.get(slot.string("sfSample"))
|
||||
val rel = if (sample != null) {
|
||||
File(dir, "${safe}_sf.wav").writeBytes(SampleStore.encodeWav(sample))
|
||||
slot.set("sfFile", "${safe}_sf.wav")
|
||||
"${safe}_sf.wav"
|
||||
} else {
|
||||
slot.values.remove("sfFile")
|
||||
null
|
||||
}
|
||||
preset.writeText(exportSoundFont(slot, rel))
|
||||
}
|
||||
else -> preset.writeText(PresetIo.export(slot))
|
||||
}
|
||||
return preset
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the named preset for [type]. For the Sampler its co-located WAV is
|
||||
* removed too (only when referenced by a relative path inside our storage — an
|
||||
* absolute path pointing elsewhere is left untouched). Returns true if the
|
||||
* preset existed.
|
||||
*/
|
||||
fun delete(type: ToolboxType, name: String): Boolean {
|
||||
val dir = typeDir(type)
|
||||
val base = sanitize(name)
|
||||
val preset = File(dir, "$base$PRESET_EXT")
|
||||
if (!preset.exists()) return false
|
||||
// Remove any co-located sample WAVs this preset referenced (relative only).
|
||||
sampleFileRefs(preset).forEach { rel ->
|
||||
if (!File(rel).isAbsolute) File(dir, rel).takeIf { it.exists() }?.delete()
|
||||
}
|
||||
if (type == ToolboxType.SAMPLER) {
|
||||
for (p in 0 until SamplerPads.COUNT) File(dir, "${base}_pad$p.wav").delete()
|
||||
} else if (type == ToolboxType.SOUNDFONT) {
|
||||
File(dir, "${base}_sf.wav").delete()
|
||||
}
|
||||
return preset.delete()
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload the sample audio referenced by every Sampler / SoundFont slot in
|
||||
* [project] back into the [SampleStore]. Call at startup: a restored project's
|
||||
* autosave keeps the sample file references (`padNFile` / `sfFile`) but not the
|
||||
* decoded PCM, so without this the pads of a selected preset would be silent
|
||||
* until re-imported. Missing files are skipped.
|
||||
*/
|
||||
fun rehydrate(project: Project) {
|
||||
project.toolbox.forEach { slot ->
|
||||
when (slot.type) {
|
||||
ToolboxType.SAMPLER -> resolvePads(slot, typeDir(ToolboxType.SAMPLER))
|
||||
ToolboxType.SOUNDFONT -> resolveSoundFont(slot, typeDir(ToolboxType.SOUNDFONT))
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Load the named preset for [type] into [slot]. A missing, unreadable or
|
||||
* malformed preset returns false instead of throwing. */
|
||||
fun load(slot: ToolboxSlot, type: ToolboxType, name: String): Boolean = runCatching {
|
||||
val file = File(typeDir(type), sanitize(name) + PRESET_EXT)
|
||||
if (!file.exists()) return false
|
||||
if (!PresetIo.import(slot, file.readText())) return false
|
||||
val dir = file.parentFile ?: typeDir(type)
|
||||
when (slot.type) {
|
||||
ToolboxType.SAMPLER -> resolvePads(slot, dir)
|
||||
ToolboxType.SOUNDFONT -> resolveSoundFont(slot, dir)
|
||||
else -> {}
|
||||
}
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
|
||||
/**
|
||||
* Import a preset supplied as raw text (e.g. picked from Files). Loads it into
|
||||
* [slot] and persists a copy in the library so it appears in the browser.
|
||||
* Sampler samples referenced by an absolute path are pulled in; relative refs
|
||||
* need the zip bundle form (see [importZip]).
|
||||
*/
|
||||
fun importText(slot: ToolboxSlot, text: String): Boolean = runCatching {
|
||||
if (!PresetIo.import(slot, text)) return false
|
||||
val type = slot.type ?: return false
|
||||
when (type) {
|
||||
ToolboxType.SAMPLER -> resolvePads(slot, typeDir(type))
|
||||
ToolboxType.SOUNDFONT -> resolveSoundFont(slot, typeDir(type))
|
||||
else -> {}
|
||||
}
|
||||
save(slot, slot.name.ifBlank { type.displayName })
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
|
||||
/**
|
||||
* Import a preset packaged as a `.zip` bundle (a `.szp` plus its sample WAVs) —
|
||||
* used for the Sampler and SoundFont. Entries are extracted to a scratch dir,
|
||||
* the contained preset is loaded and its samples resolved, then it is re-saved
|
||||
* into the proper type folder so it joins the library self-contained.
|
||||
*/
|
||||
fun importZip(slot: ToolboxSlot, input: InputStream): Boolean = runCatching {
|
||||
val scratch = File(baseDir, IMPORT_DIR).apply { mkdirs(); listFiles()?.forEach { it.delete() } }
|
||||
var presetText: String? = null
|
||||
ZipInputStream(input).use { zis ->
|
||||
var entry: ZipEntry? = zis.nextEntry
|
||||
while (entry != null) {
|
||||
// Use only the base name to guard against zip-slip path traversal.
|
||||
val safeName = File(entry.name).name
|
||||
// Skip directories, entries with no usable file name, and macOS
|
||||
// AppleDouble junk (a "__MACOSX/" folder plus "._name" resource-fork
|
||||
// twins). The latter matters: "._preset.szp" also ends in .szp, so
|
||||
// without this it would clobber the real preset with binary garbage.
|
||||
val junk = entry.name.startsWith("__MACOSX") || safeName.startsWith("._")
|
||||
if (!entry.isDirectory && safeName.isNotBlank() && !junk) {
|
||||
val target = File(scratch, safeName)
|
||||
target.outputStream().use { zis.copyTo(it) }
|
||||
// Keep the first real preset; a later entry can't overwrite it.
|
||||
if (presetText == null && target.name.endsWith(PRESET_EXT)) presetText = target.readText()
|
||||
}
|
||||
zis.closeEntry()
|
||||
entry = zis.nextEntry
|
||||
}
|
||||
}
|
||||
// A zip without a valid .szp preset (or with an unrecognised one) is rejected.
|
||||
val text = presetText ?: return false
|
||||
if (!PresetIo.import(slot, text)) return false
|
||||
when (slot.type) {
|
||||
ToolboxType.SAMPLER -> resolvePads(slot, scratch)
|
||||
ToolboxType.SOUNDFONT -> resolveSoundFont(slot, scratch)
|
||||
else -> {}
|
||||
}
|
||||
// Persist a self-contained copy into the real library folder.
|
||||
save(slot, slot.name.ifBlank { slot.type?.displayName ?: "preset" })
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
|
||||
/**
|
||||
* Produce a shareable file for a stored preset: a self-contained `.zip`
|
||||
* (preset + sample WAVs) for the Sampler / SoundFont, or the plain `.szp` for
|
||||
* everything else. The result lives under [baseDir] so the app's FileProvider
|
||||
* can expose it. Returns null if the preset does not exist.
|
||||
*/
|
||||
fun bundleForShare(type: ToolboxType, name: String): File? {
|
||||
val preset = File(typeDir(type), sanitize(name) + PRESET_EXT)
|
||||
if (!preset.exists()) return null
|
||||
if (type != ToolboxType.SAMPLER && type != ToolboxType.SOUNDFONT) return preset
|
||||
|
||||
val bundles = File(baseDir, BUNDLE_DIR).apply { mkdirs() }
|
||||
val zip = File(bundles, sanitize(name) + ".zip")
|
||||
ZipOutputStream(zip.outputStream()).use { zos ->
|
||||
addEntry(zos, preset, preset.name)
|
||||
sampleFileRefs(preset).forEach { rel ->
|
||||
val wav = File(rel).let { if (it.isAbsolute) it else File(preset.parentFile, rel) }
|
||||
if (wav.exists()) addEntry(zos, wav, wav.name)
|
||||
}
|
||||
}
|
||||
return zip
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- internals
|
||||
|
||||
/** Key under which a pad's sample file path is written in the preset text. */
|
||||
private fun padFileKey(pad: Int): String = "pad${pad}File"
|
||||
|
||||
/** Serialize a Sampler slot: global volume, plus each assigned pad's sample
|
||||
* file ([padRefs]) and its slice trim. */
|
||||
private fun exportSampler(slot: ToolboxSlot, padRefs: Map<Int, String>): String = buildString {
|
||||
appendLine(PresetIo.HEADER)
|
||||
appendLine("TYPE=${ToolboxType.SAMPLER.name}")
|
||||
appendLine("NAME=${slot.name}")
|
||||
appendLine("volume=${slot.string("volume", "0.8")}")
|
||||
for ((pad, ref) in padRefs) {
|
||||
appendLine("${padFileKey(pad)}=$ref")
|
||||
appendLine("${SamplerPads.sliceStartKey(pad)}=${slot.string(SamplerPads.sliceStartKey(pad), "0.0")}")
|
||||
appendLine("${SamplerPads.sliceEndKey(pad)}=${slot.string(SamplerPads.sliceEndKey(pad), "1.0")}")
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve every `padNFile` reference (relative to [presetDir], or absolute)
|
||||
* into decoded samples in the [SampleStore], mapping each to its pad. */
|
||||
private fun resolvePads(slot: ToolboxSlot, presetDir: File) {
|
||||
for (pad in 0 until SamplerPads.COUNT) {
|
||||
val rel = slot.string(padFileKey(pad))
|
||||
if (rel.isBlank()) continue
|
||||
resolveWav(rel, presetDir)?.let { slot.set(SamplerPads.key(pad), it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Serialize a SoundFont slot: volume, root note, and its extracted sample WAV. */
|
||||
private fun exportSoundFont(slot: ToolboxSlot, sampleRel: String?): String = buildString {
|
||||
appendLine(PresetIo.HEADER)
|
||||
appendLine("TYPE=${ToolboxType.SOUNDFONT.name}")
|
||||
appendLine("NAME=${slot.name}")
|
||||
appendLine("volume=${slot.string("volume", "0.8")}")
|
||||
appendLine("sfRoot=${slot.string("sfRoot", "60.0")}")
|
||||
if (sampleRel != null) appendLine("sfFile=$sampleRel")
|
||||
}
|
||||
|
||||
/** Resolve a SoundFont preset's `sfFile` into the [SampleStore] and point the
|
||||
* slot's `sfSample` at it (its `sfRoot` was already restored by PresetIo). */
|
||||
private fun resolveSoundFont(slot: ToolboxSlot, presetDir: File) {
|
||||
val rel = slot.string("sfFile")
|
||||
if (rel.isBlank()) return
|
||||
resolveWav(rel, presetDir)?.let { slot.set("sfSample", it) }
|
||||
}
|
||||
|
||||
/** Decode a referenced WAV (relative to [presetDir], or absolute) into the
|
||||
* [SampleStore] and return its id, or null if missing/undecodable. */
|
||||
private fun resolveWav(rel: String, presetDir: File): String? {
|
||||
val wav = File(rel).let { if (it.isAbsolute) it else File(presetDir, rel) }
|
||||
if (!wav.exists()) return null
|
||||
val sample = SampleStore.decodeWav(wav.readBytes()) ?: return null
|
||||
val id = "file:" + wav.absolutePath
|
||||
SampleStore.put(id, sample)
|
||||
return id
|
||||
}
|
||||
|
||||
/** Every sample file path a preset references (any `…File=` line — covers both
|
||||
* the Sampler's `padNFile` and the SoundFont's `sfFile`). */
|
||||
private fun sampleFileRefs(preset: File): List<String> =
|
||||
preset.readLines().mapNotNull { line ->
|
||||
val t = line.trim()
|
||||
val eq = t.indexOf('=')
|
||||
if (eq > 0 && t.substring(0, eq).endsWith("File")) t.substring(eq + 1).trim() else null
|
||||
}
|
||||
|
||||
private fun addEntry(zos: ZipOutputStream, file: File, entryName: String) {
|
||||
zos.putNextEntry(ZipEntry(entryName))
|
||||
file.inputStream().use { it.copyTo(zos) }
|
||||
zos.closeEntry()
|
||||
}
|
||||
|
||||
/** Keep names safe as file names while staying human-readable. */
|
||||
private fun sanitize(name: String): String =
|
||||
name.trim().replace(Regex("[^A-Za-z0-9._ -]"), "_").ifBlank { "preset" }
|
||||
|
||||
private companion object {
|
||||
const val PRESET_EXT = ".szp"
|
||||
const val BUNDLE_DIR = ".bundles"
|
||||
const val IMPORT_DIR = ".import"
|
||||
const val SEEDED_MARKER = ".seeded"
|
||||
}
|
||||
}
|
||||
233
app/src/main/java/space/rcmd/android/sizzle/io/ProjectIo.kt
Normal file
233
app/src/main/java/space/rcmd/android/sizzle/io/ProjectIo.kt
Normal file
@@ -0,0 +1,233 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.io
|
||||
|
||||
import space.rcmd.android.sizzle.model.Cell
|
||||
import space.rcmd.android.sizzle.model.Mixer
|
||||
import space.rcmd.android.sizzle.model.Pattern
|
||||
import space.rcmd.android.sizzle.model.Project
|
||||
import space.rcmd.android.sizzle.model.Scale
|
||||
import space.rcmd.android.sizzle.model.TimeSignature
|
||||
import space.rcmd.android.sizzle.model.ToolboxType
|
||||
|
||||
/**
|
||||
* Saves and loads a whole [Project] to/from the `.sng` text format.
|
||||
*
|
||||
* The format is line-oriented and human-readable (sections in [BRACKETS], one
|
||||
* record per line). It is our own layout, aligned in spirit with the reference
|
||||
* Sizzletracker project (github.com/reactorcoremeltdown/sizzletracker). A field
|
||||
* mapping to that project's exact byte layout is the remaining work — see
|
||||
* DEVELOPER_HANDOVER.md, "Project format". Because this writer/reader is fully
|
||||
* round-trip safe on its own, the app is usable today and the compatibility
|
||||
* shim can be added without touching any other code.
|
||||
*
|
||||
* Only non-empty tracker cells are written, keeping songs compact.
|
||||
*/
|
||||
object ProjectIo {
|
||||
private const val HEADER = "SIZZLETRACKER-SNG 1"
|
||||
|
||||
// ------------------------------------------------------------------- write
|
||||
fun save(project: Project): String = buildString {
|
||||
appendLine(HEADER)
|
||||
appendLine("NAME=${project.name}")
|
||||
appendLine("TEMPO=${project.tempoBpm}")
|
||||
appendLine("SIG=${project.timeSignature.name}")
|
||||
appendLine("SCALE=${project.scale.name}")
|
||||
appendLine("ROOT=${project.rootNote}")
|
||||
appendLine("ACTIVE=${project.activePatternId}")
|
||||
|
||||
project.patterns.forEach { p ->
|
||||
appendLine("[PATTERN id=${p.id} name=${p.name} len=${p.length}]")
|
||||
for (t in 0 until Pattern.TRACK_COUNT) {
|
||||
for (line in 0 until p.length) {
|
||||
val cell = p.cell(t, line)
|
||||
if (!cell.isEmpty) appendLine("$t,$line,${cell.note},${cell.velocity},${cell.channel}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val arr = project.arrangement
|
||||
val mutes = arr.laneMuted.joinToString("") { if (it) "1" else "0" }
|
||||
appendLine("[ARRANGEMENT bars=${arr.lengthBars} loop=${arr.loopEnabled.b()} mutes=$mutes]")
|
||||
val usedBeats = arr.lastFilledBeat() + 1
|
||||
for (lane in arr.slots.indices) {
|
||||
for (beat in 0 until usedBeats) {
|
||||
val pid = arr.slots[lane][beat]
|
||||
if (pid >= 0) appendLine("$lane,$beat,$pid")
|
||||
}
|
||||
}
|
||||
appendLine("[REGION A en=${arr.regionA.enabled.b()} s=${arr.regionA.start} e=${arr.regionA.end} r=${arr.regionA.repeats}]")
|
||||
appendLine("[REGION B en=${arr.regionB.enabled.b()} s=${arr.regionB.start} e=${arr.regionB.end} r=${arr.regionB.repeats}]")
|
||||
|
||||
appendLine("[MIXER]")
|
||||
project.mixer.channels.forEach { ch ->
|
||||
appendLine(
|
||||
"${ch.index},${ch.instrumentSlot},${ch.fxSlots.joinToString(",")}," +
|
||||
"${ch.midiChannel},${ch.volume},${ch.mute.b()},${ch.solo.b()},${ch.limiter.b()}",
|
||||
)
|
||||
}
|
||||
|
||||
appendLine("[TOOLBOX]")
|
||||
project.toolbox.forEach { slot ->
|
||||
if (!slot.isEmpty) {
|
||||
val params = slot.values.entries.joinToString(",") { "${it.key}=${it.value}" }
|
||||
appendLine("${slot.index}=${slot.type!!.name};${slot.name};$params")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- read
|
||||
/**
|
||||
* Parse [text] into the *existing* [project], replacing its contents. Used to
|
||||
* load a song into the app's shared singleton without swapping the object the
|
||||
* ViewModel and audio engine already hold a reference to.
|
||||
*/
|
||||
fun loadInto(project: Project, text: String): Project {
|
||||
// Reset everything to a clean slate first.
|
||||
project.patterns.clear()
|
||||
for (lane in project.arrangement.slots.indices)
|
||||
project.arrangement.slots[lane].fill(space.rcmd.android.sizzle.model.Arrangement.EMPTY)
|
||||
project.arrangement.laneMuted.fill(false)
|
||||
project.toolbox.forEach { it.clearSlot() }
|
||||
project.mixer.channels.forEach { ch ->
|
||||
ch.instrumentSlot = -1; ch.fxSlots.fill(-1); ch.midiChannel = 0
|
||||
ch.volume = 0.8f; ch.mute = false; ch.solo = false; ch.limiter = false
|
||||
}
|
||||
var section = ""
|
||||
var current: Pattern? = null
|
||||
|
||||
text.lineSequence().map { it.trim() }.filter { it.isNotEmpty() }.forEach { line ->
|
||||
// Per-line safety net: a single malformed line is skipped rather than
|
||||
// aborting the whole load (which would leave a half-cleared project).
|
||||
runCatching {
|
||||
when {
|
||||
line == HEADER -> {}
|
||||
line.startsWith("[PATTERN") -> {
|
||||
val attrs = parseAttrs(line)
|
||||
current = Pattern(
|
||||
id = attrs["id"]?.toIntOrNull() ?: project.patterns.size,
|
||||
name = attrs["name"] ?: "PTN",
|
||||
length = attrs["len"]?.toIntOrNull() ?: 16,
|
||||
).also { project.patterns.add(it) }
|
||||
section = "PATTERN"
|
||||
}
|
||||
line.startsWith("[ARRANGEMENT") -> {
|
||||
val attrs = parseAttrs(line)
|
||||
// "bars" is the current canvas unit; older files stored "len" in
|
||||
// beats — those simply get the default 256-bar canvas (their cells
|
||||
// are restored from the explicit lane,beat,pid lines regardless).
|
||||
project.arrangement.lengthBars =
|
||||
attrs["bars"]?.toIntOrNull()
|
||||
?: space.rcmd.android.sizzle.model.Arrangement.MAX_BARS
|
||||
project.arrangement.loopEnabled = attrs["loop"] == "1"
|
||||
attrs["mutes"]?.let { m ->
|
||||
val lanes = project.arrangement.laneMuted
|
||||
for (i in 0 until minOf(m.length, lanes.size)) lanes[i] = m[i] == '1'
|
||||
}
|
||||
section = "ARRANGEMENT"
|
||||
}
|
||||
line.startsWith("[REGION A") -> applyRegion(project.arrangement.regionA, parseAttrs(line))
|
||||
line.startsWith("[REGION B") -> applyRegion(project.arrangement.regionB, parseAttrs(line))
|
||||
line.startsWith("[MIXER]") -> section = "MIXER"
|
||||
line.startsWith("[TOOLBOX]") -> section = "TOOLBOX"
|
||||
line.contains('=') && !line.startsWith("[") && section == "" ->
|
||||
applyHeaderField(project, line)
|
||||
section == "PATTERN" -> applyPatternCell(current, line)
|
||||
section == "ARRANGEMENT" -> applyArrangementSlot(project, line)
|
||||
section == "MIXER" -> applyMixerRow(project, line)
|
||||
section == "TOOLBOX" -> applyToolboxRow(project, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Guarantee one block per lane (lane i is tied to block i), padding any
|
||||
// that the file did not define so the arrangement/editor always resolve.
|
||||
for (id in 0 until space.rcmd.android.sizzle.model.Arrangement.LANE_COUNT) {
|
||||
if (project.patterns.none { it.id == id }) project.patterns.add(Pattern(id, name = "BLK${id + 1}"))
|
||||
}
|
||||
project.patterns.sortBy { it.id }
|
||||
project.activePatternId = project.activePatternId.coerceIn(0, project.patterns.size - 1)
|
||||
return project
|
||||
}
|
||||
|
||||
// ----- small helpers -----
|
||||
private fun Boolean.b() = if (this) "1" else "0"
|
||||
|
||||
private fun parseAttrs(line: String): Map<String, String> =
|
||||
line.trim('[', ']').substringAfter(' ', "").split(' ')
|
||||
.mapNotNull { tok -> tok.split('=', limit = 2).takeIf { it.size == 2 }?.let { it[0] to it[1] } }
|
||||
.toMap()
|
||||
|
||||
private fun applyHeaderField(p: Project, line: String) {
|
||||
val (k, v) = line.split('=', limit = 2)
|
||||
when (k) {
|
||||
"NAME" -> p.name = v
|
||||
"TEMPO" -> p.tempoBpm = v.toFloatOrNull() ?: 120f
|
||||
"SIG" -> p.timeSignature = runCatching { TimeSignature.valueOf(v) }.getOrDefault(TimeSignature.FOUR_FOUR)
|
||||
"SCALE" -> p.scale = runCatching { Scale.valueOf(v) }.getOrDefault(Scale.CHROMATIC)
|
||||
"ROOT" -> p.rootNote = v.toIntOrNull() ?: 0
|
||||
"ACTIVE" -> p.activePatternId = v.toIntOrNull() ?: 0
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyPatternCell(pattern: Pattern?, line: String) {
|
||||
val p = pattern ?: return
|
||||
val f = line.split(',')
|
||||
if (f.size < 5) return
|
||||
val t = f[0].toIntOrNull() ?: return
|
||||
val ln = f[1].toIntOrNull() ?: return
|
||||
if (t !in 0 until Pattern.TRACK_COUNT || ln !in 0 until p.length) return
|
||||
p.cell(t, ln).apply {
|
||||
note = f[2].toIntOrNull() ?: Cell.EMPTY
|
||||
velocity = (f[3].toIntOrNull() ?: Cell.MAX_VELOCITY).coerceIn(0, Cell.MAX_VELOCITY)
|
||||
channel = (f[4].toIntOrNull() ?: 1).coerceIn(1, Cell.MAX_CHANNEL)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyArrangementSlot(p: Project, line: String) {
|
||||
val f = line.split(',')
|
||||
if (f.size < 3) return
|
||||
val lane = f[0].toIntOrNull() ?: return
|
||||
val beat = f[1].toIntOrNull() ?: return
|
||||
val pid = f[2].toIntOrNull() ?: return
|
||||
p.arrangement.set(lane, beat, pid)
|
||||
}
|
||||
|
||||
private fun applyRegion(region: space.rcmd.android.sizzle.model.LoopRegion, a: Map<String, String>) {
|
||||
region.enabled = a["en"] == "1"
|
||||
region.start = a["s"]?.toIntOrNull() ?: 0
|
||||
region.end = a["e"]?.toIntOrNull() ?: 0
|
||||
region.repeats = a["r"]?.toIntOrNull() ?: 2
|
||||
}
|
||||
|
||||
private fun applyMixerRow(p: Project, line: String) {
|
||||
val f = line.split(',')
|
||||
if (f.size < 10) return
|
||||
val idx = f[0].toIntOrNull() ?: return
|
||||
val ch = p.mixer.channels.getOrNull(idx) ?: return
|
||||
ch.instrumentSlot = f[1].toIntOrNull() ?: -1
|
||||
for (i in 0 until Mixer.FX_SLOTS) ch.fxSlots[i] = f[2 + i].toIntOrNull() ?: -1
|
||||
ch.midiChannel = f[6].toIntOrNull() ?: 0
|
||||
ch.volume = f[7].toFloatOrNull() ?: 0.8f
|
||||
ch.mute = f[8] == "1"
|
||||
ch.solo = f[9] == "1"
|
||||
ch.limiter = f.getOrNull(10) == "1" // optional: absent in older saves
|
||||
}
|
||||
|
||||
private fun applyToolboxRow(p: Project, line: String) {
|
||||
val idx = line.substringBefore('=').toIntOrNull() ?: return
|
||||
val rest = line.substringAfter('=')
|
||||
val parts = rest.split(';')
|
||||
if (parts.size < 2) return
|
||||
val type = ToolboxType.entries.firstOrNull { it.name == parts[0] } ?: return
|
||||
val slot = p.toolbox.getOrNull(idx) ?: return
|
||||
slot.fill(type)
|
||||
slot.name = parts[1]
|
||||
if (parts.size >= 3 && parts[2].isNotBlank()) {
|
||||
parts[2].split(',').forEach { kv ->
|
||||
val e = kv.split('=', limit = 2)
|
||||
if (e.size == 2) slot.set(e[0], e[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.io
|
||||
|
||||
import space.rcmd.android.sizzle.model.Project
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Autosaves the whole [Project] to a single file in the app's scoped storage and
|
||||
* restores it on the next launch — this is the crash-recovery / resume-where-you-
|
||||
* left-off layer. The payload is the full-fidelity [ProjectIo] text, so every
|
||||
* musical, mixer and toolbox parameter is preserved.
|
||||
*
|
||||
* Note: the Sampler's actual audio lives in the in-memory SampleStore, not in the
|
||||
* project text; pad assignments and params are restored, but re-import a sample
|
||||
* (or load a saved preset) to hear it again after a cold restart.
|
||||
*/
|
||||
class ProjectStore(private val file: File) {
|
||||
|
||||
/** Write the current project. Never throws — a failed autosave must not crash. */
|
||||
fun save(project: Project) {
|
||||
runCatching {
|
||||
file.parentFile?.mkdirs()
|
||||
file.writeText(ProjectIo.save(project))
|
||||
}
|
||||
}
|
||||
|
||||
/** Restore the autosaved project into [project] in place. Returns true on
|
||||
* success; a missing or corrupt file leaves [project] untouched. */
|
||||
fun restore(project: Project): Boolean =
|
||||
runCatching {
|
||||
if (!file.exists()) return false
|
||||
ProjectIo.loadInto(project, file.readText())
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.io
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
/** The DataStore file backing app-level settings. */
|
||||
private val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore(name = "app_settings")
|
||||
|
||||
/** App-level settings that persist across restarts (theme, recording, keyboard). */
|
||||
data class AppSettings(
|
||||
val paletteName: String? = null,
|
||||
/** Persisted SAF tree URI for master-recording WAV output, or null if unset. */
|
||||
val recordDirUri: String? = null,
|
||||
/** FX-tail length (bars) captured after the sequencer stops. */
|
||||
val recordTailBars: Int = 2,
|
||||
/** Target MIDI channel for the on-screen keyboards (0 == empty channel). */
|
||||
val kbdChannel: Int = 0,
|
||||
/** Base octave for the on-screen keyboards. */
|
||||
val kbdOctave: Int = 3,
|
||||
)
|
||||
|
||||
/**
|
||||
* Persists app-level UI/audio settings (the colour theme and the low-latency
|
||||
* engine toggle). Song/mixer/toolbox parameters live in the project and are
|
||||
* handled by [ProjectStore]; input bindings by
|
||||
* [space.rcmd.android.sizzle.input.BindingStore].
|
||||
*
|
||||
* Audio output/input *device* selection is intentionally not persisted: Android
|
||||
* device ids are session-scoped and don't identify the same hardware next launch.
|
||||
*/
|
||||
class SettingsStore(private val context: Context) {
|
||||
|
||||
suspend fun load(): AppSettings {
|
||||
val prefs = context.settingsDataStore.data.first()
|
||||
return AppSettings(
|
||||
paletteName = prefs[PALETTE],
|
||||
recordDirUri = prefs[RECORD_DIR],
|
||||
recordTailBars = prefs[RECORD_TAIL] ?: 2,
|
||||
kbdChannel = prefs[KBD_CHANNEL] ?: 0,
|
||||
kbdOctave = prefs[KBD_OCTAVE] ?: 3,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun save(paletteName: String) {
|
||||
context.settingsDataStore.edit { prefs ->
|
||||
prefs[PALETTE] = paletteName
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the master-recording output folder and FX-tail length independently
|
||||
* of the theme/engine settings (independent DataStore keys). */
|
||||
suspend fun saveRecording(dirUri: String?, tailBars: Int) {
|
||||
context.settingsDataStore.edit { prefs ->
|
||||
if (dirUri != null) prefs[RECORD_DIR] = dirUri
|
||||
prefs[RECORD_TAIL] = tailBars
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the on-screen keyboard's MIDI channel + base octave. */
|
||||
suspend fun saveKeyboard(channel: Int, octave: Int) {
|
||||
context.settingsDataStore.edit { prefs ->
|
||||
prefs[KBD_CHANNEL] = channel
|
||||
prefs[KBD_OCTAVE] = octave
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val PALETTE = stringPreferencesKey("palette")
|
||||
val RECORD_DIR = stringPreferencesKey("recordDirUri")
|
||||
val RECORD_TAIL = intPreferencesKey("recordTailBars")
|
||||
val KBD_CHANNEL = intPreferencesKey("kbdChannel")
|
||||
val KBD_OCTAVE = intPreferencesKey("kbdOctave")
|
||||
}
|
||||
}
|
||||
149
app/src/main/java/space/rcmd/android/sizzle/io/SngFormat.kt
Normal file
149
app/src/main/java/space/rcmd/android/sizzle/io/SngFormat.kt
Normal file
@@ -0,0 +1,149 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.io
|
||||
|
||||
import space.rcmd.android.sizzle.model.Arrangement
|
||||
import space.rcmd.android.sizzle.model.Cell
|
||||
import space.rcmd.android.sizzle.model.Pattern
|
||||
import space.rcmd.android.sizzle.model.Pitch
|
||||
import space.rcmd.android.sizzle.model.Project
|
||||
import space.rcmd.android.sizzle.model.TimeSignature
|
||||
|
||||
/**
|
||||
* Reads and writes the reference desktop **sizzletracker `.sng`** format
|
||||
* (github.com/reactorcoremeltdown/sizzletracker) so songs interchange with the
|
||||
* CLI version. It is line-oriented text:
|
||||
*
|
||||
* ```
|
||||
* version 1
|
||||
* bpm 120
|
||||
* sig 4 4
|
||||
*
|
||||
* block A 16 4
|
||||
* roll ####
|
||||
* track T1 0
|
||||
* 0 C-4 64 01
|
||||
* 4 E-4 .. ..
|
||||
* 8 OFF .. ..
|
||||
* endblock
|
||||
* ```
|
||||
*
|
||||
* The per-block `roll` (one char per beat, `#` = plays) maps directly onto our
|
||||
* lane-per-block arrangement. Velocity is hexadecimal; `..` means "default"; a
|
||||
* step channel of `..` inherits the track's declared channel. This format carries
|
||||
* the musical data only — mixer/toolbox settings use our own [ProjectIo].
|
||||
*/
|
||||
object SngFormat {
|
||||
|
||||
// ------------------------------------------------------------------- write
|
||||
fun export(project: Project): String = buildString {
|
||||
appendLine("version 1")
|
||||
appendLine("bpm ${project.tempoBpm.toInt()}")
|
||||
appendLine("sig ${project.timeSignature.beatsPerBar} 4")
|
||||
appendLine()
|
||||
|
||||
val arr = project.arrangement
|
||||
val usedBeats = arr.lastFilledBeat() + 1
|
||||
for (block in project.patterns) {
|
||||
val rollUsed = (0 until usedBeats).any { arr.patternAt(block.id, it) != Arrangement.EMPTY }
|
||||
val hasNotes = (0 until Pattern.TRACK_COUNT).any { t ->
|
||||
(0 until block.length).any { !block.cell(t, it).isEmpty }
|
||||
}
|
||||
if (!rollUsed && !hasNotes) continue // skip untouched blocks
|
||||
|
||||
appendLine("block ${block.name} ${block.length} ${project.timeSignature.beatsPerBar}")
|
||||
|
||||
val roll = buildString {
|
||||
for (beat in 0 until usedBeats) {
|
||||
append(if (arr.patternAt(block.id, beat) != Arrangement.EMPTY) '#' else '.')
|
||||
}
|
||||
}.trimEnd('.')
|
||||
if (roll.isNotEmpty()) appendLine("roll $roll")
|
||||
|
||||
for (t in 0 until Pattern.TRACK_COUNT) {
|
||||
val steps = (0 until block.length).filter { !block.cell(t, it).isEmpty }
|
||||
if (steps.isEmpty()) continue
|
||||
val defChannel = (project.mixer.channels[t].midiChannel - 1).coerceAtLeast(0)
|
||||
appendLine("track T${t + 1} $defChannel")
|
||||
for (line in steps) {
|
||||
val cell = block.cell(t, line)
|
||||
val note = if (cell.isNoteOff) "OFF" else Pitch.name(cell.note)
|
||||
val vel = cell.velocity.toString(16).uppercase().padStart(2, '0')
|
||||
val chan = cell.channel.toString().padStart(2, '0')
|
||||
appendLine("$line $note $vel $chan")
|
||||
}
|
||||
}
|
||||
appendLine("endblock")
|
||||
appendLine()
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- read
|
||||
/** Parse `.sng` [text] into the existing [project] (resetting its content). */
|
||||
fun importInto(project: Project, text: String): Project {
|
||||
// Clear blocks and arrangement (keep the 8 lane-blocks, wipe their cells).
|
||||
project.patterns.forEach { p -> for (t in 0 until Pattern.TRACK_COUNT) p.tracks[t].forEach { it.clear() } }
|
||||
for (lane in project.arrangement.slots.indices) project.arrangement.slots[lane].fill(Arrangement.EMPTY)
|
||||
project.arrangement.laneMuted.fill(false)
|
||||
|
||||
var blockIndex = 0
|
||||
var current: Pattern? = null
|
||||
var track = 0
|
||||
var trackChannel = 1
|
||||
var maxRoll = 0
|
||||
|
||||
text.lineSequence().map { it.trim() }.filter { it.isNotEmpty() }.forEach { line ->
|
||||
val tok = line.split(Regex("\\s+"))
|
||||
when (tok[0]) {
|
||||
"version" -> {}
|
||||
"bpm" -> project.tempoBpm = tok.getOrNull(1)?.toFloatOrNull() ?: 120f
|
||||
"sig" -> project.timeSignature = when (tok.getOrNull(1)?.toIntOrNull()) {
|
||||
3 -> TimeSignature.THREE_FOUR
|
||||
5 -> TimeSignature.FIVE_FOUR
|
||||
else -> TimeSignature.FOUR_FOUR
|
||||
}
|
||||
"block" -> {
|
||||
current = project.patterns.getOrNull(blockIndex)?.also { blk ->
|
||||
tok.getOrNull(1)?.let { blk.name = it }
|
||||
tok.getOrNull(2)?.toIntOrNull()?.let { blk.resize(it) }
|
||||
}
|
||||
blockIndex++
|
||||
track = 0; trackChannel = 1
|
||||
}
|
||||
"roll" -> current?.let { blk ->
|
||||
val roll = tok.getOrNull(1) ?: ""
|
||||
roll.forEachIndexed { beat, ch -> if (ch == '#') project.arrangement.set(blk.id, beat, blk.id) }
|
||||
maxRoll = maxOf(maxRoll, roll.length)
|
||||
}
|
||||
"track" -> {
|
||||
track = tok.getOrNull(1)?.filter { it.isDigit() }?.toIntOrNull()?.minus(1)?.coerceIn(0, Pattern.TRACK_COUNT - 1) ?: 0
|
||||
trackChannel = tok.getOrNull(2)?.toIntOrNull()?.plus(1)?.coerceIn(1, 16) ?: 1
|
||||
}
|
||||
"endblock" -> current = null
|
||||
else -> applyStep(project, current, track, trackChannel, tok)
|
||||
}
|
||||
}
|
||||
// Grow the canvas (in bars) if the imported roll is longer than the default.
|
||||
if (maxRoll > 0) {
|
||||
val beatsPerBar = project.timeSignature.beatsPerBar
|
||||
val neededBars = (maxRoll + beatsPerBar - 1) / beatsPerBar // ceil
|
||||
project.arrangement.lengthBars =
|
||||
maxOf(project.arrangement.lengthBars, neededBars).coerceAtMost(Arrangement.MAX_BARS)
|
||||
}
|
||||
project.activePatternId = 0
|
||||
return project
|
||||
}
|
||||
|
||||
private fun applyStep(project: Project, block: Pattern?, track: Int, trackChannel: Int, tok: List<String>) {
|
||||
val blk = block ?: return
|
||||
val tick = tok.getOrNull(0)?.toIntOrNull() ?: return
|
||||
if (tick !in 0 until blk.length) return
|
||||
val cell = blk.cell(track, tick)
|
||||
cell.note = Pitch.parse(tok.getOrElse(1) { "..." })
|
||||
cell.velocity = (tok.getOrNull(2)?.takeIf { it != ".." }?.toIntOrNull(16) ?: Cell.MAX_VELOCITY)
|
||||
.coerceIn(0, Cell.MAX_VELOCITY)
|
||||
cell.channel = (tok.getOrNull(3)?.takeIf { it != ".." }?.toIntOrNull() ?: trackChannel)
|
||||
.coerceIn(1, Cell.MAX_CHANNEL)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.io
|
||||
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* The on-device project library. Songs are stored as **full-fidelity** projects
|
||||
* ([ProjectIo] text: patterns, arrangement, mixer, FX and instrument settings) so
|
||||
* saving/loading round-trips the whole song — unlike the desktop-compatible `.sng`
|
||||
* ([SngFormat]), which carries only the musical data and is used purely for
|
||||
* export/import interchange (see [writeExport]).
|
||||
*
|
||||
* <baseDir>/My Tune.szg ← full project (what Save/Load use)
|
||||
* <baseDir>/.export/….sng ← transient .sng written for sharing
|
||||
*
|
||||
* The library only moves text around; serialization lives in [ProjectIo] /
|
||||
* [SngFormat].
|
||||
*/
|
||||
class SongLibrary(private val baseDir: File) {
|
||||
|
||||
/** Names (without extension) of the stored full projects, alphabetically. */
|
||||
fun list(): List<String> =
|
||||
baseDir.listFiles { f -> f.isFile && f.name.endsWith(EXT) }
|
||||
?.map { it.name.removeSuffix(EXT) }
|
||||
?.sorted()
|
||||
?: emptyList()
|
||||
|
||||
fun file(name: String): File = File(baseDir.apply { mkdirs() }, sanitize(name) + EXT)
|
||||
|
||||
/** Write [text] as the named full project. Returns the written file. */
|
||||
fun save(name: String, text: String): File {
|
||||
val f = file(name)
|
||||
f.writeText(text)
|
||||
return f
|
||||
}
|
||||
|
||||
/** Read the named full project's text, or null if it doesn't exist. */
|
||||
fun load(name: String): String? = file(name).takeIf { it.exists() }?.readText()
|
||||
|
||||
/** Delete the named full project. Returns true if it existed. */
|
||||
fun delete(name: String): Boolean = file(name).let { if (it.exists()) it.delete() else false }
|
||||
|
||||
/**
|
||||
* Write a one-off `.sng` export (desktop interchange) to a shareable sub-dir
|
||||
* and return the file. Kept separate from the [EXT] library files so it never
|
||||
* shows up in the project list.
|
||||
*/
|
||||
fun writeExport(name: String, sngText: String): File {
|
||||
val dir = File(baseDir, EXPORT_DIR).apply { mkdirs() }
|
||||
val f = File(dir, sanitize(name) + ".sng")
|
||||
f.writeText(sngText)
|
||||
return f
|
||||
}
|
||||
|
||||
/** Keep names safe as file names while staying human-readable. */
|
||||
private fun sanitize(name: String): String =
|
||||
name.trim().replace(Regex("[^A-Za-z0-9._ -]"), "_").ifBlank { "song" }
|
||||
|
||||
private companion object {
|
||||
const val EXT = ".szg" // full project (ProjectIo)
|
||||
const val EXPORT_DIR = ".export"
|
||||
}
|
||||
}
|
||||
79
app/src/main/java/space/rcmd/android/sizzle/io/ThemeIo.kt
Normal file
79
app/src/main/java/space/rcmd/android/sizzle/io/ThemeIo.kt
Normal file
@@ -0,0 +1,79 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.io
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import space.rcmd.android.sizzle.ui.theme.RetroPalette
|
||||
|
||||
/**
|
||||
* Reads and writes a colour [RetroPalette] as plain, human-editable text — the same
|
||||
* `SIZZLE-THEME 1` format the Settings tab exports, so a person can hand-tweak a
|
||||
* theme and it round-trips losslessly:
|
||||
*
|
||||
* SIZZLE-THEME 1
|
||||
* NAME=Amber CRT
|
||||
* background=#0B0D0E
|
||||
* surface=#15181A
|
||||
* ...
|
||||
*
|
||||
* Parsing is lenient about individual colour lines (a missing/garbled colour falls
|
||||
* back to the default palette's value) but rejects text that isn't a theme at all,
|
||||
* so importing a wrong file fails cleanly instead of producing a broken palette.
|
||||
*/
|
||||
object ThemeIo {
|
||||
const val HEADER = "SIZZLE-THEME 1"
|
||||
|
||||
fun export(p: RetroPalette): String = buildString {
|
||||
appendLine(HEADER)
|
||||
appendLine("NAME=${p.name}")
|
||||
appendLine("background=${hex(p.background)}")
|
||||
appendLine("surface=${hex(p.surface)}")
|
||||
appendLine("grid=${hex(p.grid)}")
|
||||
appendLine("text=${hex(p.text)}")
|
||||
appendLine("textDim=${hex(p.textDim)}")
|
||||
appendLine("accent=${hex(p.accent)}")
|
||||
appendLine("beat=${hex(p.beat)}")
|
||||
appendLine("bar=${hex(p.bar)}")
|
||||
appendLine("playhead=${hex(p.playhead)}")
|
||||
appendLine("danger=${hex(p.danger)}")
|
||||
}
|
||||
|
||||
/** Parse theme [text] into a [RetroPalette], or null if it isn't a valid theme. */
|
||||
fun parse(text: String): RetroPalette? {
|
||||
val map = HashMap<String, String>()
|
||||
var sawHeader = false
|
||||
text.lineSequence().map { it.trim() }.filter { it.isNotEmpty() }.forEach { line ->
|
||||
if (line == HEADER) { sawHeader = true; return@forEach }
|
||||
val eq = line.indexOf('=')
|
||||
if (eq > 0) map[line.substring(0, eq).trim()] = line.substring(eq + 1).trim()
|
||||
}
|
||||
if (!sawHeader) return null
|
||||
val name = map["NAME"]?.takeIf { it.isNotBlank() } ?: return null
|
||||
val d = RetroPalette.AMBER // per-colour fallback
|
||||
return RetroPalette(
|
||||
name = name,
|
||||
background = color(map["background"], d.background),
|
||||
surface = color(map["surface"], d.surface),
|
||||
grid = color(map["grid"], d.grid),
|
||||
text = color(map["text"], d.text),
|
||||
textDim = color(map["textDim"], d.textDim),
|
||||
accent = color(map["accent"], d.accent),
|
||||
beat = color(map["beat"], d.beat),
|
||||
bar = color(map["bar"], d.bar),
|
||||
playhead = color(map["playhead"], d.playhead),
|
||||
danger = color(map["danger"], d.danger),
|
||||
)
|
||||
}
|
||||
|
||||
private fun hex(c: Color): String =
|
||||
"#%02X%02X%02X".format((c.red * 255).toInt(), (c.green * 255).toInt(), (c.blue * 255).toInt())
|
||||
|
||||
/** Parse "#RRGGBB" (or "RRGGBB") into an opaque colour; [fallback] on any error. */
|
||||
private fun color(s: String?, fallback: Color): Color {
|
||||
val hex = s?.trim()?.removePrefix("#") ?: return fallback
|
||||
if (hex.length != 6) return fallback
|
||||
val rgb = hex.toLongOrNull(16) ?: return fallback
|
||||
return Color(0xFF000000L or (rgb and 0xFFFFFF))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.io
|
||||
|
||||
import space.rcmd.android.sizzle.ui.theme.RetroPalette
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* On-device library of USER colour themes, stored as plain [ThemeIo] text:
|
||||
*
|
||||
* <baseDir>/Sunset.szt ← a saved/imported theme
|
||||
* <baseDir>/.export/….szt ← transient copy written for sharing
|
||||
*
|
||||
* The two built-in themes ([RetroPalette.ALL]) are NOT stored here — they live in
|
||||
* code and can never be deleted. This library only holds themes the user imports,
|
||||
* so `delete` here can only ever remove a user theme.
|
||||
*/
|
||||
class ThemeLibrary(private val baseDir: File) {
|
||||
|
||||
/** Names (without extension) of the stored user themes, alphabetically. */
|
||||
fun list(): List<String> =
|
||||
baseDir.listFiles { f -> f.isFile && f.name.endsWith(EXT) }
|
||||
?.map { it.name.removeSuffix(EXT) }
|
||||
?.sorted()
|
||||
?: emptyList()
|
||||
|
||||
private fun file(name: String): File = File(baseDir.apply { mkdirs() }, sanitize(name) + EXT)
|
||||
|
||||
/** Save [palette] as a user theme (named after it). Returns the written file. */
|
||||
fun save(palette: RetroPalette): File = file(palette.name).apply { writeText(ThemeIo.export(palette)) }
|
||||
|
||||
/** Load a stored user theme by name, or null if missing/unparseable. */
|
||||
fun load(name: String): RetroPalette? =
|
||||
file(name).takeIf { it.exists() }?.let { runCatching { ThemeIo.parse(it.readText()) }.getOrNull() }
|
||||
|
||||
/** Every stored user theme, skipping any that fail to parse. */
|
||||
fun loadAll(): List<RetroPalette> = list().mapNotNull { load(it) }
|
||||
|
||||
/** Delete a stored user theme. Returns true if it existed. */
|
||||
fun delete(name: String): Boolean = file(name).let { if (it.exists()) it.delete() else false }
|
||||
|
||||
/** Import theme [text] (from a picked file): parse, persist to the library, and
|
||||
* return the palette — or null if the text isn't a valid theme. */
|
||||
fun importText(text: String): RetroPalette? {
|
||||
val palette = runCatching { ThemeIo.parse(text) }.getOrNull() ?: return null
|
||||
save(palette)
|
||||
return palette
|
||||
}
|
||||
|
||||
/** Write a one-off `.szt` to a shareable sub-dir and return the file (kept out of
|
||||
* the library list, like [SongLibrary.writeExport]). */
|
||||
fun writeExport(palette: RetroPalette): File {
|
||||
val dir = File(baseDir, EXPORT_DIR).apply { mkdirs() }
|
||||
return File(dir, sanitize(palette.name) + EXT).apply { writeText(ThemeIo.export(palette)) }
|
||||
}
|
||||
|
||||
private fun sanitize(name: String): String =
|
||||
name.trim().replace(Regex("[^A-Za-z0-9._ -]"), "_").ifBlank { "theme" }
|
||||
|
||||
private companion object {
|
||||
const val EXT = ".szt"
|
||||
const val EXPORT_DIR = ".export"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.model
|
||||
|
||||
/**
|
||||
* Factory data for the [ToolboxType.AMBIENCE] reverb, ported from OTODESK's
|
||||
* "Ambience 1.0.1" (a 16-channel FDN reverb). Only the tunables and the 21 factory
|
||||
* presets are carried over — the plugin's graphic EQ, output EQ/cut filters and
|
||||
* visualizers are intentionally left out. Stereo width and the per-band Pro-mode
|
||||
* controls don't apply to this app's mono per-channel FX chain and are omitted too.
|
||||
*
|
||||
* A preset simply pins the reverb's parameters to a modelled acoustic space; the
|
||||
* [AmbienceEditor] writes these values straight into the slot's value map, after
|
||||
* which the user can freely tweak any control.
|
||||
*/
|
||||
object AmbiencePresets {
|
||||
|
||||
/** The 7 reverb algorithms (topologies), index-aligned with the DSP. */
|
||||
val ALGORITHMS = listOf("Room 1", "Room 2", "Hall 1", "Hall 2", "Plate", "Spring", "Goldfoil")
|
||||
|
||||
data class Preset(
|
||||
val name: String,
|
||||
val algo: Int,
|
||||
val preDelay: Float,
|
||||
val roomSize: Float,
|
||||
val decay: Float,
|
||||
val hfDamp: Float,
|
||||
val lfAbsorb: Float,
|
||||
val diffusion: Float,
|
||||
val modAmount: Float,
|
||||
val modRate: Float,
|
||||
val erLevel: Float,
|
||||
val saturation: Float,
|
||||
val wetDb: Float,
|
||||
val dryDb: Float,
|
||||
)
|
||||
|
||||
/** The 21 factory spaces, values transcribed from the original `.ambpreset` files. */
|
||||
val FACTORY: List<Preset> = listOf(
|
||||
Preset("Abbey Road", 0, 10f, 0.75f, 0.45f, 0f, 0f, 0.55f, 0.15f, 0.30f, 0.70f, 0.08f, -6f, 0f),
|
||||
Preset("Abbey Road 2", 1, 10f, 1.60f, 1.80f, 0f, 0f, 0.70f, 0.25f, 0.40f, 0.60f, 0.08f, -4f, 0f),
|
||||
Preset("Capitol Studio A", 1, 10f, 1.35f, 1.40f, 0f, 0f, 0.65f, 0.20f, 0.35f, 0.65f, 0.10f, -4f, 0f),
|
||||
Preset("Skywalker Sound", 1, 10f, 1.70f, 2.00f, 0f, 0f, 0.75f, 0.30f, 0.45f, 0.55f, 0.06f, -4f, 0f),
|
||||
Preset("Carnegie Hall", 2, 10f, 1.50f, 1.70f, 0f, 0f, 0.72f, 0.20f, 0.30f, 0.55f, 0.08f, -4f, 0f),
|
||||
Preset("Tokyo Opera City", 2, 10f, 1.65f, 2.00f, 0f, 0f, 0.75f, 0.25f, 0.35f, 0.50f, 0.06f, -4f, 0f),
|
||||
Preset("Berlin Konzerthaus", 2, 10f, 1.60f, 2.00f, 0f, 0f, 0.73f, 0.22f, 0.32f, 0.52f, 0.07f, 0f, 0f),
|
||||
Preset("Vienna Musikverein", 3, 10f, 1.80f, 2.05f, 0f, 0f, 0.80f, 0.25f, 0.30f, 0.50f, 0.07f, -4f, 0f),
|
||||
Preset("Boston Symphony", 3, 10f, 1.70f, 1.85f, 0f, 0f, 0.78f, 0.22f, 0.28f, 0.52f, 0.06f, -4f, 0f),
|
||||
Preset("Concertgebouw", 3, 10f, 1.85f, 2.05f, 0f, 0f, 0.82f, 0.28f, 0.32f, 0.48f, 0.06f, -4f, 0f),
|
||||
Preset("EMT140 Vocal", 4, 10f, 1.00f, 1.80f, 0f, 0f, 0.85f, 0.30f, 0.50f, 0.40f, 0.12f, -4f, 0f),
|
||||
Preset("EMT140 Snare", 4, 10f, 0.80f, 1.00f, 0f, 0f, 0.80f, 0.20f, 0.45f, 0.35f, 0.15f, -4f, 0f),
|
||||
Preset("Dark Plate", 4, 10f, 1.20f, 2.50f, 0f, 0f, 0.88f, 0.35f, 0.55f, 0.10f, 0.10f, -4f, 0f),
|
||||
Preset("Surf Guitar", 5, 10f, 0.70f, 1.50f, 0f, 0f, 0.45f, 0.50f, 0.70f, 0.45f, 0.20f, -4f, 0f),
|
||||
Preset("Vintage Studio", 4, 10f, 0.85f, 2.20f, 0f, 0f, 0.50f, 0.55f, 0.65f, 0.40f, 0.35f, -4f, 0f),
|
||||
Preset("Deep Tank", 5, 10f, 1.10f, 3.50f, 0f, 0f, 0.55f, 0.60f, 0.60f, 0.38f, 0.45f, -4f, 0f),
|
||||
Preset("Gothic Cathedral", 6, 10f, 2.00f, 8.00f, 0f, 0f, 0.90f, 0.40f, 0.25f, 0.35f, 0.05f, -4f, 0f),
|
||||
Preset("Stone Chamber", 6, 10f, 1.80f, 4.00f, 0f, 0f, 0.85f, 0.45f, 0.30f, 0.40f, 0.20f, -4f, 0f),
|
||||
Preset("Infinite Space", 6, 10f, 2.00f, 12.00f, 0f, 0f, 0.95f, 0.65f, 0.28f, 0.25f, 0.10f, -4f, 0f),
|
||||
Preset("Drums in a Box", 0, 10f, 0.55f, 0.30f, 0f, 0f, 0.40f, 0.10f, 0.20f, 0.80f, 0.05f, -4f, 0f),
|
||||
Preset("Tracking Room", 0, 10f, 0.90f, 0.60f, 0f, 0f, 0.65f, 0.20f, 0.35f, 0.65f, 0.10f, -4f, 0f),
|
||||
)
|
||||
|
||||
val NAMES: List<String> get() = FACTORY.map { it.name }
|
||||
|
||||
/** Write [preset]'s values into [slot]'s parameter map. */
|
||||
fun apply(slot: ToolboxSlot, preset: Preset) {
|
||||
slot.set("algo", ALGORITHMS[preset.algo.coerceIn(0, ALGORITHMS.lastIndex)])
|
||||
slot.set("predelay", preset.preDelay)
|
||||
slot.set("roomsize", preset.roomSize)
|
||||
slot.set("decay", preset.decay)
|
||||
slot.set("hfdamp", preset.hfDamp)
|
||||
slot.set("lfabsorb", preset.lfAbsorb)
|
||||
slot.set("diffusion", preset.diffusion)
|
||||
slot.set("modamt", preset.modAmount)
|
||||
slot.set("modrate", preset.modRate)
|
||||
slot.set("erlevel", preset.erLevel)
|
||||
slot.set("saturation", preset.saturation)
|
||||
slot.set("wet", preset.wetDb)
|
||||
slot.set("dry", preset.dryDb)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.model
|
||||
|
||||
/**
|
||||
* The lower-half piano-roll arrangement. It has [LANE_COUNT] lanes stacked
|
||||
* vertically and up to [MAX_BEATS] beats horizontally. Each slot holds the id of
|
||||
* the [Pattern] to trigger on that beat (or [EMPTY]). Per the spec, one marked
|
||||
* slot triggers exactly ONE beat of that pattern/block, even if the pattern is
|
||||
* longer — so the arrangement grid resolution is one-beat-per-column.
|
||||
*
|
||||
* The editable canvas is denominated in *bars* ([lengthBars]); its width in beats
|
||||
* depends on the active time signature ([canvasBeats]). Playback is unaffected by
|
||||
* the canvas width — the sequencer stops at [lastFilledBeat].
|
||||
*/
|
||||
class Arrangement {
|
||||
/** slots[lane][beat] = patternId, or [EMPTY]. */
|
||||
val slots: Array<IntArray> = Array(LANE_COUNT) { IntArray(MAX_BEATS) { EMPTY } }
|
||||
|
||||
/** Per-lane mute. A muted lane's blocks are skipped by the sequencer (its notes
|
||||
* never sound) while its cells stay in place. Read live on the audio thread. */
|
||||
val laneMuted: BooleanArray = BooleanArray(LANE_COUNT)
|
||||
|
||||
fun isLaneMuted(lane: Int): Boolean = lane in 0 until LANE_COUNT && laneMuted[lane]
|
||||
|
||||
/** Editable canvas width in *bars*. */
|
||||
var lengthBars: Int = MAX_BARS
|
||||
|
||||
/** Canvas width in beats for [sig], clamped to the backing array. One bar is
|
||||
* [TimeSignature.beatsPerBar] beats, so the canvas spans [lengthBars] bars
|
||||
* exactly regardless of signature. */
|
||||
fun canvasBeats(sig: TimeSignature): Int =
|
||||
(lengthBars * sig.beatsPerBar).coerceIn(1, MAX_BEATS)
|
||||
|
||||
/** Loop transport state (the toolbar under the roll). */
|
||||
var loopEnabled: Boolean = false
|
||||
val regionA: LoopRegion = LoopRegion()
|
||||
val regionB: LoopRegion = LoopRegion()
|
||||
|
||||
fun patternAt(lane: Int, beat: Int): Int =
|
||||
if (beat in 0 until MAX_BEATS) slots[lane][beat] else EMPTY
|
||||
|
||||
/**
|
||||
* The highest beat index holding any non-empty cell across all lanes, or -1 if
|
||||
* the arrangement is empty. Playback uses this to stop after the last filled
|
||||
* block instead of running out across the empty canvas through trailing silence.
|
||||
*/
|
||||
fun lastFilledBeat(): Int {
|
||||
for (beat in MAX_BEATS - 1 downTo 0) {
|
||||
for (lane in 0 until LANE_COUNT) {
|
||||
if (slots[lane][beat] != EMPTY) return beat
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
fun set(lane: Int, beat: Int, patternId: Int) {
|
||||
if (lane in 0 until LANE_COUNT && beat in 0 until MAX_BEATS) {
|
||||
slots[lane][beat] = patternId
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val LANE_COUNT = 8
|
||||
/** Editable canvas length, in bars. */
|
||||
const val MAX_BARS = 256
|
||||
/** Backing array width in beats: enough to hold [MAX_BARS] at the widest
|
||||
* supported signature (5/4 → 5 beats per bar). */
|
||||
const val MAX_BEATS = MAX_BARS * 5
|
||||
const val EMPTY = -1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An A or B loop region on the arrangement roll. [start] and [end] are beat
|
||||
* indices (inclusive start, exclusive end); [repeats] is how many times the
|
||||
* region plays before control moves on. A region is only active when it has a
|
||||
* non-empty span AND [enabled] is set (toggled by the A/B buttons).
|
||||
*/
|
||||
class LoopRegion(
|
||||
var enabled: Boolean = false,
|
||||
var start: Int = 0,
|
||||
var end: Int = 0,
|
||||
var repeats: Int = 2,
|
||||
) {
|
||||
val isValid: Boolean get() = enabled && end > start
|
||||
}
|
||||
51
app/src/main/java/space/rcmd/android/sizzle/model/Mixer.kt
Normal file
51
app/src/main/java/space/rcmd/android/sizzle/model/Mixer.kt
Normal file
@@ -0,0 +1,51 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.model
|
||||
|
||||
/**
|
||||
* The mixer has exactly [CHANNEL_COUNT] channels, one per tracker track. Each
|
||||
* channel routes through one instrument and up to four effects, all referenced
|
||||
* by their slot index in the [Toolbox] (tab 3). A value of -1 means "no device".
|
||||
*/
|
||||
class Mixer {
|
||||
// Buses start on the empty MIDI channel 0 (nothing routed) for a clean-slate
|
||||
// new project; the user assigns each bus a MIDI channel (and instrument) as
|
||||
// they build the song. Notes on channel 0 sound only through a channel-0 bus.
|
||||
val channels: List<MixerChannel> = List(CHANNEL_COUNT) { MixerChannel(it, midiChannel = 0) }
|
||||
|
||||
/** True if ANY channel is soloed — used to mute the non-soloed ones. Indexed
|
||||
* loop (not `any {}`) so it allocates no Iterator; called once per audio block. */
|
||||
fun anySolo(): Boolean {
|
||||
for (i in channels.indices) if (channels[i].solo) return true
|
||||
return false
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_COUNT = Pattern.TRACK_COUNT // 4, kept in lockstep
|
||||
const val FX_SLOTS = 4
|
||||
const val NO_DEVICE = -1
|
||||
/** Default linear fader level; the fader also snaps back to this. */
|
||||
const val DEFAULT_VOLUME = 0.8f
|
||||
}
|
||||
}
|
||||
|
||||
class MixerChannel(
|
||||
val index: Int,
|
||||
/** Toolbox slot index of the instrument feeding this channel, or -1. */
|
||||
var instrumentSlot: Int = Mixer.NO_DEVICE,
|
||||
/** Toolbox slot indices of up to four insert effects, -1 where unused. */
|
||||
val fxSlots: IntArray = IntArray(Mixer.FX_SLOTS) { Mixer.NO_DEVICE },
|
||||
/** MIDI channel (1..16) this mixer channel listens/sends on. */
|
||||
var midiChannel: Int = 1,
|
||||
/** Linear 0..1 fader. */
|
||||
var volume: Float = Mixer.DEFAULT_VOLUME,
|
||||
var mute: Boolean = false,
|
||||
var solo: Boolean = false,
|
||||
/** When on, this channel's post-FX output is soft-clipped to +-1 before it hits
|
||||
* the master sum — a per-bus safety limiter. */
|
||||
var limiter: Boolean = false,
|
||||
) {
|
||||
/** Whether this channel should actually produce sound given the solo state. */
|
||||
fun audible(anySolo: Boolean): Boolean = !mute && (!anySolo || solo)
|
||||
}
|
||||
112
app/src/main/java/space/rcmd/android/sizzle/model/Music.kt
Normal file
112
app/src/main/java/space/rcmd/android/sizzle/model/Music.kt
Normal file
@@ -0,0 +1,112 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.model
|
||||
|
||||
/**
|
||||
* Musical primitives shared across the whole app: pitches, scales and time
|
||||
* signatures. Everything here is deliberately small, immutable and free of any
|
||||
* Android dependency so it can be unit-tested and reasoned about in isolation.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A MIDI note number, 0..127, where 60 == middle C (C4 in the convention we use).
|
||||
* We keep notes as plain Ints in the hot paths (patterns) for memory reasons and
|
||||
* only wrap them when we need naming/formatting helpers.
|
||||
*/
|
||||
object Pitch {
|
||||
const val LOWEST = 0
|
||||
const val HIGHEST = 127
|
||||
const val MIDDLE_C = 60
|
||||
|
||||
private val NAMES = arrayOf(
|
||||
"C-", "C#", "D-", "D#", "E-", "F-", "F#", "G-", "G#", "A-", "A#", "B-",
|
||||
)
|
||||
|
||||
/** Human/tracker-readable name, e.g. 60 -> "C-4", 61 -> "C#4". */
|
||||
fun name(midi: Int): String {
|
||||
if (midi !in LOWEST..HIGHEST) return "..."
|
||||
val octave = midi / 12 - 1 // MIDI 12 == C0 in this convention
|
||||
return NAMES[midi % 12] + octave
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of [name]: parse "C-4" / "C#4" / "OFF" / "..." into a note value.
|
||||
* Returns [Cell.OFF] for a note-off, [Cell.EMPTY] for empty/unparseable input.
|
||||
* Used by the sizzletracker `.sng` importer.
|
||||
*/
|
||||
fun parse(text: String): Int {
|
||||
val t = text.trim()
|
||||
if (t == "OFF" || t == "===") return Cell.OFF
|
||||
if (t.length < 3 || t.startsWith(".")) return Cell.EMPTY
|
||||
val idx = NAMES.indexOf(t.substring(0, 2))
|
||||
if (idx < 0) return Cell.EMPTY
|
||||
val octave = t.substring(2).toIntOrNull() ?: return Cell.EMPTY
|
||||
return ((octave + 1) * 12 + idx).coerceIn(LOWEST, HIGHEST)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The three time signatures the tracker supports. The key idea in this app is
|
||||
* that a *beat* is made of a fixed number of tracker *lines* (ticks), and that
|
||||
* number is what changes between signatures:
|
||||
* - 3/4 -> 3 lines per beat
|
||||
* - 4/4 -> 4 lines per beat
|
||||
* - 5/4 -> 5 lines per beat
|
||||
* A *bar* is always [beatsPerBar] beats long (here, equal to the numerator).
|
||||
*/
|
||||
enum class TimeSignature(
|
||||
val label: String,
|
||||
val linesPerBeat: Int,
|
||||
val beatsPerBar: Int,
|
||||
/** Allowed pattern lengths (in lines) offered in the length dropdown. */
|
||||
val lengthOptions: List<Int>,
|
||||
) {
|
||||
THREE_FOUR("3/4", linesPerBeat = 3, beatsPerBar = 3, lengthOptions = listOf(12, 24, 48)),
|
||||
FOUR_FOUR("4/4", linesPerBeat = 4, beatsPerBar = 4, lengthOptions = listOf(16, 32, 64)),
|
||||
FIVE_FOUR("5/4", linesPerBeat = 5, beatsPerBar = 5, lengthOptions = listOf(20, 40, 80));
|
||||
|
||||
/** Lines per bar = lines-per-beat * beats-per-bar. */
|
||||
val linesPerBar: Int get() = linesPerBeat * beatsPerBar
|
||||
}
|
||||
|
||||
/**
|
||||
* A musical scale expressed as semitone offsets from the root (0..11).
|
||||
* Used by the tracker's note stepping (keyboard/gamepad +/- and the touch editor)
|
||||
* so the user can only land on in-key notes when a non-chromatic scale is selected.
|
||||
*/
|
||||
enum class Scale(val label: String, val intervals: List<Int>) {
|
||||
CHROMATIC("Chromatic", listOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11)),
|
||||
MAJOR("Major", listOf(0, 2, 4, 5, 7, 9, 11)),
|
||||
MINOR("Natural Minor", listOf(0, 2, 3, 5, 7, 8, 10)),
|
||||
HARMONIC_MINOR("Harmonic Minor", listOf(0, 2, 3, 5, 7, 8, 11)),
|
||||
DORIAN("Dorian", listOf(0, 2, 3, 5, 7, 9, 10)),
|
||||
PHRYGIAN("Phrygian", listOf(0, 1, 3, 5, 7, 8, 10)),
|
||||
MIXOLYDIAN("Mixolydian", listOf(0, 2, 4, 5, 7, 9, 10)),
|
||||
PENTATONIC_MAJOR("Pentatonic Major", listOf(0, 2, 4, 7, 9)),
|
||||
PENTATONIC_MINOR("Pentatonic Minor", listOf(0, 3, 5, 7, 10)),
|
||||
BLUES("Blues", listOf(0, 3, 5, 6, 7, 10));
|
||||
|
||||
/**
|
||||
* Returns the next in-scale MIDI note above [fromMidi] when [direction] is
|
||||
* +1, or below when -1, given a scale [root] (0=C .. 11=B). If [fromMidi] is
|
||||
* off-scale we snap to the nearest in-scale note first. This is the single
|
||||
* function that powers the tracker's note stepping (keyboard/gamepad + editor).
|
||||
*/
|
||||
fun step(fromMidi: Int, root: Int, direction: Int): Int {
|
||||
if (this == CHROMATIC) {
|
||||
return (fromMidi + direction).coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
|
||||
}
|
||||
// Build the set of pitch-classes that are in-key.
|
||||
val inKey = intervals.map { (it + root) % 12 }.toSortedSet()
|
||||
var candidate = fromMidi
|
||||
// Walk one semitone at a time until we hit an in-key pitch-class.
|
||||
do {
|
||||
candidate += direction
|
||||
if (candidate !in Pitch.LOWEST..Pitch.HIGHEST) {
|
||||
return fromMidi // hit the edge, don't move
|
||||
}
|
||||
} while ((candidate % 12) !in inKey)
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
96
app/src/main/java/space/rcmd/android/sizzle/model/Pattern.kt
Normal file
96
app/src/main/java/space/rcmd/android/sizzle/model/Pattern.kt
Normal file
@@ -0,0 +1,96 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.model
|
||||
|
||||
/**
|
||||
* One cell in the tracker grid: a single note event on a single line of a single
|
||||
* track. All three fields map directly to the three on-screen columns:
|
||||
* - [note] : MIDI note number, or [EMPTY], or [OFF] (note-off).
|
||||
* - [velocity] : 0..127 (MIDI range), shown to the user as two hex digits (00..7F).
|
||||
* - [channel] : MIDI channel 1..16, shown as a decimal.
|
||||
*
|
||||
* We use plain mutable fields (not a `data class` copy) because the tracker grid
|
||||
* is edited cell-by-cell in tight gestures and we want to avoid allocating a new
|
||||
* object on every drag tick.
|
||||
*/
|
||||
class Cell(
|
||||
var note: Int = EMPTY,
|
||||
var velocity: Int = MAX_VELOCITY, // default to full velocity so new notes are audible
|
||||
var channel: Int = 1,
|
||||
) {
|
||||
val isEmpty: Boolean get() = note == EMPTY
|
||||
val isNoteOff: Boolean get() = note == OFF
|
||||
val isPlayable: Boolean get() = note in Pitch.LOWEST..Pitch.HIGHEST
|
||||
|
||||
fun clear() {
|
||||
note = EMPTY
|
||||
velocity = MAX_VELOCITY
|
||||
// channel is preserved: an empty cell never sounds, and keeping it means a
|
||||
// note later entered here still routes to the track's default bus.
|
||||
}
|
||||
|
||||
/** A detached copy (for the clipboard) — never aliases the source cell. */
|
||||
fun copyOf(): Cell = Cell(note, velocity, channel)
|
||||
|
||||
/** Overwrite this cell's fields from [other] (paste). */
|
||||
fun setFrom(other: Cell) {
|
||||
note = other.note
|
||||
velocity = other.velocity
|
||||
channel = other.channel
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EMPTY = -1
|
||||
const val OFF = -2
|
||||
/** MIDI velocity ceiling (0x7F). Values are clamped to 0..[MAX_VELOCITY]. */
|
||||
const val MAX_VELOCITY = 0x7F
|
||||
/** MIDI channel ceiling. Channels run 1..[MAX_CHANNEL]. */
|
||||
const val MAX_CHANNEL = 16
|
||||
}
|
||||
}
|
||||
|
||||
/** The four editable columns per track. Only NOTE is pitch; the rest are values. */
|
||||
enum class CellColumn { NOTE, VELOCITY, CHANNEL }
|
||||
|
||||
/**
|
||||
* A pattern (a "block" in arrangement terms): [TRACK_COUNT] parallel tracks, each
|
||||
* a vertical column of [length] cells. Length is one of the current time
|
||||
* signature's allowed values (see [TimeSignature.lengthOptions]).
|
||||
*
|
||||
* The grid is stored as `tracks[trackIndex][lineIndex]`.
|
||||
*/
|
||||
class Pattern(
|
||||
val id: Int,
|
||||
var name: String = "PTN",
|
||||
var length: Int = 16,
|
||||
) {
|
||||
/** tracks[t][line]. Always [TRACK_COUNT] tracks; each list has [length] cells.
|
||||
* New cells default to the empty channel 0 (routes to no bus until the user
|
||||
* assigns one); routing is by channel, not track — see AudioEngine.triggerCell. */
|
||||
val tracks: Array<MutableList<Cell>> =
|
||||
Array(TRACK_COUNT) { MutableList(length) { Cell(channel = 0) } }
|
||||
|
||||
fun cell(track: Int, line: Int): Cell = tracks[track][line]
|
||||
|
||||
/**
|
||||
* Resize the pattern to [newLength], preserving existing cells where possible.
|
||||
* Called when the length dropdown or the time signature changes.
|
||||
*/
|
||||
fun resize(newLength: Int) {
|
||||
if (newLength == length) return
|
||||
for (t in 0 until TRACK_COUNT) {
|
||||
val col = tracks[t]
|
||||
when {
|
||||
newLength > col.size -> repeat(newLength - col.size) { col.add(Cell(channel = 0)) }
|
||||
newLength < col.size -> while (col.size > newLength) col.removeAt(col.size - 1)
|
||||
}
|
||||
}
|
||||
length = newLength
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Fixed by the spec: the tracker upper half always has four tracks. */
|
||||
const val TRACK_COUNT = 4
|
||||
}
|
||||
}
|
||||
58
app/src/main/java/space/rcmd/android/sizzle/model/Project.kt
Normal file
58
app/src/main/java/space/rcmd/android/sizzle/model/Project.kt
Normal file
@@ -0,0 +1,58 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.model
|
||||
|
||||
/**
|
||||
* The whole song in memory. This single object is what gets saved to / loaded
|
||||
* from the `.sng` file format and what the audio engine reads while playing.
|
||||
* Keeping one root object makes serialization and undo/redo straightforward.
|
||||
*
|
||||
* Persistence is done by our own text serializer (see
|
||||
* [space.rcmd.android.sizzle.io.ProjectIo]), not kotlinx, so the
|
||||
* model classes carry no serialization annotations.
|
||||
*/
|
||||
class Project {
|
||||
var name: String = "untitled"
|
||||
|
||||
// ----- Global transport -----
|
||||
var tempoBpm: Float = 120f
|
||||
var timeSignature: TimeSignature = TimeSignature.FOUR_FOUR
|
||||
var scale: Scale = Scale.CHROMATIC
|
||||
/** Root note as a pitch-class 0..11 (0 = C). Used with [scale] for entry. */
|
||||
var rootNote: Int = 0
|
||||
|
||||
// ----- Content -----
|
||||
/**
|
||||
* One pattern/block per arrangement lane. Lane i is tied to block i (the
|
||||
* "sizzletracker CLI" model): enabling a cell on arrangement lane i schedules
|
||||
* block i to play when the playhead reaches that beat. Block id == lane index.
|
||||
*/
|
||||
val patterns: MutableList<Pattern> = MutableList(Arrangement.LANE_COUNT) {
|
||||
// Default to the shortest length for the signature: 16 ticks for 4/4.
|
||||
Pattern(id = it, name = "BLK${it + 1}", length = timeSignature.lengthOptions[0])
|
||||
}
|
||||
val arrangement: Arrangement = Arrangement()
|
||||
val mixer: Mixer = Mixer()
|
||||
/** 16 toolbox slots (tab 3). Index in the list == on-screen slot number. */
|
||||
val toolbox: MutableList<ToolboxSlot> = MutableList(TOOLBOX_SLOTS) { ToolboxSlot(it) }
|
||||
|
||||
/** Look up a pattern by id. Indexed loop (not `firstOrNull`) so it allocates no
|
||||
* Iterator — this is called once per output sample on the audio thread. */
|
||||
fun pattern(id: Int): Pattern? {
|
||||
for (i in patterns.indices) {
|
||||
val p = patterns[i]
|
||||
if (p.id == id) return p
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Which pattern the tracker tab is currently editing. */
|
||||
var activePatternId: Int = 0
|
||||
|
||||
fun activePattern(): Pattern = pattern(activePatternId) ?: patterns.first()
|
||||
|
||||
companion object {
|
||||
const val TOOLBOX_SLOTS = 16
|
||||
}
|
||||
}
|
||||
320
app/src/main/java/space/rcmd/android/sizzle/model/Toolbox.kt
Normal file
320
app/src/main/java/space/rcmd/android/sizzle/model/Toolbox.kt
Normal file
@@ -0,0 +1,320 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.model
|
||||
|
||||
/**
|
||||
* The third tab is a toolbox of 16 slots. Every slot can hold one instrument OR
|
||||
* one effect. Rather than a separate class per device (which would be a lot of
|
||||
* near-identical code for a newcomer to wade through), we describe each device
|
||||
* type with [ToolboxType] and store its settings as a simple
|
||||
* name -> value map. That map is exactly what we serialize to a human-readable
|
||||
* preset, and it is what the audio engine reads at render time.
|
||||
*
|
||||
* When you add a new instrument or effect you only need to:
|
||||
* 1. add an entry to [ToolboxType] with its default parameters, and
|
||||
* 2. teach the audio engine how to interpret those parameters.
|
||||
* The UI (a generic parameter editor) and preset save/load work automatically.
|
||||
*/
|
||||
|
||||
enum class ToolboxKind { INSTRUMENT, EFFECT }
|
||||
|
||||
/**
|
||||
* A single tunable parameter's description: its key, a friendly label, the value
|
||||
* range and a default. [choices] being non-empty marks an enumerated parameter
|
||||
* (rendered as a dropdown, e.g. a waveform selector) instead of a slider.
|
||||
*/
|
||||
data class ParamSpec(
|
||||
val key: String,
|
||||
val label: String,
|
||||
val default: Float = 0f,
|
||||
val min: Float = 0f,
|
||||
val max: Float = 1f,
|
||||
val choices: List<String> = emptyList(),
|
||||
/** When true the slider snaps to whole integers (e.g. bit depth, semitones). */
|
||||
val isInt: Boolean = false,
|
||||
) {
|
||||
val isEnum: Boolean get() = choices.isNotEmpty()
|
||||
}
|
||||
|
||||
enum class ToolboxType(
|
||||
val kind: ToolboxKind,
|
||||
val displayName: String,
|
||||
val params: List<ParamSpec>,
|
||||
) {
|
||||
// ---------- Instruments ----------
|
||||
NES_SYNTH(
|
||||
ToolboxKind.INSTRUMENT, "NES Synth",
|
||||
listOf(
|
||||
ParamSpec("wave", "Wave", choices = listOf("Pulse12", "Pulse25", "Pulse50", "Triangle", "Noise")),
|
||||
ParamSpec("attack", "Attack", 0.01f, 0f, 1f),
|
||||
ParamSpec("decay", "Decay", 0.15f, 0f, 1f),
|
||||
ParamSpec("sustain", "Sustain", 0.6f, 0f, 1f),
|
||||
ParamSpec("release", "Release", 0.1f, 0f, 1f),
|
||||
),
|
||||
),
|
||||
SAMPLER(
|
||||
ToolboxKind.INSTRUMENT, "Sampler",
|
||||
listOf(
|
||||
ParamSpec("volume", "Volume", 0.8f, 0f, 1f),
|
||||
// A 16-pad bank: each pad maps to a consecutive note from C2 (see
|
||||
// [SamplerPads]) and stores its own sample id + slice as string params
|
||||
// ("pad0", "pad0S", "pad0E", …). Edited by ui/toolbox/SamplerEditor.
|
||||
),
|
||||
),
|
||||
SOUNDFONT(
|
||||
ToolboxKind.INSTRUMENT, "SF2 / XI Loader",
|
||||
listOf(
|
||||
ParamSpec("program", "Program", 0f, 0f, 127f, isInt = true),
|
||||
ParamSpec("volume", "Volume", 0.8f, 0f, 1f),
|
||||
// Volume ADSR envelope (seconds, sustain 0..1).
|
||||
ParamSpec("attack", "Attack", 0.005f, 0f, 2f),
|
||||
ParamSpec("decay", "Decay", 0.10f, 0f, 2f),
|
||||
ParamSpec("sustain", "Sustain", 1f, 0f, 1f),
|
||||
ParamSpec("release", "Release", 0.20f, 0f, 3f),
|
||||
),
|
||||
),
|
||||
|
||||
// ---------- Effects ----------
|
||||
ARPEGGIATOR(
|
||||
ToolboxKind.EFFECT, "MIDI Arpeggiator",
|
||||
listOf(
|
||||
ParamSpec("mode", "Mode", choices = listOf("Up", "Down", "UpDown", "Random")),
|
||||
ParamSpec("octaves", "Octaves", 1f, 1f, 4f, isInt = true),
|
||||
ParamSpec("division", "Division", 3f, 0f, 6f, isInt = true), // index into TimeDivision
|
||||
// When On, the arp restarts from step 0 on every bar downbeat so its
|
||||
// octave pattern stays locked to the bar (otherwise it free-runs).
|
||||
ParamSpec("resetbar", "Reset/Bar", choices = listOf("Off", "On")),
|
||||
),
|
||||
),
|
||||
TRANSPOSER(
|
||||
ToolboxKind.EFFECT, "MIDI Transposer",
|
||||
listOf(
|
||||
ParamSpec("octaves", "Octaves", 0f, -4f, 4f, isInt = true),
|
||||
ParamSpec("semitones", "Semitones", 0f, -24f, 24f, isInt = true),
|
||||
),
|
||||
),
|
||||
LFO(
|
||||
ToolboxKind.EFFECT, "MIDI LFO",
|
||||
listOf(
|
||||
ParamSpec("wave", "Wave", choices = listOf("Sine", "Triangle", "Saw", "Square", "SampleHold")),
|
||||
ParamSpec("phase", "Phase", 0f, 0f, 1f),
|
||||
ParamSpec("depth", "Depth", 0.5f, 0f, 1f),
|
||||
ParamSpec("division", "Division", 3f, 0f, 6f, isInt = true),
|
||||
// "target" (which parameter to modulate) is stored as a string param.
|
||||
),
|
||||
),
|
||||
DELAY(
|
||||
ToolboxKind.EFFECT, "Tape Delay",
|
||||
// Four independent tape heads, each with its own tempo-synced division and an
|
||||
// on/off flag, plus the common feedback / dry-wet / tape-strength controls.
|
||||
// Feedback tops out below 1 (no runaway). Edited by ui/toolbox/DelayEditor.
|
||||
buildList {
|
||||
add(ParamSpec("feedback", "Feedback", 0.4f, 0f, DelayDivisions.MAX_FEEDBACK))
|
||||
add(ParamSpec("drywet", "Dry/Wet", 0.35f, 0f, 1f))
|
||||
add(ParamSpec("tape", "Tape Strength", 0.3f, 0f, 1f))
|
||||
val headDefaults = intArrayOf(
|
||||
DelayDivisions.indexOf("1/4"), DelayDivisions.indexOf("1/8"),
|
||||
DelayDivisions.indexOf("1/4."), DelayDivisions.indexOf("1/2"),
|
||||
)
|
||||
for (h in 0 until DelayDivisions.HEADS) {
|
||||
add(ParamSpec("head${h}On", "Head ${h + 1} On", if (h == 0) 1f else 0f, 0f, 1f))
|
||||
add(ParamSpec("head${h}Div", "Head ${h + 1} Div",
|
||||
headDefaults[h].toFloat(), 0f, (DelayDivisions.size - 1).toFloat()))
|
||||
}
|
||||
},
|
||||
),
|
||||
FILTER(
|
||||
ToolboxKind.EFFECT, "Filter (LP/HP/BP)",
|
||||
listOf(
|
||||
ParamSpec("type", "Type", choices = listOf("LPF", "HPF", "BPF")),
|
||||
ParamSpec("cutoff", "Cutoff", 0.7f, 0f, 1f),
|
||||
ParamSpec("resonance", "Resonance", 0.2f, 0f, 1f),
|
||||
// Analog-style saturation added in the filter path.
|
||||
ParamSpec("warmth", "Warmth", 0f, 0f, 1f),
|
||||
// Cutoff envelope: how far the ADSR opens the filter above the base
|
||||
// cutoff. 0 = envelope off (the filter behaves as a plain filter). The
|
||||
// envelope is (re)triggered by signal activity arriving on the channel.
|
||||
ParamSpec("envamt", "Env Amount", 0f, 0f, 1f),
|
||||
ParamSpec("attack", "Attack", 0.005f, 0f, 2f),
|
||||
ParamSpec("decay", "Decay", 0.10f, 0f, 2f),
|
||||
ParamSpec("sustain", "Sustain", 1f, 0f, 1f),
|
||||
ParamSpec("release", "Release", 0.20f, 0f, 3f),
|
||||
),
|
||||
),
|
||||
EQ10(
|
||||
ToolboxKind.EFFECT, "10-Band EQ",
|
||||
// 10 gain bands, each -1..+1 mapping to roughly -12..+12 dB.
|
||||
(0 until 10).map { ParamSpec("band$it", "Band ${it + 1}", 0f, -1f, 1f) },
|
||||
),
|
||||
BITCRUSHER(
|
||||
ToolboxKind.EFFECT, "Bitcrusher",
|
||||
listOf(
|
||||
ParamSpec("bits", "Bits", 8f, 1f, 16f, isInt = true),
|
||||
ParamSpec("downsample", "Downsample", 1f, 1f, 32f, isInt = true),
|
||||
ParamSpec("drive", "Drive", 0.2f, 0f, 1f),
|
||||
),
|
||||
),
|
||||
AMBIENCE(
|
||||
ToolboxKind.EFFECT, "Ambience Reverb",
|
||||
// A mono port of the Ambience 16-channel FDN reverb (OTODESK): 7 algorithms,
|
||||
// tempo-independent tunables and 21 factory spaces (seeded into the preset
|
||||
// library). Rendered by audio/AmbienceReverb.kt.
|
||||
listOf(
|
||||
ParamSpec("algo", "Algorithm", 0f, 0f, 6f, AmbiencePresets.ALGORITHMS),
|
||||
ParamSpec("predelay", "Pre-Delay", 10f, 0f, 500f),
|
||||
ParamSpec("roomsize", "Room Size", 1f, 0.3f, 2f),
|
||||
ParamSpec("decay", "Decay", 1.5f, 0.1f, 20f),
|
||||
ParamSpec("hfdamp", "HF Damp", 0f, 0f, 1f),
|
||||
ParamSpec("lfabsorb", "LF Absorb", 0f, 0f, 1f),
|
||||
ParamSpec("diffusion", "Diffusion", 0.7f, 0f, 1f),
|
||||
ParamSpec("modamt", "Mod Amount", 0.25f, 0f, 1f),
|
||||
ParamSpec("modrate", "Mod Rate", 0.5f, 0.05f, 2f),
|
||||
ParamSpec("erlevel", "ER Level", 0.6f, 0f, 1f),
|
||||
ParamSpec("saturation", "Saturation", 0f, 0f, 1f),
|
||||
ParamSpec("wet", "Wet (dB)", -4f, -60f, 0f),
|
||||
ParamSpec("dry", "Dry (dB)", 0f, -60f, 0f),
|
||||
ParamSpec("duckamt", "Ducking (dB)", 0f, 0f, 20f),
|
||||
ParamSpec("duckthresh", "Duck Thresh (dB)", -20f, -60f, 0f),
|
||||
ParamSpec("duckattack", "Duck Attack (ms)", 10f, 0.5f, 100f),
|
||||
ParamSpec("duckrelease", "Duck Release (ms)", 200f, 10f, 2000f),
|
||||
),
|
||||
);
|
||||
|
||||
val isInstrument: Boolean get() = kind == ToolboxKind.INSTRUMENT
|
||||
|
||||
/** Fresh default parameter map for a new instance of this type. */
|
||||
fun defaultValues(): MutableMap<String, String> =
|
||||
params.associate { spec ->
|
||||
spec.key to if (spec.isEnum) spec.choices[spec.default.toInt().coerceIn(0, spec.choices.lastIndex)]
|
||||
else spec.default.toString()
|
||||
}.toMutableMap()
|
||||
}
|
||||
|
||||
/**
|
||||
* The Sampler's 16-pad bank. Pads map to consecutive MIDI notes starting at C2
|
||||
* ([BASE_NOTE] = 36), so pad 0 = C2 … pad 15 = D#3. Each pad stores, in the slot's
|
||||
* value map, a sample id under [key] and its trim under [sliceStartKey]/[sliceEndKey].
|
||||
* Triggering a pad's note plays that sample at its natural pitch (one-shot).
|
||||
*/
|
||||
object SamplerPads {
|
||||
const val COUNT = 16
|
||||
const val BASE_NOTE = 36 // C2 (this app uses MIDI 60 == C4)
|
||||
const val DEFAULT_VOLUME = 0.8f
|
||||
|
||||
fun key(pad: Int): String = "pad$pad"
|
||||
fun sliceStartKey(pad: Int): String = "pad${pad}S"
|
||||
fun sliceEndKey(pad: Int): String = "pad${pad}E"
|
||||
fun volumeKey(pad: Int): String = "pad${pad}V"
|
||||
|
||||
/** The MIDI note a pad triggers, and the inverse. */
|
||||
fun noteFor(pad: Int): Int = BASE_NOTE + pad
|
||||
fun padFor(note: Int): Int = note - BASE_NOTE
|
||||
}
|
||||
|
||||
/**
|
||||
* The tempo-synced note values a Tape Delay head can be set to: straight, dotted
|
||||
* (·, ×1.5) and triplet (T, ×2/3) subdivisions from 1/1 down to 1/16, ordered
|
||||
* longest → shortest. Each head stores its choice as an index into [entries], which
|
||||
* serializes as a plain integer. [beatFraction] is measured in quarter-note beats
|
||||
* (1/4 == 1.0 beat), matching [TimeDivision].
|
||||
*/
|
||||
object DelayDivisions {
|
||||
const val HEADS = 4
|
||||
const val MAX_FEEDBACK = 0.9f // hard ceiling < 1 so feedback can never run away
|
||||
|
||||
/** (label, beat-fraction), longest first. */
|
||||
val entries: List<Pair<String, Double>> = listOf(
|
||||
"1/1" to 4.0,
|
||||
"1/2." to 3.0,
|
||||
"1/2" to 2.0,
|
||||
"1/4." to 1.5,
|
||||
"1/2T" to 4.0 / 3.0,
|
||||
"1/4" to 1.0,
|
||||
"1/8." to 0.75,
|
||||
"1/4T" to 2.0 / 3.0,
|
||||
"1/8" to 0.5,
|
||||
"1/16." to 0.375,
|
||||
"1/8T" to 1.0 / 3.0,
|
||||
"1/16" to 0.25,
|
||||
"1/16T" to 1.0 / 6.0,
|
||||
)
|
||||
|
||||
val size: Int get() = entries.size
|
||||
fun label(i: Int): String = entries[i.coerceIn(0, entries.lastIndex)].first
|
||||
fun beatFraction(i: Int): Double = entries[i.coerceIn(0, entries.lastIndex)].second
|
||||
|
||||
/** Plain loop (no lambda/iterator) — this is read on the audio thread. */
|
||||
fun indexOf(label: String): Int {
|
||||
for (i in entries.indices) if (entries[i].first == label) return i
|
||||
return 0
|
||||
}
|
||||
|
||||
/** Default division index (a quarter note). A cached `val`, not a getter, so
|
||||
* reading it on the audio thread (as a fallback arg) allocates nothing. */
|
||||
val DEFAULT: Int = indexOf("1/4")
|
||||
}
|
||||
|
||||
/** Tempo-synced note lengths used by arpeggiator / LFO "division" params. */
|
||||
enum class TimeDivision(val label: String, val beatFraction: Double) {
|
||||
WHOLE("1/1", 4.0), HALF("1/2", 2.0), QUARTER("1/4", 1.0),
|
||||
EIGHTH("1/8", 0.5), SIXTEENTH("1/16", 0.25),
|
||||
THIRTYSECOND("1/32", 0.125), DOTTED_EIGHTH("1/8.", 0.75);
|
||||
|
||||
companion object {
|
||||
fun fromIndex(i: Int): TimeDivision = entries[i.coerceIn(0, entries.lastIndex)]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One occupied (or empty) toolbox slot. [type] == null means the slot is empty.
|
||||
* [values] holds every parameter as a string so the same structure serializes
|
||||
* cleanly to text and round-trips without loss. String-only params such as a
|
||||
* sample file path live in the same map under keys the engine knows about
|
||||
* (e.g. "samplePath", "target").
|
||||
*
|
||||
* (Serialized via [space.rcmd.android.sizzle.io.PresetIo], not
|
||||
* kotlinx, so no @Serializable annotation is needed here.)
|
||||
*/
|
||||
class ToolboxSlot(
|
||||
val index: Int,
|
||||
var type: ToolboxType? = null,
|
||||
var name: String = "",
|
||||
val values: MutableMap<String, String> = mutableMapOf(),
|
||||
) {
|
||||
val isEmpty: Boolean get() = type == null
|
||||
|
||||
fun fill(newType: ToolboxType) {
|
||||
type = newType
|
||||
name = newType.displayName
|
||||
values.clear()
|
||||
values.putAll(newType.defaultValues())
|
||||
}
|
||||
|
||||
fun clearSlot() {
|
||||
type = null
|
||||
name = ""
|
||||
values.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Bumped on every parameter write. The audio engine reads it to skip re-parsing
|
||||
* a slot's params (and re-deriving effect state) every block — string parsing on
|
||||
* the audio thread allocates and, unchecked, drove GC-pause dropouts. Volatile so
|
||||
* a UI-thread edit is seen by the audio thread. */
|
||||
@Volatile
|
||||
var version: Int = 0
|
||||
private set
|
||||
|
||||
// Typed accessors used by the audio engine and UI.
|
||||
// NOTE: `float` runs on the audio thread; `toFloatOrNull()` boxes a Float, so parse
|
||||
// to a primitive via try/catch instead (the happy path doesn't box; stored values
|
||||
// are valid). Callers should still avoid calling it every block — see [version].
|
||||
fun float(key: String, fallback: Float = 0f): Float {
|
||||
val s = values[key] ?: return fallback
|
||||
return try { s.toFloat() } catch (e: NumberFormatException) { fallback }
|
||||
}
|
||||
fun string(key: String, fallback: String = ""): String = values[key] ?: fallback
|
||||
fun set(key: String, value: Float) { values[key] = value.toString(); version++ }
|
||||
fun set(key: String, value: String) { values[key] = value; version++ }
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.playback
|
||||
|
||||
import android.os.Looper
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.SimpleBasePlayer
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.google.common.util.concurrent.Futures
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import space.rcmd.android.sizzle.audio.AudioEngine
|
||||
|
||||
/**
|
||||
* Adapts our custom [AudioEngine] to the media3 [Player] interface via
|
||||
* [SimpleBasePlayer], so a [androidx.media3.session.MediaSession] can expose the
|
||||
* transport to the system: lock-screen controls, Bluetooth/headset media buttons,
|
||||
* and the media notification. We only implement play/pause/stop — the tracker
|
||||
* itself is the real transport; this is the "remote control" surface.
|
||||
*
|
||||
* Call [refresh] whenever the engine's transport changes so the reported state
|
||||
* (and therefore the notification) stays in sync with on-screen play/pause.
|
||||
*/
|
||||
@UnstableApi
|
||||
class EnginePlayer(private val engine: AudioEngine) : SimpleBasePlayer(Looper.getMainLooper()) {
|
||||
|
||||
private val mediaItem = MediaItem.Builder()
|
||||
.setMediaId("sizzletracker-song")
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setTitle("Sizzletracker")
|
||||
.setArtist("Song")
|
||||
.build(),
|
||||
)
|
||||
.build()
|
||||
|
||||
override fun getState(): State {
|
||||
val playing = engine.transport.value.isPlaying
|
||||
return State.Builder()
|
||||
.setAvailableCommands(
|
||||
Player.Commands.Builder()
|
||||
.addAll(
|
||||
Player.COMMAND_PLAY_PAUSE,
|
||||
Player.COMMAND_STOP,
|
||||
Player.COMMAND_PREPARE,
|
||||
Player.COMMAND_GET_CURRENT_MEDIA_ITEM,
|
||||
Player.COMMAND_GET_METADATA,
|
||||
Player.COMMAND_GET_TIMELINE,
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.setPlaybackState(Player.STATE_READY)
|
||||
.setPlayWhenReady(playing, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST)
|
||||
.setPlaylist(
|
||||
listOf(
|
||||
MediaItemData.Builder("sizzletracker-song")
|
||||
.setMediaItem(mediaItem)
|
||||
.build(),
|
||||
),
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
/** Publish the latest engine state to any connected media controllers. */
|
||||
fun refresh() = invalidateState()
|
||||
|
||||
override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> {
|
||||
if (playWhenReady) engine.play() else engine.pause()
|
||||
invalidateState()
|
||||
return Futures.immediateVoidFuture()
|
||||
}
|
||||
|
||||
override fun handlePrepare(): ListenableFuture<*> = Futures.immediateVoidFuture()
|
||||
|
||||
override fun handleStop(): ListenableFuture<*> {
|
||||
engine.stop()
|
||||
invalidateState()
|
||||
return Futures.immediateVoidFuture()
|
||||
}
|
||||
|
||||
override fun handleRelease(): ListenableFuture<*> = Futures.immediateVoidFuture()
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.playback
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
import space.rcmd.android.sizzle.SizzleApp
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* A media3 [MediaSessionService] that owns the audio engine's lifecycle and hosts
|
||||
* a [MediaSession] wrapping it (via [EnginePlayer]). media3 automatically provides
|
||||
* the media notification, lock-screen controls, and Bluetooth/headset media-button
|
||||
* handling from the session — so the same transport the on-screen buttons drive is
|
||||
* also controllable from outside the app, and playback continues in the background.
|
||||
*/
|
||||
@UnstableApi
|
||||
class PlaybackService : MediaSessionService() {
|
||||
|
||||
private val engine get() = (application as SizzleApp).audioEngine
|
||||
private var session: MediaSession? = null
|
||||
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
engine.start()
|
||||
val player = EnginePlayer(engine)
|
||||
session = MediaSession.Builder(this, player).build()
|
||||
// Keep the session's reported state in sync with the engine (the on-screen
|
||||
// Play/Pause talks to the engine directly, so we mirror it here).
|
||||
scope.launch { engine.transport.collect { player.refresh() } }
|
||||
}
|
||||
|
||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = session
|
||||
|
||||
override fun onDestroy() {
|
||||
scope.cancel()
|
||||
session?.run { player.release(); release() }
|
||||
session = null
|
||||
engine.release()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Start the service from a foreground context (safe: plain startService,
|
||||
* MediaSessionService promotes itself to foreground when playback begins). */
|
||||
fun start(context: Context) {
|
||||
context.startService(Intent(context, PlaybackService::class.java))
|
||||
}
|
||||
}
|
||||
}
|
||||
143
app/src/main/java/space/rcmd/android/sizzle/ui/App.kt
Normal file
143
app/src/main/java/space/rcmd/android/sizzle/ui/App.kt
Normal file
@@ -0,0 +1,143 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.GridView
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material.icons.filled.Widgets
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.NavigationBarItemDefaults
|
||||
import androidx.compose.material3.NavigationRail
|
||||
import androidx.compose.material3.NavigationRailItem
|
||||
import androidx.compose.material3.NavigationRailItemDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import space.rcmd.android.sizzle.ui.mixer.MixerScreen
|
||||
import space.rcmd.android.sizzle.ui.settings.SettingsScreen
|
||||
import space.rcmd.android.sizzle.ui.theme.LocalRetro
|
||||
import space.rcmd.android.sizzle.ui.toolbox.ToolboxScreen
|
||||
import space.rcmd.android.sizzle.ui.tracker.TrackerScreen
|
||||
|
||||
/**
|
||||
* The top-level layout: the active tab's screen fills the space above a standard
|
||||
* Material 3 [NavigationBar]. The pattern grid / mixer still use the bespoke retro
|
||||
* look, but chrome (this bar and the Settings tab) uses stock Material components
|
||||
* for familiar, accessible interaction.
|
||||
*/
|
||||
private data class TabSpec(val title: String, val icon: ImageVector)
|
||||
|
||||
private val TABS = listOf(
|
||||
TabSpec("Tracker", Icons.Filled.GridView),
|
||||
TabSpec("Mix", Icons.Filled.Tune),
|
||||
TabSpec("Toolbox", Icons.Filled.Widgets),
|
||||
TabSpec("Setup", Icons.Filled.Settings),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun SizzleApp(vm: AppViewModel) {
|
||||
val c = LocalRetro.current
|
||||
// System back / edge-swipe steps to the previous tab. Deeper handlers (a
|
||||
// Toolbox editor, a Settings subsection) are composed inside the active screen
|
||||
// and intercept first. On the first tab this is disabled, so back falls through
|
||||
// to the system default (leaving the app).
|
||||
BackHandler(enabled = vm.selectedTab > 0) { vm.selectTab(vm.selectedTab - 1) }
|
||||
if (isLandscape()) {
|
||||
// Landscape: a side nav rail so the (short) vertical space goes entirely to
|
||||
// the active screen instead of a bottom bar.
|
||||
Row(Modifier.fillMaxSize().background(c.background)) {
|
||||
SideNav(vm)
|
||||
Box(Modifier.weight(1f).fillMaxHeight()) { ActiveScreen(vm) }
|
||||
}
|
||||
} else {
|
||||
Column(Modifier.fillMaxSize().background(c.background)) {
|
||||
Box(Modifier.weight(1f).fillMaxWidth()) { ActiveScreen(vm) }
|
||||
BottomNav(vm)
|
||||
}
|
||||
}
|
||||
|
||||
// App-wide notice if the low-latency (Oboe) backend couldn't start and playback
|
||||
// fell back to the standard path.
|
||||
vm.nativeAudioError?.let { msg ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { vm.dismissNativeAudioError() },
|
||||
title = { Text("Audio engine") },
|
||||
text = { Text(msg) },
|
||||
confirmButton = { TextButton(onClick = { vm.dismissNativeAudioError() }) { Text("OK") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The active tab's screen. */
|
||||
@Composable
|
||||
private fun ActiveScreen(vm: AppViewModel) {
|
||||
when (vm.selectedTab) {
|
||||
0 -> TrackerScreen(vm)
|
||||
1 -> MixerScreen(vm)
|
||||
2 -> ToolboxScreen(vm)
|
||||
3 -> SettingsScreen(vm)
|
||||
}
|
||||
}
|
||||
|
||||
/** Bottom navigation bar (portrait). */
|
||||
@Composable
|
||||
private fun BottomNav(vm: AppViewModel) {
|
||||
val c = LocalRetro.current
|
||||
NavigationBar(containerColor = c.surface) {
|
||||
TABS.forEachIndexed { index, tab ->
|
||||
NavigationBarItem(
|
||||
selected = vm.selectedTab == index,
|
||||
onClick = { vm.selectTab(index) },
|
||||
icon = { Icon(tab.icon, contentDescription = tab.title) },
|
||||
label = { Text(tab.title) },
|
||||
colors = NavigationBarItemDefaults.colors(
|
||||
selectedIconColor = c.background,
|
||||
selectedTextColor = c.accent,
|
||||
indicatorColor = c.accent,
|
||||
unselectedIconColor = c.textDim,
|
||||
unselectedTextColor = c.textDim,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Side navigation rail (landscape) — same tabs, laid out vertically down the left
|
||||
* edge so the screen keeps its full height. */
|
||||
@Composable
|
||||
private fun SideNav(vm: AppViewModel) {
|
||||
val c = LocalRetro.current
|
||||
NavigationRail(containerColor = c.surface) {
|
||||
TABS.forEachIndexed { index, tab ->
|
||||
NavigationRailItem(
|
||||
selected = vm.selectedTab == index,
|
||||
onClick = { vm.selectTab(index) },
|
||||
icon = { Icon(tab.icon, contentDescription = tab.title) },
|
||||
label = { Text(tab.title) },
|
||||
colors = NavigationRailItemDefaults.colors(
|
||||
selectedIconColor = c.background,
|
||||
selectedTextColor = c.accent,
|
||||
indicatorColor = c.accent,
|
||||
unselectedIconColor = c.textDim,
|
||||
unselectedTextColor = c.textDim,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
928
app/src/main/java/space/rcmd/android/sizzle/ui/AppViewModel.kt
Normal file
928
app/src/main/java/space/rcmd/android/sizzle/ui/AppViewModel.kt
Normal file
@@ -0,0 +1,928 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.ui
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import space.rcmd.android.sizzle.audio.AudioEngine
|
||||
import space.rcmd.android.sizzle.input.InputAction
|
||||
import space.rcmd.android.sizzle.input.InputRouter
|
||||
import space.rcmd.android.sizzle.model.Cell
|
||||
import space.rcmd.android.sizzle.model.CellColumn
|
||||
import space.rcmd.android.sizzle.model.Pattern
|
||||
import space.rcmd.android.sizzle.model.Pitch
|
||||
import space.rcmd.android.sizzle.model.Project
|
||||
import space.rcmd.android.sizzle.model.TimeSignature
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* The single screen-facing state holder. It sits between the UI (Compose) and
|
||||
* the model/engine, and it is the ONE place that reacts to [InputAction]s. Touch
|
||||
* gestures call its methods directly; keyboard/gamepad/MIDI reach it through the
|
||||
* [InputRouter] flow collected in [init]. Either way the same code runs, which is
|
||||
* how all four input methods stay equally capable.
|
||||
*
|
||||
* Recomposition trick: the model classes are plain (non-observable) for audio
|
||||
* 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.
|
||||
*/
|
||||
class AppViewModel(
|
||||
val project: Project,
|
||||
private val engine: AudioEngine,
|
||||
private val router: InputRouter,
|
||||
private val gamepadInput: space.rcmd.android.sizzle.input.GamepadInput,
|
||||
private val midiInput: space.rcmd.android.sizzle.input.MidiInput,
|
||||
private val keyboardInput: space.rcmd.android.sizzle.input.KeyboardInput,
|
||||
private val bindingStore: space.rcmd.android.sizzle.input.BindingStore,
|
||||
private val presetLibrary: space.rcmd.android.sizzle.io.PresetLibrary,
|
||||
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 appContext: android.content.Context,
|
||||
) : ViewModel() {
|
||||
|
||||
// ----- Observable UI state -----
|
||||
var selectedTab by mutableIntStateOf(0); private set
|
||||
var revision by mutableIntStateOf(0); private set // bump to force grid redraw
|
||||
/** Active colour theme; read by [MainActivity] to drive SizzleTheme. Set via
|
||||
* [selectPalette] so the choice is persisted. */
|
||||
var palette by mutableStateOf(space.rcmd.android.sizzle.ui.theme.RetroPalette.AMBER)
|
||||
private set
|
||||
|
||||
// Tracker cursor position.
|
||||
var cursorTrack by mutableIntStateOf(0); private set
|
||||
var cursorLine by mutableIntStateOf(0); private set
|
||||
var cursorColumn by mutableStateOf(CellColumn.NOTE); private set
|
||||
|
||||
// ----- Selection & clipboard (tracker edit ops) -----
|
||||
/** When on, the cursor drags out a rectangular selection from [selAnchorTrack]/
|
||||
* [selAnchorLine] to the live cursor; edit ops act on that rectangle. */
|
||||
var selectMode by mutableStateOf(false); private set
|
||||
var selAnchorTrack by mutableIntStateOf(-1); private set
|
||||
var selAnchorLine by mutableIntStateOf(-1); private set
|
||||
/** Copied cells, indexed [trackOffset][lineOffset]; null until first copy/cut. */
|
||||
private var clipboard: Array<Array<Cell>>? = null
|
||||
val hasSelection: Boolean get() = selectMode && selAnchorTrack >= 0
|
||||
val canPaste: Boolean get() = clipboard != null
|
||||
|
||||
// ----- Entry memory -----
|
||||
/** The last note & channel actually entered (by any input). Keyboard/gamepad
|
||||
* edits on an empty tick resume from these so entry continues where you left
|
||||
* off instead of jumping to a fixed default. */
|
||||
var lastNote by mutableIntStateOf(Pitch.MIDDLE_C); private set
|
||||
var lastChannel by mutableIntStateOf(0); private set
|
||||
|
||||
// ----- Shared keyboard / punch-in state -----
|
||||
// These live on the ViewModel (not per-screen) so the tracker punch-in keyboard
|
||||
// and the toolbox audition keyboard share them and they survive tab switches.
|
||||
/** Rows the cursor jumps after a punch-in and per up/down nav step (0/1/2/4/8;
|
||||
* 0 keeps the cursor put). */
|
||||
var skipStep by mutableIntStateOf(1); private set
|
||||
/** Whether the tracker's lower half shows the punch-in keyboard (vs the arranger). */
|
||||
var punchInMode by mutableStateOf(false); private set
|
||||
/** Target MIDI channel for keyboard notes; 0 == the empty channel. */
|
||||
var kbdChannel by mutableIntStateOf(0); private set
|
||||
/** Base octave (the lower of the two shown octaves). */
|
||||
var kbdOctave by mutableIntStateOf(3); private set
|
||||
/** Velocity for keyboard notes (0..127). */
|
||||
var kbdVelocity by mutableIntStateOf(Cell.MAX_VELOCITY); private set
|
||||
|
||||
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() }
|
||||
fun updateKbdOctave(o: Int) { kbdOctave = o.coerceIn(0, 8); persistKeyboard(); touched() }
|
||||
fun updateKbdVelocity(v: Int) { kbdVelocity = v.coerceIn(0, Cell.MAX_VELOCITY); touched() }
|
||||
|
||||
/** Persist the keyboard MIDI channel + base octave so they survive a restart. */
|
||||
private fun persistKeyboard() {
|
||||
viewModelScope.launch { settingsStore.saveKeyboard(kbdChannel, kbdOctave) }
|
||||
}
|
||||
|
||||
/** Live transport snapshot from the audio thread (drives the playhead). */
|
||||
val transport get() = engine.transport
|
||||
|
||||
// ----- Binding-learn state (Settings tab) -----
|
||||
/** The action id currently waiting to be bound, or null. */
|
||||
var learnTarget by mutableStateOf<String?>(null); private set
|
||||
private var learnSource: space.rcmd.android.sizzle.input.InputSource? = null
|
||||
|
||||
init {
|
||||
// Collect neutral input actions from every non-touch device.
|
||||
viewModelScope.launch {
|
||||
router.actions.collect(::onAction)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- tab control
|
||||
fun selectTab(index: Int) { selectedTab = index.coerceIn(0, 3) }
|
||||
|
||||
// ----------------------------------------------------------- cursor & edit
|
||||
fun focusCell(track: Int, line: Int, column: CellColumn) {
|
||||
val p = project.activePattern()
|
||||
cursorTrack = track.coerceIn(0, Pattern.TRACK_COUNT - 1)
|
||||
cursorLine = line.coerceIn(0, p.length - 1)
|
||||
cursorColumn = column
|
||||
}
|
||||
|
||||
private fun focusedCell(): Cell =
|
||||
project.activePattern().cell(cursorTrack, cursorLine)
|
||||
|
||||
/**
|
||||
* Move the cursor vertically by [dLine] rows, cycling past the top/bottom of the
|
||||
* pattern (wrap-around) rather than clamping. Used by the external controllers'
|
||||
* up/down so repeated presses keep scrolling — combined with the skip step this
|
||||
* jumps by N rows and wraps at the pattern ends.
|
||||
*/
|
||||
fun moveCursorVerticalWrap(dLine: Int) {
|
||||
val len = project.activePattern().length
|
||||
if (len <= 0) return
|
||||
cursorLine = Math.floorMod(cursorLine + dLine, len)
|
||||
}
|
||||
|
||||
fun nextColumn() { cursorColumn = nextOf(cursorColumn, +1) }
|
||||
fun prevColumn() { cursorColumn = nextOf(cursorColumn, -1) }
|
||||
|
||||
/**
|
||||
* Move the cursor by one column across the whole row — the three columns
|
||||
* (note/velocity/channel) of every track laid end to end — so stepping right
|
||||
* off a track's last column lands on the next track's first column. Used by the
|
||||
* external controllers' left/right so they traverse columns, not just tracks.
|
||||
*/
|
||||
fun moveColumnFlat(dir: Int) {
|
||||
val cols = CellColumn.entries.size
|
||||
val flat = (cursorTrack * cols + cursorColumn.ordinal + dir)
|
||||
.coerceIn(0, Pattern.TRACK_COUNT * cols - 1)
|
||||
cursorTrack = flat / cols
|
||||
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) {
|
||||
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
|
||||
moveCursorVerticalWrap(skipStep)
|
||||
touched()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ held notes
|
||||
/**
|
||||
* Notes currently held down, from ANY source (on-screen keyboards, external
|
||||
* keyboard/gamepad, MIDI in). Keyed by MIDI note → active-press count so the
|
||||
* same note held by two sources at once (e.g. touch + MIDI) doesn't clear the
|
||||
* highlight until both release. Backed by a snapshot map, so every on-screen
|
||||
* keyboard widget that reads [isNoteHeld] lights its keys up while held.
|
||||
*/
|
||||
private val heldNotes = mutableStateMapOf<Int, Int>()
|
||||
|
||||
/** True while [midi] is held down by at least one input source. */
|
||||
fun isNoteHeld(midi: Int): Boolean = (heldNotes[midi] ?: 0) > 0
|
||||
|
||||
private fun markNoteOn(midi: Int) { heldNotes[midi] = (heldNotes[midi] ?: 0) + 1 }
|
||||
private fun markNoteOff(midi: Int) {
|
||||
val n = (heldNotes[midi] ?: 0) - 1
|
||||
if (n <= 0) heldNotes.remove(midi) else heldNotes[midi] = n
|
||||
}
|
||||
|
||||
/** Audition a note on the keyboard's channel/velocity (mix-bus playback). */
|
||||
fun kbdNoteOn(midi: Int) { markNoteOn(midi); engine.busNoteOn(kbdChannel, midi, kbdVelocity) }
|
||||
fun kbdNoteOff(midi: Int) { markNoteOff(midi); engine.busNoteOff(kbdChannel, midi) }
|
||||
|
||||
/**
|
||||
* The core value-editing operation, shared by drag gestures and +/- input.
|
||||
* NOTE column cycles within the active scale; VELOCITY/CHANNEL step numerically.
|
||||
*/
|
||||
fun editFocused(direction: Int) {
|
||||
val cell = focusedCell()
|
||||
when (cursorColumn) {
|
||||
CellColumn.NOTE -> {
|
||||
// Seed an empty tick from the last note entered so filling resumes
|
||||
// where you left off rather than at a fixed default.
|
||||
val seed = if (cell.isPlayable) cell.note else lastNote
|
||||
cell.note = project.scale.step(seed, project.rootNote, direction)
|
||||
lastNote = cell.note
|
||||
}
|
||||
CellColumn.VELOCITY -> cell.velocity = (cell.velocity + direction).coerceIn(0, Cell.MAX_VELOCITY)
|
||||
CellColumn.CHANNEL -> {
|
||||
// Likewise seed an empty tick's channel from the last one entered.
|
||||
val seed = if (cell.isEmpty) lastChannel else cell.channel
|
||||
cell.channel = (seed + direction).coerceIn(1, Cell.MAX_CHANNEL)
|
||||
lastChannel = cell.channel
|
||||
}
|
||||
}
|
||||
touched()
|
||||
}
|
||||
|
||||
fun clearFocused() { focusedCell().clear(); touched() }
|
||||
|
||||
/** Insert a note-off ("===") at the cursor and advance by the skip step, like a
|
||||
* punch-in. The note-off is stamped with the last-remembered channel so it
|
||||
* releases the same bus the previous note was entered on. */
|
||||
fun noteOffFocused() {
|
||||
val cell = focusedCell()
|
||||
cell.note = Cell.OFF
|
||||
cell.channel = lastChannel
|
||||
moveCursorVerticalWrap(skipStep)
|
||||
touched()
|
||||
}
|
||||
|
||||
/** DEL button: clear the selection (or focused cell) and advance by the skip
|
||||
* step. Kept separate from [deleteSelection] so a CUT doesn't move the cursor. */
|
||||
fun deleteAndAdvance() {
|
||||
deleteSelection()
|
||||
moveCursorVerticalWrap(skipStep)
|
||||
touched()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------- selection & clipboard
|
||||
/** The selected track range (inclusive). Anchor..cursor, or just the cursor
|
||||
* cell when there is no active selection. */
|
||||
val selTrackRange: IntRange
|
||||
get() = if (hasSelection) minOf(selAnchorTrack, cursorTrack)..maxOf(selAnchorTrack, cursorTrack)
|
||||
else cursorTrack..cursorTrack
|
||||
val selLineRange: IntRange
|
||||
get() = if (hasSelection) minOf(selAnchorLine, cursorLine)..maxOf(selAnchorLine, cursorLine)
|
||||
else cursorLine..cursorLine
|
||||
|
||||
/** Toggle select mode. Turning it on anchors the selection at the cursor;
|
||||
* turning it off clears the selection (the clipboard is untouched). */
|
||||
fun toggleSelectMode() {
|
||||
selectMode = !selectMode
|
||||
if (selectMode) { selAnchorTrack = cursorTrack; selAnchorLine = cursorLine }
|
||||
else { selAnchorTrack = -1; selAnchorLine = -1 }
|
||||
touched()
|
||||
}
|
||||
|
||||
/** Copy the selection (or, with none, the focused cell) into the clipboard. */
|
||||
fun copySelection() {
|
||||
val p = project.activePattern()
|
||||
val tr = selTrackRange; val lr = selLineRange
|
||||
clipboard = Array(tr.count()) { ti ->
|
||||
Array(lr.count()) { li -> p.cell(tr.first + ti, lr.first + li).copyOf() }
|
||||
}
|
||||
touched() // so the PASTE button (canPaste) refreshes now that a clipboard exists
|
||||
}
|
||||
|
||||
/** Clear the selected cells (or the focused cell when nothing is selected). */
|
||||
fun deleteSelection() {
|
||||
val p = project.activePattern()
|
||||
for (t in selTrackRange) for (l in selLineRange) p.cell(t, l).clear()
|
||||
touched()
|
||||
}
|
||||
|
||||
/** Copy then clear — a standard cut. */
|
||||
fun cutSelection() { copySelection(); deleteSelection() }
|
||||
|
||||
/** Paste the clipboard with its top-left at the cursor, clipped to the grid. */
|
||||
fun pasteClipboard() {
|
||||
val cb = clipboard ?: return
|
||||
val p = project.activePattern()
|
||||
for (ti in cb.indices) for (li in cb[ti].indices) {
|
||||
val t = cursorTrack + ti
|
||||
val l = cursorLine + li
|
||||
if (t < Pattern.TRACK_COUNT && l < p.length) p.cell(t, l).setFrom(cb[ti][li])
|
||||
}
|
||||
touched()
|
||||
}
|
||||
|
||||
/** Which block (0..7) the tracker is editing. Lane i is tied to block i. */
|
||||
val activeBlock: Int get() = project.activePatternId
|
||||
fun setActiveBlock(i: Int) {
|
||||
project.activePatternId = i.coerceIn(0, project.patterns.size - 1)
|
||||
cursorLine = cursorLine.coerceIn(0, project.activePattern().length - 1)
|
||||
touched()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- transport control
|
||||
fun playPause() {
|
||||
val t = transport.value
|
||||
if (t.isPlaying) engine.pause() else engine.play()
|
||||
}
|
||||
|
||||
fun stop() = engine.stop()
|
||||
|
||||
/** MIDI panic: silence all sounding/stuck notes immediately. */
|
||||
fun midiPanic() { heldNotes.clear(); engine.panic() }
|
||||
|
||||
// ------------------------------------------------ master-bus recording
|
||||
/** Whether recording is armed (starts capturing when playback next starts). */
|
||||
var recordArmed by mutableStateOf(false); private set
|
||||
/** FX-tail length captured after the sequencer stops, in bars (0..4). */
|
||||
var recordTailBars by mutableIntStateOf(2); private set
|
||||
/** Chosen output folder as a persisted SAF tree URI string, or null. */
|
||||
var recordDirUri by mutableStateOf<String?>(null); private set
|
||||
/** Human-readable name of the chosen output folder (for the toolbar). */
|
||||
var recordDirName by mutableStateOf<String?>(null); private set
|
||||
/** True while a take is actively capturing (arm has fired on play). */
|
||||
var masterRecording by mutableStateOf(false); private set
|
||||
/** Filename of the most recently written take (a small "saved …" status). */
|
||||
var lastRecordingName by mutableStateOf<String?>(null); private set
|
||||
|
||||
/** Toggle record-arm. Requires a folder first — see [MixerScreen] which opens the
|
||||
* picker when none is set, so this is only reached once a destination exists. */
|
||||
fun toggleRecordArm() {
|
||||
recordArmed = !recordArmed
|
||||
engine.setRecordingArm(recordArmed, recordTailBars)
|
||||
}
|
||||
|
||||
fun updateRecordTailBars(n: Int) {
|
||||
recordTailBars = n.coerceIn(0, 4)
|
||||
engine.setRecordTailBars(recordTailBars)
|
||||
persistRecordingSettings()
|
||||
}
|
||||
|
||||
/** Adopt a folder picked via the Storage Access Framework as the WAV destination,
|
||||
* taking a persistable permission so it survives an app restart. */
|
||||
fun setRecordDir(uri: android.net.Uri) {
|
||||
runCatching {
|
||||
appContext.contentResolver.takePersistableUriPermission(
|
||||
uri,
|
||||
android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||
android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
|
||||
)
|
||||
}
|
||||
recordDirUri = uri.toString()
|
||||
recordDirName = displayNameForTree(uri)
|
||||
persistRecordingSettings()
|
||||
}
|
||||
|
||||
private fun persistRecordingSettings() {
|
||||
viewModelScope.launch { settingsStore.saveRecording(recordDirUri, recordTailBars) }
|
||||
}
|
||||
|
||||
/** Copy a finalized temp WAV into the chosen SAF folder with a date-time name.
|
||||
* Runs on the recorder's writer thread; returns the written file name or null. */
|
||||
private fun saveRecordingToDir(temp: java.io.File, startMillis: Long): String? {
|
||||
val dirUriStr = recordDirUri
|
||||
if (dirUriStr == null) { temp.delete(); return null }
|
||||
return runCatching {
|
||||
val treeUri = android.net.Uri.parse(dirUriStr)
|
||||
val dirDocUri = android.provider.DocumentsContract.buildDocumentUriUsingTree(
|
||||
treeUri, android.provider.DocumentsContract.getTreeDocumentId(treeUri),
|
||||
)
|
||||
val name = "sizzle-" + java.text.SimpleDateFormat("yyyyMMdd-HHmmss", java.util.Locale.US)
|
||||
.format(java.util.Date(startMillis)) + ".wav"
|
||||
val fileUri = android.provider.DocumentsContract.createDocument(
|
||||
appContext.contentResolver, dirDocUri, "audio/wav", name,
|
||||
) ?: return@runCatching null
|
||||
appContext.contentResolver.openOutputStream(fileUri)?.use { out ->
|
||||
temp.inputStream().use { it.copyTo(out) }
|
||||
}
|
||||
name
|
||||
}.getOrNull().also { temp.delete() }
|
||||
}
|
||||
|
||||
/** Best-effort readable folder name from a SAF tree URI's document id. */
|
||||
private fun displayNameForTree(uri: android.net.Uri): String {
|
||||
val id = runCatching { android.provider.DocumentsContract.getTreeDocumentId(uri) }
|
||||
.getOrNull() ?: return "folder"
|
||||
return id.substringAfterLast('/').substringAfterLast(':').ifBlank { id }
|
||||
}
|
||||
|
||||
// --------------------------------------------- project-level setting edits
|
||||
fun setTempo(bpm: Float) { project.tempoBpm = bpm.coerceIn(20f, 300f); touched() }
|
||||
|
||||
fun setTimeSignature(sig: TimeSignature) {
|
||||
project.timeSignature = sig
|
||||
// Snap pattern length to the shortest option for the new signature.
|
||||
val p = project.activePattern()
|
||||
if (p.length !in sig.lengthOptions) p.resize(sig.lengthOptions[0])
|
||||
cursorLine = cursorLine.coerceIn(0, p.length - 1)
|
||||
touched()
|
||||
}
|
||||
|
||||
fun setPatternLength(len: Int) {
|
||||
project.activePattern().resize(len)
|
||||
cursorLine = cursorLine.coerceIn(0, len - 1)
|
||||
touched()
|
||||
}
|
||||
|
||||
// ----------------------------------------------------- input action router
|
||||
/** The single dispatch point for keyboard / gamepad / MIDI actions. */
|
||||
private fun onAction(action: InputAction) {
|
||||
when (action) {
|
||||
// External up/down jump by the skip step; left/right traverse columns.
|
||||
InputAction.NavUp -> moveCursorVerticalWrap(-skipStep)
|
||||
InputAction.NavDown -> moveCursorVerticalWrap(+skipStep)
|
||||
InputAction.NavLeft -> moveColumnFlat(-1)
|
||||
InputAction.NavRight -> moveColumnFlat(+1)
|
||||
InputAction.NextColumn -> nextColumn()
|
||||
InputAction.PrevColumn -> prevColumn()
|
||||
is InputAction.Increment -> editFocused(action.amount)
|
||||
is InputAction.Decrement -> editFocused(-action.amount)
|
||||
InputAction.ClearCell -> clearFocused()
|
||||
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()
|
||||
}
|
||||
}
|
||||
is InputAction.NoteOff -> { markNoteOff(action.pitch); engine.liveNoteOff(action.pitch) }
|
||||
InputAction.PlayPause -> playPause()
|
||||
InputAction.Stop -> stop()
|
||||
InputAction.ToggleLoop -> { project.arrangement.loopEnabled = !project.arrangement.loopEnabled; touched() }
|
||||
is InputAction.SelectTab -> selectTab(action.index)
|
||||
InputAction.NextTab -> selectTab(selectedTab + 1)
|
||||
InputAction.PrevTab -> selectTab(selectedTab - 1)
|
||||
is InputAction.RawControl -> onRawControl(action)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- binding learn
|
||||
/** Arm MIDI-learn / gamepad-rebind: the next matching control is bound to
|
||||
* [actionId]. [source] restricts which device kind will be captured. */
|
||||
fun beginLearn(actionId: String, source: space.rcmd.android.sizzle.input.InputSource) {
|
||||
learnTarget = actionId
|
||||
learnSource = source
|
||||
router.learnSource = source
|
||||
router.learnMode = true
|
||||
}
|
||||
|
||||
fun cancelLearn() {
|
||||
learnTarget = null
|
||||
learnSource = null
|
||||
router.learnSource = null
|
||||
router.learnMode = false
|
||||
}
|
||||
|
||||
/** A control arrived during learn. If it matches the armed source, store it as
|
||||
* the binding for [learnTarget]. Each action keeps at most one binding, so any
|
||||
* previous one for this action (on the same device) is replaced. */
|
||||
private fun onRawControl(action: InputAction.RawControl) {
|
||||
val target = learnTarget ?: return
|
||||
if (action.source != learnSource) return
|
||||
when (action.source) {
|
||||
space.rcmd.android.sizzle.input.InputSource.MIDI ->
|
||||
midiInput.ccBindings[action.code] = target
|
||||
space.rcmd.android.sizzle.input.InputSource.GAMEPAD -> {
|
||||
gamepadInput.bindings.entries.removeAll { it.value == target }
|
||||
gamepadInput.bindings[
|
||||
space.rcmd.android.sizzle.input.GamepadChord(action.modifier, action.code),
|
||||
] = target
|
||||
}
|
||||
space.rcmd.android.sizzle.input.InputSource.KEYBOARD -> {
|
||||
keyboardInput.bindings.entries.removeAll { it.value == target }
|
||||
keyboardInput.bindings[action.code] = target
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
cancelLearn()
|
||||
touched()
|
||||
persistBindings()
|
||||
}
|
||||
|
||||
/** Current MIDI CC number bound to [actionId], or null. */
|
||||
fun midiBindingFor(actionId: String): Int? =
|
||||
midiInput.ccBindings.entries.firstOrNull { it.value == actionId }?.key
|
||||
|
||||
/** Current gamepad chord (modifier + button, or plain button) bound to
|
||||
* [actionId], or null. */
|
||||
fun gamepadBindingFor(actionId: String): space.rcmd.android.sizzle.input.GamepadChord? =
|
||||
gamepadInput.bindings.entries.firstOrNull { it.value == actionId }?.key
|
||||
|
||||
/** Current keyboard key code bound to [actionId], or null. */
|
||||
fun keyboardBindingFor(actionId: String): Int? =
|
||||
keyboardInput.bindings.entries.firstOrNull { it.value == actionId }?.key
|
||||
|
||||
fun clearBinding(actionId: String, source: space.rcmd.android.sizzle.input.InputSource) {
|
||||
when (source) {
|
||||
space.rcmd.android.sizzle.input.InputSource.MIDI ->
|
||||
midiInput.ccBindings.entries.removeAll { it.value == actionId }
|
||||
space.rcmd.android.sizzle.input.InputSource.GAMEPAD ->
|
||||
gamepadInput.bindings.entries.removeAll { it.value == actionId }
|
||||
space.rcmd.android.sizzle.input.InputSource.KEYBOARD ->
|
||||
keyboardInput.bindings.entries.removeAll { it.value == actionId }
|
||||
else -> {}
|
||||
}
|
||||
touched()
|
||||
persistBindings()
|
||||
}
|
||||
|
||||
/** Save all input binding maps so edits survive an app restart. */
|
||||
private fun persistBindings() {
|
||||
viewModelScope.launch { bindingStore.save(keyboardInput, gamepadInput, midiInput) }
|
||||
}
|
||||
|
||||
private fun touched() { revision++ }
|
||||
|
||||
/** Public hook so screens that mutate the model directly (e.g. dropdowns in
|
||||
* the toolbar, mixer faders) can request a redraw. */
|
||||
fun bumpForToolbar() = touched()
|
||||
|
||||
/** Call after changing mixer FX routing so the audio engine rebuilds the
|
||||
* per-channel insert chains without waiting for the next Play. */
|
||||
fun rebuildAudioRouting() {
|
||||
engine.rebuildFxChains()
|
||||
touched()
|
||||
}
|
||||
|
||||
/** Toggle mute on an arrangement MIDI lane — a muted lane's blocks are skipped
|
||||
* by the sequencer while its cells stay in place. Read live on the audio thread. */
|
||||
fun toggleArrangementLaneMute(lane: Int) {
|
||||
val muted = project.arrangement.laneMuted
|
||||
if (lane in muted.indices) { muted[lane] = !muted[lane]; touched() }
|
||||
}
|
||||
|
||||
/** Nudge an A/B loop region's repeat count and apply it to live playback
|
||||
* immediately (without restarting the transport). */
|
||||
fun changeRegionRepeats(region: space.rcmd.android.sizzle.model.LoopRegion, delta: Int) {
|
||||
region.repeats = (region.repeats + delta).coerceAtLeast(1)
|
||||
engine.refreshLoopPlaylist()
|
||||
touched()
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- sampler
|
||||
private val recorder = space.rcmd.android.sizzle.audio.SampleRecorder()
|
||||
var isRecording by mutableStateOf(false); private set
|
||||
|
||||
/** Decode a WAV and assign it to Sampler [slot]'s [pad] (from a file import). */
|
||||
fun loadSampleInto(
|
||||
slot: space.rcmd.android.sizzle.model.ToolboxSlot,
|
||||
pad: Int,
|
||||
id: String,
|
||||
bytes: ByteArray,
|
||||
): Boolean {
|
||||
val sample = space.rcmd.android.sizzle.audio.SampleStore.decodeWav(bytes) ?: return false
|
||||
space.rcmd.android.sizzle.audio.SampleStore.put(id, sample)
|
||||
val pads = space.rcmd.android.sizzle.model.SamplerPads
|
||||
slot.set(pads.key(pad), id)
|
||||
slot.set(pads.sliceStartKey(pad), 0f); slot.set(pads.sliceEndKey(pad), 1f)
|
||||
touched()
|
||||
return true
|
||||
}
|
||||
|
||||
/** Load an SF2 / XI (or WAV) file into a SoundFont [slot]: parse it, cache the
|
||||
* extracted sample, and store its id + root note on the slot. */
|
||||
fun loadSoundFont(
|
||||
slot: space.rcmd.android.sizzle.model.ToolboxSlot,
|
||||
id: String,
|
||||
bytes: ByteArray,
|
||||
): Boolean {
|
||||
val loaded = space.rcmd.android.sizzle.audio.SoundFontLoader.load(bytes) ?: return false
|
||||
space.rcmd.android.sizzle.audio.SampleStore.put(id, loaded.sample)
|
||||
slot.set("sfSample", id)
|
||||
slot.set("sfRoot", loaded.rootNote.toFloat())
|
||||
touched()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Destructively trim a Sampler [slot]'s [pad] to its current slice region: the
|
||||
* pad's sample is replaced by just the portion between the two slice markers,
|
||||
* and the markers reset to the full (new) sample. There is no undo — re-import
|
||||
* or re-record to start over. Returns false if there's nothing to trim.
|
||||
*/
|
||||
fun trimSamplerPad(slot: space.rcmd.android.sizzle.model.ToolboxSlot, pad: Int): Boolean {
|
||||
val pads = space.rcmd.android.sizzle.model.SamplerPads
|
||||
val store = space.rcmd.android.sizzle.audio.SampleStore
|
||||
val sample = store.get(slot.string(pads.key(pad))) ?: return false
|
||||
val len = sample.data.size
|
||||
if (len <= 1) return false
|
||||
val a = slot.float(pads.sliceStartKey(pad), 0f).coerceIn(0f, 1f)
|
||||
val b = slot.float(pads.sliceEndKey(pad), 1f).coerceIn(0f, 1f)
|
||||
val start = (minOf(a, b) * len).toInt().coerceIn(0, len - 1)
|
||||
val end = (maxOf(a, b) * len).toInt().coerceIn(start + 1, len)
|
||||
if (start == 0 && end == len) return false // slice already spans the whole sample
|
||||
// A fresh id so other pads sharing the original sample are left untouched.
|
||||
val id = "trim:" + System.nanoTime()
|
||||
store.put(id, space.rcmd.android.sizzle.audio.SampleStore.Sample(
|
||||
sample.data.copyOfRange(start, end), sample.sampleRate))
|
||||
slot.set(pads.key(pad), id)
|
||||
slot.set(pads.sliceStartKey(pad), 0f)
|
||||
slot.set(pads.sliceEndKey(pad), 1f)
|
||||
touched()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Peak-normalize a Sampler [slot]'s [pad]: scale the whole sample so its loudest
|
||||
* peak reaches full scale (+-1). The slice markers are unchanged (the length is
|
||||
* the same). No undo. Returns false if the pad is empty, silent, or already
|
||||
* normalized.
|
||||
*/
|
||||
fun normalizeSamplerPad(slot: space.rcmd.android.sizzle.model.ToolboxSlot, pad: Int): Boolean {
|
||||
val pads = space.rcmd.android.sizzle.model.SamplerPads
|
||||
val store = space.rcmd.android.sizzle.audio.SampleStore
|
||||
val sample = store.get(slot.string(pads.key(pad))) ?: return false
|
||||
val data = sample.data
|
||||
if (data.isEmpty()) return false
|
||||
var peak = 0f
|
||||
for (v in data) { val a = if (v < 0f) -v else v; if (a > peak) peak = a }
|
||||
if (peak <= 0f) return false // silent — nothing to normalize
|
||||
val gain = 1f / peak
|
||||
if (gain in 0.999f..1.001f) return false // already at full scale
|
||||
val out = FloatArray(data.size) { (data[it] * gain).coerceIn(-1f, 1f) }
|
||||
val id = "norm:" + System.nanoTime() // fresh id so shared samples are untouched
|
||||
store.put(id, space.rcmd.android.sizzle.audio.SampleStore.Sample(out, sample.sampleRate))
|
||||
slot.set(pads.key(pad), id)
|
||||
touched()
|
||||
return true
|
||||
}
|
||||
|
||||
/** Clear the sample assigned to a Sampler [slot]'s [pad]. */
|
||||
fun clearSamplerPad(slot: space.rcmd.android.sizzle.model.ToolboxSlot, pad: Int) {
|
||||
val pads = space.rcmd.android.sizzle.model.SamplerPads
|
||||
slot.values.remove(pads.key(pad))
|
||||
slot.values.remove(pads.sliceStartKey(pad))
|
||||
slot.values.remove(pads.sliceEndKey(pad))
|
||||
touched()
|
||||
}
|
||||
|
||||
fun startRecording() { if (recorder.start()) isRecording = true }
|
||||
|
||||
// ------------------------------------------------------- audio device routing
|
||||
/** Ids of the chosen output/input devices (for the Settings UI), null = default. */
|
||||
var outputDeviceId by mutableStateOf<Int?>(null); private set
|
||||
var inputDeviceId by mutableStateOf<Int?>(null); private set
|
||||
|
||||
fun setAudioOutput(device: android.media.AudioDeviceInfo?) {
|
||||
engine.setPreferredOutput(device)
|
||||
outputDeviceId = device?.id
|
||||
}
|
||||
|
||||
fun setAudioInput(device: android.media.AudioDeviceInfo?) {
|
||||
recorder.preferredInput = device
|
||||
inputDeviceId = device?.id
|
||||
}
|
||||
|
||||
/** Set when the native Oboe backend failed to initialise and playback fell back
|
||||
* to the standard AudioTrack path; drives a one-off popup. Null when clear. */
|
||||
var nativeAudioError by mutableStateOf<String?>(null); private set
|
||||
fun dismissNativeAudioError() { nativeAudioError = null }
|
||||
|
||||
/** Choose the colour theme and persist the choice. */
|
||||
fun selectPalette(p: space.rcmd.android.sizzle.ui.theme.RetroPalette) {
|
||||
palette = p
|
||||
persistSettings()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ colour themes
|
||||
private val stockThemes get() = space.rcmd.android.sizzle.ui.theme.RetroPalette.ALL
|
||||
|
||||
/** The two built-in themes are read-only and can never be deleted. */
|
||||
fun isStockTheme(name: String): Boolean = stockThemes.any { it.name == name }
|
||||
|
||||
/** Every selectable theme: the two stock themes followed by user themes. */
|
||||
fun allThemes(): List<space.rcmd.android.sizzle.ui.theme.RetroPalette> =
|
||||
stockThemes + themeLibrary.loadAll()
|
||||
|
||||
/** All theme names for the selector dropdown (stock first, then user). */
|
||||
fun themeNames(): List<String> = allThemes().map { it.name }
|
||||
|
||||
/** Apply the theme with [name] (stock or user), persisting the choice. */
|
||||
fun selectThemeByName(name: String) {
|
||||
allThemes().firstOrNull { it.name == name }?.let { selectPalette(it) }
|
||||
}
|
||||
|
||||
/** Import a theme from picked bytes and apply it. Returns false if it isn't a
|
||||
* valid `.szt` theme (so the caller can show a popup). */
|
||||
fun importTheme(bytes: ByteArray): Boolean {
|
||||
val palette = themeLibrary.importText(String(bytes, Charsets.UTF_8)) ?: return false
|
||||
selectPalette(palette) // apply + persist
|
||||
touched()
|
||||
return true
|
||||
}
|
||||
|
||||
/** A shareable `.szt` file of the current theme (for the system share sheet). */
|
||||
fun themeExportFile(): java.io.File = themeLibrary.writeExport(palette)
|
||||
|
||||
/** Delete a USER theme by name. Stock themes are protected (returns false). If
|
||||
* the deleted theme was active, fall back to the default palette. */
|
||||
fun deleteTheme(name: String): Boolean {
|
||||
if (isStockTheme(name)) return false
|
||||
val ok = themeLibrary.delete(name)
|
||||
if (ok) {
|
||||
if (palette.name == name) selectPalette(space.rcmd.android.sizzle.ui.theme.RetroPalette.AMBER)
|
||||
touched()
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
/** Save app-level settings (theme + audio backend) so they survive a restart. */
|
||||
private fun persistSettings() {
|
||||
viewModelScope.launch { settingsStore.save(palette.name) }
|
||||
}
|
||||
|
||||
// Restore persisted app settings. This init block sits AFTER the `palette` state
|
||||
// property is declared, so its backing MutableState exists before we touch it
|
||||
// (init blocks run in textual order). A tiny blocking read at construction
|
||||
// (before the first compose) so the theme applies without a flash of the default.
|
||||
init {
|
||||
kotlinx.coroutines.runBlocking {
|
||||
val s = settingsStore.load()
|
||||
// Match the saved theme against the stock themes first, then user themes.
|
||||
allThemes().firstOrNull { it.name == s.paletteName }?.let { palette = it }
|
||||
|
||||
// Surface a popup if the low-latency (Oboe) backend fails to init and the
|
||||
// engine falls back to standard playback. Fires off the main thread.
|
||||
engine.onNativeInitError = { msg ->
|
||||
viewModelScope.launch { nativeAudioError = msg }
|
||||
}
|
||||
|
||||
// Restore the recording destination + tail, and wire the engine's
|
||||
// capture staging/completion into the SAF copy.
|
||||
recordDirUri = s.recordDirUri
|
||||
recordDirName = recordDirUri
|
||||
?.let { runCatching { displayNameForTree(android.net.Uri.parse(it)) }.getOrNull() }
|
||||
recordTailBars = s.recordTailBars.coerceIn(0, 4)
|
||||
// Restore the on-screen keyboard's channel + octave.
|
||||
kbdChannel = s.kbdChannel.coerceIn(0, Cell.MAX_CHANNEL)
|
||||
kbdOctave = s.kbdOctave.coerceIn(0, 8)
|
||||
engine.recTempDir = appContext.cacheDir
|
||||
engine.setRecordTailBars(recordTailBars)
|
||||
engine.onRecordingStarted = {
|
||||
viewModelScope.launch { masterRecording = true }
|
||||
}
|
||||
engine.onRecordingComplete = { file, startMillis ->
|
||||
// On the recorder's writer thread: copy off the main thread, then
|
||||
// publish the UI state change on it.
|
||||
val saved = saveRecordingToDir(file, startMillis)
|
||||
viewModelScope.launch {
|
||||
masterRecording = false
|
||||
if (saved != null) lastRecordingName = saved
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop recording and assign the captured audio to Sampler [slot]'s [pad]. */
|
||||
fun stopRecordingInto(slot: space.rcmd.android.sizzle.model.ToolboxSlot, pad: Int) {
|
||||
val sample = recorder.stop()
|
||||
isRecording = false
|
||||
if (sample != null) {
|
||||
val id = "rec-" + System.currentTimeMillis()
|
||||
space.rcmd.android.sizzle.audio.SampleStore.put(id, sample)
|
||||
val pads = space.rcmd.android.sizzle.model.SamplerPads
|
||||
slot.set(pads.key(pad), id)
|
||||
slot.set(pads.sliceStartKey(pad), 0f); slot.set(pads.sliceEndKey(pad), 1f)
|
||||
touched()
|
||||
}
|
||||
}
|
||||
|
||||
/** Point an LFO slot at another device's parameter, capturing that param's
|
||||
* current value as the modulation centre. Pass targetSlotIndex < 0 to clear. */
|
||||
fun setLfoTarget(
|
||||
lfoSlot: space.rcmd.android.sizzle.model.ToolboxSlot,
|
||||
targetSlotIndex: Int,
|
||||
key: String,
|
||||
) {
|
||||
if (targetSlotIndex < 0) {
|
||||
lfoSlot.set("target", "")
|
||||
} else {
|
||||
val tSlot = project.toolbox.getOrNull(targetSlotIndex)
|
||||
val spec = tSlot?.type?.params?.firstOrNull { it.key == key }
|
||||
lfoSlot.set("target", "$targetSlotIndex:$key")
|
||||
lfoSlot.set("center", tSlot?.float(key, spec?.default ?: 0f) ?: 0f)
|
||||
}
|
||||
touched()
|
||||
}
|
||||
|
||||
/** Preview a note of the Sampler slot's loaded sample (one-shot, editor use). */
|
||||
fun auditionSample(slot: space.rcmd.android.sizzle.model.ToolboxSlot, midi: Int) {
|
||||
engine.auditionSlotOn(slot.index, midi)
|
||||
}
|
||||
|
||||
/** Toolbox test keyboard: press/release a note on the instrument in [slotIndex]. */
|
||||
fun auditionOn(slotIndex: Int, midi: Int) { markNoteOn(midi); engine.auditionSlotOn(slotIndex, midi) }
|
||||
fun auditionOff(midi: Int) { markNoteOff(midi); engine.auditionSlotOff(midi) }
|
||||
|
||||
|
||||
// ------------------------------------------------------------ project I/O
|
||||
/** Serialize the current song to `.sng` text. */
|
||||
fun saveProjectText(): String =
|
||||
space.rcmd.android.sizzle.io.ProjectIo.save(project)
|
||||
|
||||
/** Replace the current song's contents from our full internal text (in place). */
|
||||
fun loadProjectText(text: String) {
|
||||
space.rcmd.android.sizzle.io.ProjectIo.loadInto(project, text)
|
||||
cursorLine = 0; cursorTrack = 0
|
||||
touched()
|
||||
}
|
||||
|
||||
/** Export to the reference desktop sizzletracker `.sng` format (musical data). */
|
||||
fun exportSng(): String =
|
||||
space.rcmd.android.sizzle.io.SngFormat.export(project)
|
||||
|
||||
/** Import a reference `.sng` file (in place). Returns false if the text doesn't
|
||||
* look like a `.sng` (so the caller can surface a popup) rather than silently
|
||||
* wiping the song with garbage. */
|
||||
fun importSng(text: String): Boolean {
|
||||
val looksLikeSng = Regex("(?m)^\\s*(version|bpm|sig|block)\\b").containsMatchIn(text)
|
||||
if (!looksLikeSng) return false
|
||||
runCatching {
|
||||
space.rcmd.android.sizzle.io.SngFormat.importInto(project, text)
|
||||
}.getOrElse { return false }
|
||||
cursorLine = 0; cursorTrack = 0
|
||||
touched()
|
||||
return true
|
||||
}
|
||||
|
||||
// -------------------------------------------- on-device project library (full)
|
||||
/** Names of the full projects stored on device (for the Settings browser). */
|
||||
fun songNames(): List<String> = songLibrary.list()
|
||||
|
||||
/** The current project name (shown as the save default). */
|
||||
val songName: String get() = project.name
|
||||
|
||||
/** Save the current project — full fidelity (mixer/FX/instruments) — to storage. */
|
||||
fun saveSong(name: String) {
|
||||
project.name = name
|
||||
songLibrary.save(name, saveProjectText())
|
||||
touched()
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard the current song and start from a clean, empty project — the same
|
||||
* state as a fresh launch. Implemented by loading a freshly-serialized default
|
||||
* [Project] into the shared instance (reusing the loader's full reset), so every
|
||||
* field — patterns, arrangement, mixer routing, toolbox, tempo/scale — returns to
|
||||
* its default. There is no undo. Playback is stopped first so the audio thread
|
||||
* isn't reading the model as it's cleared.
|
||||
*/
|
||||
fun newProject() {
|
||||
engine.stop()
|
||||
loadProjectText(space.rcmd.android.sizzle.io.ProjectIo.save(Project()))
|
||||
project.name = "untitled"
|
||||
engine.rebuildFxChains() // all FX routing was cleared
|
||||
}
|
||||
|
||||
/** Load a full project from storage (in place), restoring its sample audio. */
|
||||
fun loadSong(name: String): Boolean {
|
||||
val text = songLibrary.load(name) ?: return false
|
||||
loadProjectText(text) // full restore + cursor reset + revision bump
|
||||
project.name = name
|
||||
presetLibrary.rehydrate(project) // reload the referenced sample audio
|
||||
engine.rebuildFxChains() // mixer FX routing may have changed
|
||||
return true
|
||||
}
|
||||
|
||||
/** Delete a stored full project. */
|
||||
fun deleteSong(name: String): Boolean {
|
||||
val ok = songLibrary.delete(name)
|
||||
if (ok) touched()
|
||||
return ok
|
||||
}
|
||||
|
||||
/** Write the current project as a desktop-compatible `.sng` to a shareable file. */
|
||||
fun writeSngExport(): java.io.File =
|
||||
songLibrary.writeExport(project.name.ifBlank { "song" }, exportSng())
|
||||
|
||||
// ------------------------------------------------------ instrument/effect presets
|
||||
/** Preset names available for a device type (for the editor's browser dropdown). */
|
||||
fun presetNames(type: space.rcmd.android.sizzle.model.ToolboxType): List<String> =
|
||||
presetLibrary.list(type)
|
||||
|
||||
/** Save [slot] as a named preset in the library. Returns the preset file. */
|
||||
fun savePreset(slot: space.rcmd.android.sizzle.model.ToolboxSlot, name: String): java.io.File {
|
||||
val file = presetLibrary.save(slot, name)
|
||||
touched()
|
||||
return file
|
||||
}
|
||||
|
||||
/** Load a named preset for [slot]'s current type into it. The slot is renamed to
|
||||
* the preset so the toolbox tile and mixer inserts read as the loaded sound. */
|
||||
fun loadPreset(slot: space.rcmd.android.sizzle.model.ToolboxSlot, name: String): Boolean {
|
||||
val type = slot.type ?: return false
|
||||
val ok = presetLibrary.load(slot, type, name)
|
||||
if (ok) { slot.name = name; engine.rebuildFxChains(); touched() }
|
||||
return ok
|
||||
}
|
||||
|
||||
/** Import a preset from picked bytes: a `.zip` bundle (Sampler) or plain text. */
|
||||
fun importPreset(slot: space.rcmd.android.sizzle.model.ToolboxSlot, bytes: ByteArray): Boolean {
|
||||
val isZip = bytes.size >= 2 && bytes[0] == 'P'.code.toByte() && bytes[1] == 'K'.code.toByte()
|
||||
val ok = if (isZip) presetLibrary.importZip(slot, java.io.ByteArrayInputStream(bytes))
|
||||
else presetLibrary.importText(slot, String(bytes, Charsets.UTF_8))
|
||||
if (ok) { engine.rebuildFxChains(); touched() }
|
||||
return ok
|
||||
}
|
||||
|
||||
/** A shareable file for a stored preset (a zip bundle for the Sampler). */
|
||||
fun presetShareFile(type: space.rcmd.android.sizzle.model.ToolboxType, name: String): java.io.File? =
|
||||
presetLibrary.bundleForShare(type, name)
|
||||
|
||||
/** Delete a stored preset (and, for the Sampler, its bundled sample). */
|
||||
fun deletePreset(type: space.rcmd.android.sizzle.model.ToolboxType, name: String): Boolean {
|
||||
val ok = presetLibrary.delete(type, name)
|
||||
if (ok) touched()
|
||||
return ok
|
||||
}
|
||||
|
||||
private fun nextOf(c: CellColumn, dir: Int): CellColumn {
|
||||
val values = CellColumn.entries
|
||||
return values[(c.ordinal + dir + values.size) % values.size]
|
||||
}
|
||||
}
|
||||
21
app/src/main/java/space/rcmd/android/sizzle/ui/Responsive.kt
Normal file
21
app/src/main/java/space/rcmd/android/sizzle/ui/Responsive.kt
Normal file
@@ -0,0 +1,21 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
|
||||
/**
|
||||
* True when the device is currently in landscape orientation. Drives the app's
|
||||
* side-by-side landscape layouts (a side nav rail, the tracker/toolbox split into
|
||||
* columns) so wide, short screens — tablets, handheld consoles — use the space
|
||||
* horizontally instead of squashing everything into short vertical bands.
|
||||
*
|
||||
* The Activity handles rotation itself (configChanges), so [LocalConfiguration]
|
||||
* updates and recomposes callers on every orientation change.
|
||||
*/
|
||||
@Composable
|
||||
fun isLandscape(): Boolean =
|
||||
LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
@@ -0,0 +1,124 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.waitForUpOrCancellation
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import space.rcmd.android.sizzle.model.Pitch
|
||||
import space.rcmd.android.sizzle.ui.components.PianoKey
|
||||
import space.rcmd.android.sizzle.ui.components.PianoOctave
|
||||
import space.rcmd.android.sizzle.ui.components.RetroButton
|
||||
import space.rcmd.android.sizzle.ui.theme.LocalRetro
|
||||
|
||||
private const val VEL_STEP = 8
|
||||
|
||||
/**
|
||||
* A stacked two-octave keyboard shared by the Toolbox (audition) and Tracker
|
||||
* (punch-in) tabs. Its channel, octave range and velocity live on the
|
||||
* [AppViewModel] (kbdChannel / kbdOctave / kbdVelocity) so both keyboards share
|
||||
* them and they persist across tab switches. Holding a key auditions the note
|
||||
* through the selected mix bus; when [punchIn] is true it also writes the note into
|
||||
* the tracker's focused cell and advances the cursor by the skip step.
|
||||
*/
|
||||
@Composable
|
||||
fun StackedKeyboard(vm: AppViewModel, punchIn: Boolean, modifier: Modifier) {
|
||||
val c = LocalRetro.current
|
||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // refresh key filtering on scale/root change
|
||||
// In punch-in mode only in-scale keys are playable (Chromatic yields all twelve);
|
||||
// the toolbox audition keyboard stays fully chromatic (null = no filter).
|
||||
val inKey: Set<Int>? = if (punchIn) {
|
||||
val root = vm.project.rootNote
|
||||
vm.project.scale.intervals.map { (it + root) % 12 }.toSet()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
Column(
|
||||
modifier.fillMaxWidth().background(c.background).padding(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Selector("CH", if (vm.kbdChannel == 0) "--" else "${vm.kbdChannel}",
|
||||
{ vm.updateKbdChannel(vm.kbdChannel - 1) }, { vm.updateKbdChannel(vm.kbdChannel + 1) })
|
||||
Selector("OCT", "${vm.kbdOctave}–${vm.kbdOctave + 1}",
|
||||
{ vm.updateKbdOctave(vm.kbdOctave - 1) }, { vm.updateKbdOctave(vm.kbdOctave + 1) })
|
||||
Selector("VEL", "%02X".format(vm.kbdVelocity),
|
||||
{ vm.updateKbdVelocity(vm.kbdVelocity - VEL_STEP) }, { vm.updateKbdVelocity(vm.kbdVelocity + VEL_STEP) })
|
||||
}
|
||||
KeyboardOctave(vm, punchIn, inKey, (vm.kbdOctave + 2) * 12, Modifier.weight(1f)) // upper octave on top
|
||||
KeyboardOctave(vm, punchIn, inKey, (vm.kbdOctave + 1) * 12, Modifier.weight(1f)) // lower octave below
|
||||
}
|
||||
}
|
||||
|
||||
/** A "LABEL - value +" stepper emitted inline into the control row. */
|
||||
@Composable
|
||||
private fun Selector(label: String, value: String, onDec: () -> Unit, onInc: () -> Unit) {
|
||||
val c = LocalRetro.current
|
||||
Text(label, color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 11.sp)
|
||||
RetroButton("-", onClick = onDec)
|
||||
Text(
|
||||
value, color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 13.sp,
|
||||
textAlign = TextAlign.Center, modifier = Modifier.width(40.dp),
|
||||
)
|
||||
RetroButton("+", onClick = onInc)
|
||||
}
|
||||
|
||||
/** One octave; hold a key to audition (and, in punch-in mode, record) the note.
|
||||
* When [inKey] is non-null, out-of-scale keys are dimmed and inert. */
|
||||
@Composable
|
||||
private fun KeyboardOctave(
|
||||
vm: AppViewModel,
|
||||
punchIn: Boolean,
|
||||
inKey: Set<Int>?,
|
||||
startMidi: Int,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
PianoOctave(modifier.fillMaxWidth()) { pc, isBlack, keyMod ->
|
||||
val midi = (startMidi + pc).coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
|
||||
val enabled = inKey == null || pc in inKey
|
||||
val liveMidi = rememberUpdatedState(midi)
|
||||
val livePunch = rememberUpdatedState(punchIn)
|
||||
PianoKey(
|
||||
label = Pitch.name(midi).replace("-", ""),
|
||||
isBlack = isBlack,
|
||||
enabled = enabled,
|
||||
selected = false,
|
||||
pressed = vm.isNoteHeld(midi),
|
||||
modifier = if (!enabled) keyMod else keyMod.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
awaitFirstDown()
|
||||
val n = liveMidi.value
|
||||
if (livePunch.value) vm.punchNote(n)
|
||||
vm.kbdNoteOn(n)
|
||||
waitForUpOrCancellation()
|
||||
vm.kbdNoteOff(n)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.ui.components
|
||||
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.text.TextMeasurer
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
|
||||
/**
|
||||
* Lays out each distinct (text, style) pair exactly once and reuses the result on
|
||||
* every later frame. The Canvas grids draw the same tiny set of fixed-width strings
|
||||
* — note names, hex velocities, lane/bar numbers — dozens of times per frame, and
|
||||
* re-measuring them every frame was the main cost that made the playhead feel
|
||||
* sluggish. [TextMeasurer]'s own cache is a small LRU (8 entries) that a full grid
|
||||
* blows past immediately, so we keep our own unbounded, allocation-free cache.
|
||||
*
|
||||
* Styles are compared by identity (===): callers pass a fixed handful of remembered
|
||||
* [TextStyle] instances, so a short linear scan avoids hashing a whole TextStyle per
|
||||
* cell. Not thread-safe — only ever touched from the draw phase.
|
||||
*/
|
||||
class GlyphCache(private val measurer: TextMeasurer) {
|
||||
private val styles = ArrayList<TextStyle>(6)
|
||||
private val byStyle = ArrayList<HashMap<String, TextLayoutResult>>(6)
|
||||
|
||||
fun measure(text: String, style: TextStyle): TextLayoutResult {
|
||||
// Indexed identity scan (not indexOfFirst {}) so no Iterator/lambda allocates
|
||||
// on the draw phase — this runs once per drawn cell per frame.
|
||||
var idx = -1
|
||||
for (i in styles.indices) if (styles[i] === style) { idx = i; break }
|
||||
if (idx < 0) {
|
||||
styles.add(style)
|
||||
byStyle.add(HashMap())
|
||||
idx = styles.size - 1
|
||||
}
|
||||
return byStyle[idx].getOrPut(text) { measurer.measure(text, style) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import space.rcmd.android.sizzle.ui.theme.LocalRetro
|
||||
|
||||
/**
|
||||
* Lays out one octave as a real piano keyboard: seven white keys across the bottom
|
||||
* with the five black keys overlaid on top, each centred on the gap between the two
|
||||
* white keys it sits between (C# D# _ F# G# A#).
|
||||
*
|
||||
* Layout-only: the [key] slot renders each key and callers attach their own gesture
|
||||
* to [keyModifier] (tap-to-enter, hold-to-audition, …). Black keys are emitted last
|
||||
* so they draw — and receive touches — on top of the white keys they overlap, just
|
||||
* like a physical keyboard.
|
||||
*/
|
||||
@Composable
|
||||
fun PianoOctave(
|
||||
modifier: Modifier = Modifier,
|
||||
blackWidthFraction: Float = 0.62f,
|
||||
blackHeightFraction: Float = 0.62f,
|
||||
key: @Composable (pitchClass: Int, isBlack: Boolean, keyModifier: Modifier) -> Unit,
|
||||
) {
|
||||
BoxWithConstraints(modifier) {
|
||||
val whiteW = maxWidth / 7
|
||||
val blackW = whiteW * blackWidthFraction
|
||||
val blackH = maxHeight * blackHeightFraction
|
||||
// White keys: C D E F G A B, filling the width evenly.
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
for (pc in WHITE_PCS) key(pc, false, Modifier.weight(1f).fillMaxHeight())
|
||||
}
|
||||
// Black keys, each centred on the boundary after a given white-key index.
|
||||
for ((pc, afterWhite) in BLACK_PCS) {
|
||||
key(
|
||||
pc, true,
|
||||
Modifier
|
||||
.offset(x = whiteW * (afterWhite + 1) - blackW / 2)
|
||||
.width(blackW)
|
||||
.height(blackH),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A single piano key face: light for white keys, dark for black, dimmed when
|
||||
* [enabled] is false and accent-filled when [selected]. [pressed] lights the key
|
||||
* in the accent colour while it is held down (from any input source) — the visual
|
||||
* feedback shared by every keyboard widget. The gesture (if any) is supplied by the
|
||||
* caller through [modifier].
|
||||
*/
|
||||
@Composable
|
||||
fun PianoKey(
|
||||
label: String,
|
||||
isBlack: Boolean,
|
||||
enabled: Boolean,
|
||||
selected: Boolean,
|
||||
modifier: Modifier,
|
||||
pressed: Boolean = false,
|
||||
) {
|
||||
val c = LocalRetro.current
|
||||
val lit = selected || pressed
|
||||
val bg = when {
|
||||
lit -> c.accent
|
||||
isBlack -> if (enabled) BLACK_KEY else BLACK_KEY_OFF
|
||||
else -> if (enabled) WHITE_KEY else WHITE_KEY_OFF
|
||||
}
|
||||
val fg = when {
|
||||
lit -> c.background
|
||||
isBlack -> if (enabled) Color(0xFFDDDDDD) else Color(0xFF555555)
|
||||
else -> if (enabled) Color(0xFF141414) else Color(0xFF6A6A6A)
|
||||
}
|
||||
Box(
|
||||
modifier
|
||||
.background(bg)
|
||||
.border(1.dp, if (lit) c.accent else KEY_BORDER, RectangleShape),
|
||||
contentAlignment = Alignment.BottomCenter,
|
||||
) {
|
||||
Text(
|
||||
label, color = fg, fontFamily = FontFamily.Monospace, fontSize = 12.sp,
|
||||
maxLines = 1, softWrap = false,
|
||||
modifier = Modifier.padding(bottom = 3.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val WHITE_PCS = intArrayOf(0, 2, 4, 5, 7, 9, 11) // C D E F G A B
|
||||
// pitch class -> index of the white key it sits immediately after (0=C .. 6=B).
|
||||
private val BLACK_PCS = listOf(1 to 0, 3 to 1, 6 to 3, 8 to 4, 10 to 5)
|
||||
private val WHITE_KEY = Color(0xFFE8E8E8)
|
||||
private val WHITE_KEY_OFF = Color(0xFF5A5A5A)
|
||||
private val BLACK_KEY = Color(0xFF181820)
|
||||
private val BLACK_KEY_OFF = Color(0xFF0C0C10)
|
||||
private val KEY_BORDER = Color(0xFF000000)
|
||||
@@ -0,0 +1,139 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import space.rcmd.android.sizzle.ui.theme.LocalRetro
|
||||
|
||||
/**
|
||||
* A tiny library of "pixel" UI building blocks. They are intentionally plain —
|
||||
* hard rectangles, monospace text, 1px borders — so they are cheap to draw
|
||||
* (helping the "lightweight UI" requirement) and match the retro aesthetic.
|
||||
* Reusing these keeps every screen visually consistent.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A flat, square-cornered button. [active] paints it in the accent colour.
|
||||
* Pass [width] to pin a fixed width (e.g. a button whose label toggles, like
|
||||
* PLAY/PAUSE) so it never resizes; the single-line label clips with an ellipsis
|
||||
* if it can't fit.
|
||||
*/
|
||||
@Composable
|
||||
fun RetroButton(
|
||||
label: String,
|
||||
modifier: Modifier = Modifier,
|
||||
active: Boolean = false,
|
||||
width: Dp? = null,
|
||||
danger: Boolean = false,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val c = LocalRetro.current
|
||||
// danger (red) wins over active (accent); otherwise the neutral surface look.
|
||||
val hue = when { danger -> c.danger; active -> c.accent; else -> null }
|
||||
Box(
|
||||
modifier
|
||||
.then(if (width != null) Modifier.width(width) else Modifier)
|
||||
.border(1.dp, hue ?: c.grid, RectangleShape)
|
||||
.background(hue?.copy(alpha = 0.18f) ?: c.surface)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
color = hue ?: c.text,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A labelled dropdown selector rendered as `LABEL:value ▾`. */
|
||||
@Composable
|
||||
fun <T> RetroDropdown(
|
||||
label: String,
|
||||
options: List<T>,
|
||||
selected: T,
|
||||
modifier: Modifier = Modifier,
|
||||
optionLabel: (T) -> String = { it.toString() },
|
||||
onSelect: (T) -> Unit,
|
||||
) {
|
||||
val c = LocalRetro.current
|
||||
var open by remember { mutableStateOf(false) }
|
||||
// Reserve the width of the longest option so the field never resizes as the
|
||||
// selection changes — a common source of toolbar "jitter".
|
||||
val longest = options.maxByOrNull { optionLabel(it).length }?.let(optionLabel).orEmpty()
|
||||
// Drop the "label:" prefix entirely when no label is given (e.g. mixer selectors
|
||||
// that already have a section header above them).
|
||||
val prefix = if (label.isEmpty()) "" else "$label:"
|
||||
Box(modifier) {
|
||||
Box(
|
||||
Modifier
|
||||
.border(1.dp, c.grid, RectangleShape)
|
||||
.background(c.surface)
|
||||
.clickable { open = true }
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
) {
|
||||
// Invisible sizer (longest option) fixes the width; the visible value
|
||||
// draws on top and clips with an ellipsis if it ever overflows.
|
||||
Text(
|
||||
"$prefix$longest ▾",
|
||||
color = Color.Transparent,
|
||||
fontFamily = FontFamily.Monospace, fontSize = 12.sp,
|
||||
maxLines = 1, softWrap = false,
|
||||
)
|
||||
Text(
|
||||
"$prefix${optionLabel(selected)} ▾",
|
||||
color = c.text, fontFamily = FontFamily.Monospace, fontSize = 12.sp,
|
||||
maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
||||
options.forEach { opt ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(optionLabel(opt), fontFamily = FontFamily.Monospace) },
|
||||
onClick = { onSelect(opt); open = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A dim, spaced section header, e.g. "── TRANSPORT ──". */
|
||||
@Composable
|
||||
fun SectionLabel(text: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text.uppercase(),
|
||||
modifier = modifier.padding(vertical = 4.dp),
|
||||
color = LocalRetro.current.textDim,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.ui.mixer
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import space.rcmd.android.sizzle.model.Mixer
|
||||
import space.rcmd.android.sizzle.model.MixerChannel
|
||||
import space.rcmd.android.sizzle.model.ToolboxKind
|
||||
import space.rcmd.android.sizzle.ui.AppViewModel
|
||||
import space.rcmd.android.sizzle.ui.isLandscape
|
||||
import space.rcmd.android.sizzle.ui.components.RetroButton
|
||||
import space.rcmd.android.sizzle.ui.components.RetroDropdown
|
||||
import space.rcmd.android.sizzle.ui.components.SectionLabel
|
||||
import space.rcmd.android.sizzle.ui.theme.LocalRetro
|
||||
|
||||
/**
|
||||
* The second tab: a four-channel mixer, one strip per tracker track. Each strip
|
||||
* chooses an instrument + up to four effects (all drawn from the toolbox), sets a
|
||||
* MIDI channel, and has a volume fader plus mute/solo. Everything writes straight
|
||||
* into the shared [Mixer] model that the audio engine reads.
|
||||
*/
|
||||
@Composable
|
||||
fun MixerScreen(vm: AppViewModel) {
|
||||
val c = LocalRetro.current
|
||||
@Suppress("UNUSED_VARIABLE") val redraw = vm.revision
|
||||
Column(Modifier.fillMaxSize().background(c.background)) {
|
||||
RecordToolbar(vm)
|
||||
Row(
|
||||
Modifier.weight(1f).fillMaxWidth().padding(6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
vm.project.mixer.channels.forEach { channel ->
|
||||
ChannelStrip(vm, channel, Modifier.weight(1f).fillMaxHeight())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Master-bus recorder controls at the top of the mixer: arm, FX-tail length, and
|
||||
* the output folder. Recording begins when playback starts and stops automatically
|
||||
* a tail (in bars) after the sequencer stops — a pause/stop, or the arrangement end
|
||||
* with looping off. WAV files are written to the chosen folder with a date-time name.
|
||||
*/
|
||||
@Composable
|
||||
private fun RecordToolbar(vm: AppViewModel) {
|
||||
val c = LocalRetro.current
|
||||
val dirPicker = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.OpenDocumentTree(),
|
||||
) { uri -> uri?.let { vm.setRecordDir(it) } }
|
||||
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(c.surface)
|
||||
.padding(6.dp)
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val recording = vm.masterRecording
|
||||
// Arm/record. With no folder yet, the first tap opens the picker instead.
|
||||
RetroButton(
|
||||
if (recording) "● REC" else "● ARM",
|
||||
active = vm.recordArmed && !recording,
|
||||
danger = recording,
|
||||
onClick = { if (vm.recordDirUri == null) dirPicker.launch(null) else vm.toggleRecordArm() },
|
||||
)
|
||||
RetroDropdown(
|
||||
label = "TAIL",
|
||||
options = listOf(0, 1, 2, 3, 4),
|
||||
selected = vm.recordTailBars,
|
||||
optionLabel = { "$it bar" + if (it == 1) "" else "s" },
|
||||
onSelect = { vm.updateRecordTailBars(it) },
|
||||
)
|
||||
RetroButton(
|
||||
"DIR:" + (vm.recordDirName ?: "—"),
|
||||
onClick = { dirPicker.launch(null) },
|
||||
)
|
||||
val status = when {
|
||||
recording -> "recording…"
|
||||
vm.recordDirUri == null -> "pick a folder to record"
|
||||
vm.recordArmed -> "armed — starts on play"
|
||||
vm.lastRecordingName != null -> "saved ${vm.lastRecordingName}"
|
||||
else -> "master → WAV"
|
||||
}
|
||||
Text(
|
||||
status, color = c.textDim, fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp, maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChannelStrip(vm: AppViewModel, channel: MixerChannel, modifier: Modifier) {
|
||||
val c = LocalRetro.current
|
||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // subscribe so faders/mute/solo/routing refresh
|
||||
|
||||
Column(
|
||||
modifier.background(c.surface).padding(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text("CH ${channel.index + 1}", color = c.accent,
|
||||
fontFamily = FontFamily.Monospace, fontSize = 13.sp)
|
||||
|
||||
if (isLandscape()) {
|
||||
// Landscape: two columns per bus — routing on the left, level controls on
|
||||
// the right — so the fader + LIM/mute/solo are visible without scrolling
|
||||
// in the short height. The routing column scrolls if a device is extra
|
||||
// short; the level column never does.
|
||||
Row(Modifier.weight(1f).fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Column(
|
||||
Modifier.weight(1f).fillMaxHeight().verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) { ChannelRouting(vm, channel) }
|
||||
Column(
|
||||
Modifier.weight(1f).fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) { ChannelLevels(vm, channel) }
|
||||
}
|
||||
} else {
|
||||
// Portrait: one column, the fader filling the leftover height.
|
||||
ChannelRouting(vm, channel)
|
||||
ChannelLevels(vm, channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Instrument + FX + MIDI-channel selectors for a mixer [channel]. */
|
||||
@Composable
|
||||
private fun ColumnScope.ChannelRouting(vm: AppViewModel, channel: MixerChannel) {
|
||||
val c = LocalRetro.current
|
||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // defeat strong-skipping so routing refreshes
|
||||
val toolbox = vm.project.toolbox
|
||||
val instrumentSlots = toolbox.filter { it.type?.kind == ToolboxKind.INSTRUMENT }
|
||||
val effectSlots = toolbox.filter { it.type?.kind == ToolboxKind.EFFECT }
|
||||
|
||||
SectionLabel("Instrument")
|
||||
RetroDropdown(
|
||||
label = "",
|
||||
options = listOf(-1) + instrumentSlots.map { it.index },
|
||||
selected = channel.instrumentSlot,
|
||||
optionLabel = { idx -> if (idx < 0) "—" else "${idx + 1}:${toolbox[idx].name}" },
|
||||
onSelect = { channel.instrumentSlot = it; vm.bumpForToolbar() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
SectionLabel("FX 1-4")
|
||||
for (fx in 0 until Mixer.FX_SLOTS) {
|
||||
RetroDropdown(
|
||||
label = "",
|
||||
options = listOf(-1) + effectSlots.map { it.index },
|
||||
selected = channel.fxSlots[fx],
|
||||
optionLabel = { idx -> if (idx < 0) "—" else "${idx + 1}:${toolbox[idx].name}" },
|
||||
onSelect = { channel.fxSlots[fx] = it; vm.rebuildAudioRouting() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
SectionLabel("MIDI Ch")
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
RetroButton("-") { channel.midiChannel = (channel.midiChannel - 1).coerceAtLeast(0); vm.bumpForToolbar() }
|
||||
Text(
|
||||
"${channel.midiChannel}", color = c.text,
|
||||
fontFamily = FontFamily.Monospace, fontSize = 12.sp,
|
||||
textAlign = TextAlign.Center, maxLines = 1,
|
||||
modifier = Modifier.width(28.dp),
|
||||
)
|
||||
RetroButton("+") { channel.midiChannel = (channel.midiChannel + 1).coerceAtMost(16); vm.bumpForToolbar() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Volume fader + limiter + mute/solo for a mixer [channel]. The fader fills the
|
||||
* leftover height of its column. */
|
||||
@Composable
|
||||
private fun ColumnScope.ChannelLevels(vm: AppViewModel, channel: MixerChannel) {
|
||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // defeat strong-skipping so LIM/mute/solo refresh
|
||||
SectionLabel("Volume")
|
||||
VerticalFader(
|
||||
value = channel.volume,
|
||||
default = Mixer.DEFAULT_VOLUME,
|
||||
onValueChange = { channel.volume = it; vm.bumpForToolbar() },
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
)
|
||||
// Per-bus soft-clip limiter, sitting above mute/solo.
|
||||
RetroButton("LIM", active = channel.limiter, modifier = Modifier.fillMaxWidth()) {
|
||||
channel.limiter = !channel.limiter; vm.bumpForToolbar()
|
||||
}
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
RetroButton("M", active = channel.mute, modifier = Modifier.weight(1f)) {
|
||||
channel.mute = !channel.mute; vm.bumpForToolbar()
|
||||
}
|
||||
RetroButton("S", active = channel.solo, modifier = Modifier.weight(1f)) {
|
||||
channel.solo = !channel.solo; vm.bumpForToolbar()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A vertical volume fader: fill grows from the bottom; tap or drag to set.
|
||||
* Snaps to the [default] level within a small band so returning to the standard
|
||||
* volume is easy to hit. */
|
||||
@Composable
|
||||
private fun VerticalFader(value: Float, default: Float, onValueChange: (Float) -> Unit, modifier: Modifier) {
|
||||
val c = LocalRetro.current
|
||||
Box(
|
||||
modifier
|
||||
.border(1.dp, c.grid, RectangleShape)
|
||||
.background(c.background)
|
||||
.pointerInput(default) {
|
||||
detectTapGestures { off -> onValueChange(snapDefault(1f - off.y / size.height, default)) }
|
||||
}
|
||||
.pointerInput(default) {
|
||||
detectDragGestures { change, _ ->
|
||||
change.consume()
|
||||
onValueChange(snapDefault(1f - change.position.y / size.height, default))
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.BottomCenter,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(value.coerceIn(0.001f, 1f))
|
||||
.background(c.accent.copy(alpha = 0.30f)),
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
Box(Modifier.fillMaxWidth().height(3.dp).background(c.accent)) // thumb
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Snap the fader to the [default] level when the raw value lands within a small
|
||||
* band around it, so returning to the standard volume is easy. */
|
||||
private fun snapDefault(raw: Float, default: Float): Float {
|
||||
val v = raw.coerceIn(0f, 1f)
|
||||
return if (kotlin.math.abs(v - default) < SNAP_BAND) default else v
|
||||
}
|
||||
|
||||
private const val SNAP_BAND = 0.03f
|
||||
@@ -0,0 +1,658 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.ui.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.AudioDeviceInfo
|
||||
import android.media.AudioManager
|
||||
import android.view.KeyEvent
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material.icons.filled.Piano
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.ContentPaste
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Keyboard
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.SportsEsports
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.FileProvider
|
||||
import space.rcmd.android.sizzle.input.GamepadChord
|
||||
import space.rcmd.android.sizzle.input.GamepadInput
|
||||
import space.rcmd.android.sizzle.input.InputSource
|
||||
import space.rcmd.android.sizzle.ui.AppViewModel
|
||||
|
||||
/**
|
||||
* The fourth tab, built from STANDARD Material 3 components (Cards, Buttons,
|
||||
* ListItems, OutlinedTextField, FilterChips) rather than the bespoke retro grid,
|
||||
* for familiar and accessible interaction. Functionality is unchanged:
|
||||
* project save/load, theme selection + export, gamepad bindings, and a
|
||||
* searchable / collapsible MIDI binding list.
|
||||
*/
|
||||
private enum class SettingsSection { MAIN, GAMEPAD, KEYBOARD, MIDI }
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(vm: AppViewModel) {
|
||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision
|
||||
var section by remember { mutableStateOf(SettingsSection.MAIN) }
|
||||
|
||||
// Back returns from a binding subsection to the Settings home before the
|
||||
// app-level handler moves to the previous tab.
|
||||
BackHandler(enabled = section != SettingsSection.MAIN) { section = SettingsSection.MAIN }
|
||||
|
||||
when (section) {
|
||||
// Top level stays light: the heavy binding lists live in their own
|
||||
// subsections, so they only compose when the user actually opens them.
|
||||
SettingsSection.MAIN -> LazyColumn(
|
||||
Modifier.fillMaxSize().padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
item { PanicButton(vm) }
|
||||
item { ProjectCard(vm) }
|
||||
item { AudioDevicesCard(vm) }
|
||||
item { ThemeCard(vm) }
|
||||
item {
|
||||
NavCard("Gamepad Bindings", "Rebind buttons & combos", Icons.Filled.SportsEsports) {
|
||||
section = SettingsSection.GAMEPAD
|
||||
}
|
||||
}
|
||||
item {
|
||||
NavCard("Keyboard Hotkeys", "Rebind keyboard keys", Icons.Filled.Keyboard) {
|
||||
section = SettingsSection.KEYBOARD
|
||||
}
|
||||
}
|
||||
item {
|
||||
NavCard("MIDI Bindings", "MIDI-learn controls", Icons.Filled.Piano) {
|
||||
section = SettingsSection.MIDI
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsSection.GAMEPAD -> LazyColumn(
|
||||
Modifier.fillMaxSize().padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
item { SubHeader("Gamepad Bindings") { section = SettingsSection.MAIN } }
|
||||
item {
|
||||
Text(
|
||||
"Tap Learn, then press a control — or hold one and press another to " +
|
||||
"bind a combo (e.g. A + Up). Buttons, the D-pad and the analog " +
|
||||
"sticks can all be used, alone or as the modifier. A control used " +
|
||||
"only in combos can have its own binding cleared to make it a pure modifier.",
|
||||
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
items(BIND_MODULES.values.flatten()) { action ->
|
||||
BindingRow(vm, action, InputSource.GAMEPAD)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
|
||||
SettingsSection.KEYBOARD -> LazyColumn(
|
||||
Modifier.fillMaxSize().padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
item { SubHeader("Keyboard Hotkeys") { section = SettingsSection.MAIN } }
|
||||
item {
|
||||
Text(
|
||||
"Tap Learn, then press a key to bind it. The two-octave typing " +
|
||||
"piano (Z–M, Q–U) is fixed and unaffected.",
|
||||
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
items(KEYBOARD_MODULES.values.flatten()) { action ->
|
||||
BindingRow(vm, action, InputSource.KEYBOARD)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
|
||||
SettingsSection.MIDI -> LazyColumn(
|
||||
Modifier.fillMaxSize().padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
item { SubHeader("MIDI Bindings") { section = SettingsSection.MAIN } }
|
||||
item { MidiBindingSections(vm) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Header row with a back arrow for a settings subsection. */
|
||||
@Composable
|
||||
private fun SubHeader(title: String, onBack: () -> Unit) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, contentDescription = "Back") }
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
|
||||
/** A tappable card that navigates into a settings subsection. */
|
||||
@Composable
|
||||
private fun NavCard(title: String, subtitle: String, icon: androidx.compose.ui.graphics.vector.ImageVector, onClick: () -> Unit) {
|
||||
ElevatedCard(Modifier.fillMaxWidth().clickable(onClick = onClick)) {
|
||||
ListItem(
|
||||
leadingContent = { Icon(icon, null) },
|
||||
headlineContent = { Text(title) },
|
||||
supportingContent = { Text(subtitle) },
|
||||
trailingContent = { Icon(Icons.Filled.ChevronRight, null) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- top-level cards
|
||||
|
||||
/** Prominent, always-at-the-top MIDI panic: immediately silences every sounding
|
||||
* or stuck note (sequencer, live, audition). */
|
||||
@Composable
|
||||
private fun PanicButton(vm: AppViewModel) {
|
||||
Button(
|
||||
onClick = { vm.midiPanic() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError,
|
||||
),
|
||||
) {
|
||||
Text("MIDI PANIC — ALL NOTES OFF")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProjectCard(vm: AppViewModel) {
|
||||
val context = LocalContext.current
|
||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // refresh the list after save/delete
|
||||
val songs = vm.songNames()
|
||||
|
||||
var selectedName by remember { mutableStateOf<String?>(null) }
|
||||
// Show this session's explicit pick, else the loaded project (its name survives
|
||||
// restarts via the autosave), else the first saved song.
|
||||
val current = selectedName?.takeIf { it in songs }
|
||||
?: vm.songName.takeIf { it in songs }
|
||||
?: songs.firstOrNull()
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
var showSave by remember { mutableStateOf(false) }
|
||||
var confirmDelete by remember { mutableStateOf<String?>(null) }
|
||||
var confirmNew by remember { mutableStateOf(false) }
|
||||
var importError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// Import a .sng (desktop interchange) into the current project. Read/parse
|
||||
// failures surface as a popup instead of doing nothing.
|
||||
val importer = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
val bytes = runCatching {
|
||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
}.getOrNull()
|
||||
when {
|
||||
bytes == null -> importError = "Couldn't read the selected file."
|
||||
!vm.importSng(String(bytes, Charsets.UTF_8)) -> importError =
|
||||
"Couldn't import this file. It doesn't look like a valid .sng song."
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard("Project", subtitle = "Full projects saved on this device; .sng for desktop interchange") {
|
||||
// On-device full-project browser: the load dropdown sits ABOVE the action
|
||||
// buttons (save / new / delete) so the selector is clear of them.
|
||||
Box {
|
||||
OutlinedButton(onClick = { menuOpen = true }) { Text(current ?: "No projects", maxLines = 1) }
|
||||
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
|
||||
if (songs.isEmpty()) {
|
||||
DropdownMenuItem(text = { Text("No saved projects") }, onClick = { menuOpen = false }, enabled = false)
|
||||
} else {
|
||||
songs.forEach { s ->
|
||||
DropdownMenuItem(text = { Text(s) }, onClick = {
|
||||
vm.loadSong(s); selectedName = s; menuOpen = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Button(onClick = { showSave = true }) { Text("Save") }
|
||||
OutlinedButton(onClick = { confirmNew = true }) { Text("New") }
|
||||
if (current != null) {
|
||||
Button(
|
||||
onClick = { confirmDelete = current },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError,
|
||||
),
|
||||
) { Text("Delete") }
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"Full projects keep mixer, FX, instruments and sample assignments.",
|
||||
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
// Desktop interchange: .sng is only exported / imported, never the library.
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(onClick = { shareSongFile(context, vm.writeSngExport()) }) {
|
||||
Icon(Icons.Filled.ContentCopy, null, Modifier.padding(end = 6.dp))
|
||||
Text("Export .sng")
|
||||
}
|
||||
OutlinedButton(onClick = { importer.launch(arrayOf("*/*")) }) {
|
||||
Icon(Icons.Filled.ContentPaste, null, Modifier.padding(end = 6.dp))
|
||||
Text("Import .sng")
|
||||
}
|
||||
}
|
||||
Text(
|
||||
".sng carries notes/blocks/arrangement only (desktop-compatible).",
|
||||
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
if (showSave) {
|
||||
var name by remember { mutableStateOf(vm.songName.ifBlank { "untitled" }) }
|
||||
AlertDialog(
|
||||
onDismissRequest = { showSave = false },
|
||||
title = { Text("Save project") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = name, onValueChange = { name = it }, singleLine = true,
|
||||
label = { Text("Name") },
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
if (name.isNotBlank()) { vm.saveSong(name.trim()); selectedName = name.trim() }
|
||||
showSave = false
|
||||
}) { Text("Save") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { showSave = false }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
importError?.let { msg ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { importError = null },
|
||||
title = { Text("Import failed") },
|
||||
text = { Text(msg) },
|
||||
confirmButton = { TextButton(onClick = { importError = null }) { Text("OK") } },
|
||||
)
|
||||
}
|
||||
|
||||
if (confirmNew) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmNew = false },
|
||||
title = { Text("New project") },
|
||||
text = { Text("Start a clean, empty project? Any unsaved changes to the current song will be lost.") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
vm.newProject(); selectedName = null; confirmNew = false
|
||||
}) { Text("New project") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { confirmNew = false }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
confirmDelete?.let { name ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmDelete = null },
|
||||
title = { Text("Delete project") },
|
||||
text = { Text("Delete \"$name\"? This cannot be undone.") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
vm.deleteSong(name); selectedName = null; confirmDelete = null
|
||||
}) { Text("Delete") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { confirmDelete = null }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Launch the system share sheet for an exported .sng file. */
|
||||
private fun shareSongFile(context: Context, file: java.io.File) {
|
||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||
val send = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(Intent.createChooser(send, "Share song"))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AudioDevicesCard(vm: AppViewModel) {
|
||||
SettingsCard("Audio Devices", subtitle = "Route playback / recording to USB or built-in") {
|
||||
val context = LocalContext.current
|
||||
val am = remember { context.getSystemService(Context.AUDIO_SERVICE) as AudioManager }
|
||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision
|
||||
val outputs = am.getDevices(AudioManager.GET_DEVICES_OUTPUTS).toList()
|
||||
val inputs = am.getDevices(AudioManager.GET_DEVICES_INPUTS).toList()
|
||||
DeviceRow("Output", outputs, vm.outputDeviceId) { vm.setAudioOutput(it) }
|
||||
HorizontalDivider()
|
||||
DeviceRow("Input", inputs, vm.inputDeviceId) { vm.setAudioInput(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThemeCard(vm: AppViewModel) {
|
||||
val context = LocalContext.current
|
||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // refresh the list after import/delete
|
||||
val themes = vm.themeNames()
|
||||
val current = vm.palette.name
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
var confirmDelete by remember { mutableStateOf<String?>(null) }
|
||||
var importError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// Import a .szt theme (adds it to the library and applies it).
|
||||
val importer = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
val bytes = runCatching {
|
||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
}.getOrNull()
|
||||
when {
|
||||
bytes == null -> importError = "Couldn't read the selected file."
|
||||
!vm.importTheme(bytes) -> importError =
|
||||
"Couldn't import this file. It doesn't look like a valid .szt theme."
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard("Color Theme", subtitle = "Pick, import, export or delete a palette") {
|
||||
// Selector dropdown + delete, mirroring the Project card.
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box {
|
||||
OutlinedButton(onClick = { menuOpen = true }) { Text(current, maxLines = 1) }
|
||||
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
|
||||
themes.forEach { name ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(name + if (vm.isStockTheme(name)) " (stock)" else "") },
|
||||
onClick = { vm.selectThemeByName(name); menuOpen = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Delete only offered for user themes — stock themes are protected.
|
||||
if (!vm.isStockTheme(current)) {
|
||||
Button(
|
||||
onClick = { confirmDelete = current },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError,
|
||||
),
|
||||
) { Text("Delete") }
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(onClick = { shareThemeFile(context, vm.themeExportFile()) }) {
|
||||
Icon(Icons.Filled.ContentCopy, null, Modifier.padding(end = 6.dp))
|
||||
Text("Export .szt")
|
||||
}
|
||||
OutlinedButton(onClick = { importer.launch(arrayOf("*/*")) }) {
|
||||
Icon(Icons.Filled.ContentPaste, null, Modifier.padding(end = 6.dp))
|
||||
Text("Import .szt")
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"Stock themes can't be deleted; imported .szt themes appear in the list.",
|
||||
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
importError?.let { msg ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { importError = null },
|
||||
title = { Text("Import failed") },
|
||||
text = { Text(msg) },
|
||||
confirmButton = { TextButton(onClick = { importError = null }) { Text("OK") } },
|
||||
)
|
||||
}
|
||||
|
||||
confirmDelete?.let { name ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmDelete = null },
|
||||
title = { Text("Delete theme") },
|
||||
text = { Text("Delete \"$name\"? This cannot be undone.") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { vm.deleteTheme(name); confirmDelete = null }) { Text("Delete") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { confirmDelete = null }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Launch the system share sheet for an exported .szt theme file. */
|
||||
private fun shareThemeFile(context: Context, file: java.io.File) {
|
||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||
val send = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(Intent.createChooser(send, "Share theme"))
|
||||
}
|
||||
|
||||
/** A bindable action: user-facing label + the action id the input handlers use. */
|
||||
private data class BindAction(val label: String, val id: String)
|
||||
|
||||
private val BIND_MODULES: Map<String, List<BindAction>> = linkedMapOf(
|
||||
"Transport" to listOf(
|
||||
BindAction("Play / Pause", "playPause"), BindAction("Stop", "stop"), BindAction("Toggle Loop", "loop"),
|
||||
),
|
||||
"Navigation" to listOf(
|
||||
BindAction("Cursor Up", "up"), BindAction("Cursor Down", "down"),
|
||||
BindAction("Cursor Left", "left"), BindAction("Cursor Right", "right"),
|
||||
),
|
||||
"Editing" to listOf(
|
||||
BindAction("Value +", "increment"), BindAction("Value -", "decrement"), BindAction("Clear Cell", "clear"),
|
||||
),
|
||||
"Tabs" to listOf(BindAction("Next Tab", "nextTab"), BindAction("Prev Tab", "prevTab")),
|
||||
)
|
||||
|
||||
/** The keyboard supports the shared actions plus a few keyboard-only ones
|
||||
* (column movement, note-off, and octave shifting of the typing piano). */
|
||||
private val KEYBOARD_MODULES: Map<String, List<BindAction>> = linkedMapOf(
|
||||
"Transport" to listOf(
|
||||
BindAction("Play / Pause", "playPause"), BindAction("Stop", "stop"), BindAction("Toggle Loop", "loop"),
|
||||
),
|
||||
"Navigation" to listOf(
|
||||
BindAction("Cursor Up", "up"), BindAction("Cursor Down", "down"),
|
||||
BindAction("Cursor Left", "left"), BindAction("Cursor Right", "right"),
|
||||
),
|
||||
"Editing" to listOf(
|
||||
BindAction("Value +", "increment"), BindAction("Value -", "decrement"),
|
||||
BindAction("Clear Cell", "clear"), BindAction("Note Off", "noteOff"),
|
||||
),
|
||||
"Columns" to listOf(
|
||||
BindAction("Next Column", "nextColumn"), BindAction("Prev Column", "prevColumn"),
|
||||
),
|
||||
"Octave" to listOf(
|
||||
BindAction("Octave Down", "octaveDown"), BindAction("Octave Up", "octaveUp"),
|
||||
),
|
||||
"Tabs" to listOf(BindAction("Next Tab", "nextTab"), BindAction("Prev Tab", "prevTab")),
|
||||
)
|
||||
|
||||
/** One action row with its current binding, a Learn toggle, and a clear button. */
|
||||
@Composable
|
||||
private fun BindingRow(vm: AppViewModel, action: BindAction, source: InputSource) {
|
||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // refresh when a binding changes
|
||||
val listening = vm.learnTarget == action.id
|
||||
val label: String
|
||||
val bound: Boolean
|
||||
when (source) {
|
||||
InputSource.MIDI -> {
|
||||
val code = vm.midiBindingFor(action.id)
|
||||
label = code?.let { "CC $it" } ?: "unbound"; bound = code != null
|
||||
}
|
||||
InputSource.GAMEPAD -> {
|
||||
val chord = vm.gamepadBindingFor(action.id)
|
||||
label = chord?.let { gamepadChordLabel(it) } ?: "unbound"; bound = chord != null
|
||||
}
|
||||
else -> {
|
||||
val code = vm.keyboardBindingFor(action.id)
|
||||
label = code?.let { keyLabel(it) } ?: "unbound"; bound = code != null
|
||||
}
|
||||
}
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text(action.label) },
|
||||
supportingContent = { Text(label, style = MaterialTheme.typography.bodySmall) },
|
||||
trailingContent = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
TextButton(onClick = { if (listening) vm.cancelLearn() else vm.beginLearn(action.id, source) }) {
|
||||
Text(if (listening) "Listening…" else "Learn")
|
||||
}
|
||||
if (bound) {
|
||||
TextButton(onClick = { vm.clearBinding(action.id, source) }) { Text("✕") }
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
|
||||
/** Human-readable name for a physical keyboard key ("PAGE UP", "TAB", "L"). */
|
||||
private fun keyLabel(code: Int): String =
|
||||
KeyEvent.keyCodeToString(code).removePrefix("KEYCODE_").replace('_', ' ')
|
||||
|
||||
/** Short gamepad control name ("A", "L1", "UP", "LS↑") for a button/D-pad/stick code. */
|
||||
private fun gamepadButtonLabel(code: Int): String = GamepadInput.controlLabel(code)
|
||||
|
||||
/** "A + UP" for a chord, or just "A" for a plain button binding. */
|
||||
private fun gamepadChordLabel(chord: GamepadChord): String =
|
||||
if (chord.modifier == GamepadChord.NONE) gamepadButtonLabel(chord.code)
|
||||
else "${gamepadButtonLabel(chord.modifier)} + ${gamepadButtonLabel(chord.code)}"
|
||||
|
||||
/** A label + dropdown that routes audio to a chosen device (or the default). */
|
||||
@Composable
|
||||
private fun DeviceRow(
|
||||
label: String,
|
||||
devices: List<AudioDeviceInfo>,
|
||||
selectedId: Int?,
|
||||
onSelect: (AudioDeviceInfo?) -> Unit,
|
||||
) {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
val current = devices.firstOrNull { it.id == selectedId }?.let { deviceLabel(it) } ?: "Default"
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium)
|
||||
Box {
|
||||
OutlinedButton(onClick = { open = true }) { Text(current, maxLines = 1) }
|
||||
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
||||
DropdownMenuItem(text = { Text("Default") }, onClick = { onSelect(null); open = false })
|
||||
devices.forEach { d ->
|
||||
DropdownMenuItem(text = { Text(deviceLabel(d)) }, onClick = { onSelect(d); open = false })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun deviceLabel(d: AudioDeviceInfo): String {
|
||||
val kind = when (d.type) {
|
||||
AudioDeviceInfo.TYPE_USB_DEVICE, AudioDeviceInfo.TYPE_USB_HEADSET, AudioDeviceInfo.TYPE_USB_ACCESSORY -> "USB"
|
||||
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> "Speaker"
|
||||
AudioDeviceInfo.TYPE_BUILTIN_MIC -> "Mic"
|
||||
AudioDeviceInfo.TYPE_WIRED_HEADPHONES, AudioDeviceInfo.TYPE_WIRED_HEADSET -> "Wired"
|
||||
AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "BT"
|
||||
else -> "Dev"
|
||||
}
|
||||
return "${d.productName} · $kind"
|
||||
}
|
||||
|
||||
/** A titled Material card wrapper used for every settings group. */
|
||||
@Composable
|
||||
private fun SettingsCard(
|
||||
title: String,
|
||||
subtitle: String? = null,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
ElevatedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
subtitle?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Searchable + collapsible MIDI binding list. Each module is a Card whose header
|
||||
* toggles expansion; its actions are real [BindingRow]s whose Learn button arms
|
||||
* MIDI-learn — the next incoming CC is stored into the live MIDI binding map.
|
||||
*/
|
||||
@Composable
|
||||
private fun MidiBindingSections(vm: AppViewModel) {
|
||||
var query by remember { mutableStateOf("") }
|
||||
var expanded by remember { mutableStateOf<String?>("Transport") }
|
||||
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
singleLine = true,
|
||||
leadingIcon = { Icon(Icons.Filled.Search, null) },
|
||||
placeholder = { Text("Search controls…") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
BIND_MODULES.forEach { (module, actions) ->
|
||||
val matches = actions.filter { it.label.contains(query, ignoreCase = true) }
|
||||
if (query.isNotEmpty() && matches.isEmpty()) return@forEach
|
||||
val isOpen = expanded == module || query.isNotEmpty()
|
||||
|
||||
Card(Modifier.fillMaxWidth().padding(top = 8.dp)) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { expanded = if (expanded == module) null else module }
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(module, style = MaterialTheme.typography.titleSmall)
|
||||
Icon(if (isOpen) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, null)
|
||||
}
|
||||
if (isOpen) {
|
||||
matches.forEach { action -> BindingRow(vm, action, InputSource.MIDI) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
170
app/src/main/java/space/rcmd/android/sizzle/ui/theme/Theme.kt
Normal file
170
app/src/main/java/space/rcmd/android/sizzle/ui/theme/Theme.kt
Normal file
@@ -0,0 +1,170 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.view.WindowCompat
|
||||
|
||||
/**
|
||||
* The retro look-and-feel. Two ideas drive everything:
|
||||
* 1. ONE monospace font family for the entire UI (that's the "tracker" feel and
|
||||
* it makes the grid columns line up perfectly).
|
||||
* 2. A small, named [RetroPalette] so colour *themes* can be swapped and
|
||||
* imported/exported from the Settings tab without touching any screen code.
|
||||
*
|
||||
* The palette is exposed through a CompositionLocal ([LocalRetro]) so any
|
||||
* composable can read theme colours with `LocalRetro.current`.
|
||||
*/
|
||||
data class RetroPalette(
|
||||
val name: String,
|
||||
val background: Color,
|
||||
val surface: Color, // panel / toolbar background
|
||||
val grid: Color, // faint grid lines
|
||||
val text: Color, // default monospace text
|
||||
val textDim: Color, // secondary text
|
||||
val accent: Color, // cursor / primary highlight (amber)
|
||||
val beat: Color, // every-beat row tint
|
||||
val bar: Color, // every-bar row tint
|
||||
val playhead: Color, // moving playback line
|
||||
val danger: Color, // mute / destructive
|
||||
) {
|
||||
companion object {
|
||||
/** The built-in default: near-black with amber + green, classic demoscene. */
|
||||
val AMBER = RetroPalette(
|
||||
name = "Amber CRT",
|
||||
background = Color(0xFF0B0D0E),
|
||||
surface = Color(0xFF15181A),
|
||||
grid = Color(0xFF23282B),
|
||||
text = Color(0xFFCFE8D8),
|
||||
textDim = Color(0xFF6E7C74),
|
||||
accent = Color(0xFFF2C14E),
|
||||
beat = Color(0xFF141C1A),
|
||||
bar = Color(0xFF1E2A20),
|
||||
playhead = Color(0xFF7CFC9A),
|
||||
danger = Color(0xFFE05A4B),
|
||||
)
|
||||
|
||||
/** A second built-in so the theme picker has something to switch to. */
|
||||
val ICE = RetroPalette(
|
||||
name = "Ice Terminal",
|
||||
background = Color(0xFF07090F),
|
||||
surface = Color(0xFF10151F),
|
||||
grid = Color(0xFF1E2633),
|
||||
text = Color(0xFFCFE0FF),
|
||||
textDim = Color(0xFF63708A),
|
||||
accent = Color(0xFF62B6FF),
|
||||
beat = Color(0xFF121A26),
|
||||
bar = Color(0xFF1B2740),
|
||||
playhead = Color(0xFF8AF0FF),
|
||||
danger = Color(0xFFE05A7A),
|
||||
)
|
||||
|
||||
val ALL = listOf(AMBER, ICE)
|
||||
}
|
||||
}
|
||||
|
||||
val LocalRetro = staticCompositionLocalOf { RetroPalette.AMBER }
|
||||
|
||||
/** Monospace typography used everywhere — no other font is loaded. */
|
||||
private val Mono = FontFamily.Monospace
|
||||
private val retroTypography = Typography(
|
||||
bodyLarge = TextStyle(fontFamily = Mono, fontSize = 14.sp, fontWeight = FontWeight.Normal),
|
||||
bodyMedium = TextStyle(fontFamily = Mono, fontSize = 12.sp),
|
||||
bodySmall = TextStyle(fontFamily = Mono, fontSize = 10.sp),
|
||||
labelLarge = TextStyle(fontFamily = Mono, fontSize = 13.sp, fontWeight = FontWeight.Bold),
|
||||
titleMedium = TextStyle(fontFamily = Mono, fontSize = 15.sp, fontWeight = FontWeight.Bold),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun SizzleTheme(
|
||||
palette: RetroPalette = RetroPalette.AMBER,
|
||||
@Suppress("UNUSED_PARAMETER") darkTheme: Boolean = isSystemInDarkTheme(), // follows the palette, not the system
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
// Whether this palette reads as a light theme drives which Material base scheme
|
||||
// we start from. That matters because Material components (Card, ElevatedCard,
|
||||
// ListItem, DropdownMenu…) pull their container/variant colours from roles we
|
||||
// don't override — and the dark defaults look wrong (dark cards, muddy dividers)
|
||||
// on a bright palette. Starting from the matching base keeps those defaults sane;
|
||||
// then we paint the palette over every role components actually read.
|
||||
val isLight = palette.background.luminance() > 0.5f
|
||||
|
||||
// Text/icon colour to sit *on top of* the accent and danger fills — chosen for
|
||||
// contrast against the fill itself, not the page, so buttons stay legible on any
|
||||
// palette (a light accent gets near-black ink, a dark accent gets near-white).
|
||||
val onAccent = if (palette.accent.luminance() > 0.5f) Color(0xFF0B0D0E) else Color(0xFFF7F7F7)
|
||||
val onDanger = if (palette.danger.luminance() > 0.5f) Color(0xFF0B0D0E) else Color(0xFFF7F7F7)
|
||||
|
||||
val base = if (isLight) lightColorScheme() else darkColorScheme()
|
||||
val scheme = base.copy(
|
||||
primary = palette.accent,
|
||||
onPrimary = onAccent,
|
||||
primaryContainer = palette.accent,
|
||||
onPrimaryContainer = onAccent,
|
||||
secondary = palette.accent,
|
||||
onSecondary = onAccent,
|
||||
background = palette.background,
|
||||
onBackground = palette.text,
|
||||
surface = palette.surface,
|
||||
onSurface = palette.text,
|
||||
// Card / sheet / menu container roles all map to the panel colour so every
|
||||
// surface matches the toolbar instead of falling back to Material greys.
|
||||
surfaceVariant = palette.surface,
|
||||
onSurfaceVariant = palette.textDim,
|
||||
surfaceContainerLowest = palette.surface,
|
||||
surfaceContainerLow = palette.surface,
|
||||
surfaceContainer = palette.surface,
|
||||
surfaceContainerHigh = palette.surface,
|
||||
surfaceContainerHighest = palette.surface,
|
||||
surfaceBright = palette.surface,
|
||||
surfaceDim = palette.surface,
|
||||
outline = palette.grid,
|
||||
outlineVariant = palette.grid,
|
||||
error = palette.danger,
|
||||
onError = onDanger,
|
||||
errorContainer = palette.danger,
|
||||
onErrorContainer = onDanger,
|
||||
// Disable Material's automatic elevation tint so cards keep the exact panel
|
||||
// colour rather than being tinted toward the primary at higher elevations.
|
||||
surfaceTint = palette.surface,
|
||||
)
|
||||
|
||||
// Tint the system navigation bar to match the app's bottom tab bar (also
|
||||
// `surface`) so the gesture handle below it looks seamless — and re-tint when the
|
||||
// palette changes. The status bar keeps the window background for the same reason.
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
val window = (view.context as Activity).window
|
||||
window.navigationBarColor = palette.surface.toArgb()
|
||||
window.statusBarColor = palette.background.toArgb()
|
||||
val lightSurface = palette.surface.luminance() > 0.5f
|
||||
WindowCompat.getInsetsController(window, view).apply {
|
||||
isAppearanceLightNavigationBars = lightSurface
|
||||
isAppearanceLightStatusBars = palette.background.luminance() > 0.5f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalRetro provides palette) {
|
||||
MaterialTheme(colorScheme = scheme, typography = retroTypography, content = content)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.ui.toolbox
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import space.rcmd.android.sizzle.model.DelayDivisions
|
||||
import space.rcmd.android.sizzle.model.ParamSpec
|
||||
import space.rcmd.android.sizzle.model.ToolboxSlot
|
||||
import space.rcmd.android.sizzle.ui.AppViewModel
|
||||
import space.rcmd.android.sizzle.ui.theme.LocalRetro
|
||||
|
||||
/**
|
||||
* The Tape Delay's custom editor: four tape heads side by side, each a vertical
|
||||
* tempo-synced division fader (hard-snapping to the [DelayDivisions] steps —
|
||||
* 1/1 … 1/16 plus dotted and triplet values) with an on/off toggle above it, then
|
||||
* the shared Feedback / Dry-Wet / Tape-Strength sliders below. Feedback is capped
|
||||
* below 1 in both the UI and the DSP, so it can never build a runaway loop.
|
||||
*/
|
||||
@Composable
|
||||
fun DelayEditor(vm: AppViewModel, slot: ToolboxSlot) {
|
||||
val c = LocalRetro.current
|
||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // subscribe so heads/params refresh
|
||||
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(
|
||||
"Tape heads — drag a fader to set its time; tap the number to switch it off/on:",
|
||||
color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 11.sp,
|
||||
)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
for (h in 0 until DelayDivisions.HEADS) {
|
||||
DelayHead(vm, slot, h, rev, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
// Common controls (left as plain sliders with sane defaults). Feedback's max
|
||||
// is the DSP's hard ceiling, so a positive-feedback runaway is impossible.
|
||||
ParamControl(vm, slot, ParamSpec("feedback", "Feedback", 0.4f, 0f, DelayDivisions.MAX_FEEDBACK))
|
||||
ParamControl(vm, slot, ParamSpec("drywet", "Dry/Wet", 0.35f, 0f, 1f))
|
||||
ParamControl(vm, slot, ParamSpec("tape", "Tape Strength", 0.3f, 0f, 1f))
|
||||
}
|
||||
}
|
||||
|
||||
/** One tape head: an on/off toggle, a vertical hard-snapping division fader, and
|
||||
* the current division label. Longer values sit at the top (taller fill). */
|
||||
@Composable
|
||||
private fun DelayHead(vm: AppViewModel, slot: ToolboxSlot, head: Int, rev: Int, modifier: Modifier) {
|
||||
@Suppress("UNUSED_PARAMETER") val ignored = rev // param only used to bust skipping
|
||||
val c = LocalRetro.current
|
||||
val on = slot.float("head${head}On", if (head == 0) 1f else 0f) >= 0.5f
|
||||
val steps = DelayDivisions.size
|
||||
val idx = slot.float("head${head}Div", DelayDivisions.DEFAULT.toFloat()).toInt().coerceIn(0, steps - 1)
|
||||
val border = if (on) c.accent else c.grid
|
||||
|
||||
Column(modifier, horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
// On/off toggle (the head number).
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(26.dp)
|
||||
.border(1.dp, border, RectangleShape)
|
||||
.background(if (on) c.accent.copy(alpha = 0.22f) else c.surface)
|
||||
.pointerInput(head, slot.index) {
|
||||
detectTapGestures { slot.set("head${head}On", if (on) 0f else 1f); vm.bumpForToolbar() }
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text("${head + 1}", color = if (on) c.accent else c.textDim,
|
||||
fontFamily = FontFamily.Monospace, fontSize = 13.sp)
|
||||
}
|
||||
|
||||
// Vertical fader: top = longest (1/1), bottom = shortest (1/16T). Tap or drag
|
||||
// to snap to a step.
|
||||
Canvas(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(150.dp)
|
||||
.background(c.surface)
|
||||
.border(1.dp, border, RectangleShape)
|
||||
.pointerInput(head, slot.index) {
|
||||
detectTapGestures { off ->
|
||||
val i = (off.y / size.height * steps).toInt().coerceIn(0, steps - 1)
|
||||
slot.set("head${head}Div", i.toFloat()); vm.bumpForToolbar()
|
||||
}
|
||||
}
|
||||
.pointerInput(head, slot.index) {
|
||||
detectDragGestures { change, _ ->
|
||||
change.consume()
|
||||
val i = (change.position.y / size.height * steps).toInt().coerceIn(0, steps - 1)
|
||||
slot.set("head${head}Div", i.toFloat()); vm.bumpForToolbar()
|
||||
}
|
||||
},
|
||||
) {
|
||||
val w = size.width
|
||||
val hgt = size.height
|
||||
val stepH = hgt / steps
|
||||
// Fill from the bottom up to the selected step: a longer division (higher
|
||||
// up) shows a taller fader.
|
||||
val fillTop = idx * stepH
|
||||
drawRect(
|
||||
(if (on) c.accent else c.grid).copy(alpha = 0.45f),
|
||||
Offset(0f, fillTop), Size(w, hgt - fillTop),
|
||||
)
|
||||
// Step dividers.
|
||||
for (s in 1 until steps) {
|
||||
drawLine(c.grid.copy(alpha = 0.4f), Offset(0f, s * stepH), Offset(w, s * stepH), strokeWidth = 1f)
|
||||
}
|
||||
// Highlight the selected step band.
|
||||
drawRect(
|
||||
(if (on) c.playhead else c.textDim).copy(alpha = 0.5f),
|
||||
Offset(0f, fillTop), Size(w, stepH),
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
DelayDivisions.label(idx),
|
||||
color = if (on) c.text else c.textDim,
|
||||
fontFamily = FontFamily.Monospace, fontSize = 10.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.ui.toolbox
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import space.rcmd.android.sizzle.model.ToolboxSlot
|
||||
import space.rcmd.android.sizzle.ui.AppViewModel
|
||||
import space.rcmd.android.sizzle.ui.components.RetroDropdown
|
||||
import space.rcmd.android.sizzle.ui.theme.LocalRetro
|
||||
|
||||
/**
|
||||
* A modulation target: a numeric parameter on another device the LFO can drive.
|
||||
* [slotIndex] < 0 represents "no target". [encoded] is the "slotIndex:paramKey"
|
||||
* form stored in the LFO slot; [label] is what the user sees.
|
||||
*/
|
||||
private data class LfoTarget(val slotIndex: Int, val key: String, val label: String) {
|
||||
val encoded: String get() = if (slotIndex < 0) "" else "$slotIndex:$key"
|
||||
}
|
||||
|
||||
/**
|
||||
* The LFO's target picker (rendered under its generated parameter list). It lists
|
||||
* every numeric parameter of every other occupied slot; choosing one wires the
|
||||
* LFO to modulate it. See [AppViewModel.setLfoTarget].
|
||||
*/
|
||||
@Composable
|
||||
fun LfoTargetPicker(vm: AppViewModel, lfo: ToolboxSlot) {
|
||||
val c = LocalRetro.current
|
||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision
|
||||
|
||||
val none = LfoTarget(-1, "", "— none —")
|
||||
val targets = buildList {
|
||||
add(none)
|
||||
vm.project.toolbox.forEachIndexed { idx, slot ->
|
||||
val type = slot.type
|
||||
if (idx == lfo.index || type == null) return@forEachIndexed
|
||||
for (spec in type.params) {
|
||||
if (spec.isEnum) continue // only continuous params are modulatable
|
||||
add(LfoTarget(idx, spec.key, "${idx + 1}:${slot.name} · ${spec.label}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
val current = lfo.string("target")
|
||||
val selected = targets.firstOrNull { it.encoded == current } ?: none
|
||||
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
RetroDropdown(
|
||||
label = "TARGET",
|
||||
options = targets,
|
||||
selected = selected,
|
||||
optionLabel = { it.label },
|
||||
onSelect = { vm.setLfoTarget(lfo, it.slotIndex, it.key) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Text(
|
||||
"The LFO takes over its target parameter while assigned.",
|
||||
color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 10.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.ui.toolbox
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.SliderDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.sp
|
||||
import space.rcmd.android.sizzle.model.ParamSpec
|
||||
import space.rcmd.android.sizzle.model.ToolboxSlot
|
||||
import space.rcmd.android.sizzle.ui.AppViewModel
|
||||
import space.rcmd.android.sizzle.ui.components.RetroDropdown
|
||||
import space.rcmd.android.sizzle.ui.theme.LocalRetro
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* One editable parameter row. Enumerated params ([ParamSpec.isEnum]) render as a
|
||||
* dropdown; numeric params render as a labelled slider. Reading and writing goes
|
||||
* through the slot's string-typed value map so it stays serialization-friendly.
|
||||
*/
|
||||
@Composable
|
||||
fun ParamControl(vm: AppViewModel, slot: ToolboxSlot, spec: ParamSpec) {
|
||||
val c = LocalRetro.current
|
||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // subscribe so sliders/dropdowns refresh
|
||||
if (spec.isEnum) {
|
||||
RetroDropdown(
|
||||
label = spec.label,
|
||||
options = spec.choices,
|
||||
selected = slot.string(spec.key, spec.choices.first()),
|
||||
onSelect = { slot.set(spec.key, it); vm.bumpForToolbar() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
} else {
|
||||
val value = slot.float(spec.key, spec.default)
|
||||
// Integer params (e.g. bit depth, semitones, octaves) snap to whole values:
|
||||
// the slider gets one discrete stop per integer, and writes rounded values.
|
||||
val intSteps = if (spec.isInt) ((spec.max - spec.min).toInt() - 1).coerceAtLeast(0) else 0
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Row {
|
||||
Text("${spec.label}: ", color = c.textDim,
|
||||
fontFamily = FontFamily.Monospace, fontSize = 11.sp)
|
||||
Text(if (spec.isInt) value.roundToInt().toString() else formatValue(value), color = c.text,
|
||||
fontFamily = FontFamily.Monospace, fontSize = 11.sp)
|
||||
}
|
||||
Slider(
|
||||
value = value.coerceIn(spec.min, spec.max),
|
||||
valueRange = spec.min..spec.max,
|
||||
steps = intSteps,
|
||||
onValueChange = {
|
||||
val v = if (spec.isInt) it.roundToInt().toFloat() else it
|
||||
slot.set(spec.key, v); vm.bumpForToolbar()
|
||||
},
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = c.accent, activeTrackColor = c.accent, inactiveTrackColor = c.grid,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatValue(v: Float): String =
|
||||
if (v == v.toInt().toFloat()) v.toInt().toString() else String.format("%.2f", v)
|
||||
@@ -0,0 +1,350 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.ui.toolbox
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import space.rcmd.android.sizzle.audio.SampleStore
|
||||
import space.rcmd.android.sizzle.model.Pitch
|
||||
import space.rcmd.android.sizzle.model.SamplerPads
|
||||
import space.rcmd.android.sizzle.model.ToolboxSlot
|
||||
import space.rcmd.android.sizzle.ui.AppViewModel
|
||||
import space.rcmd.android.sizzle.ui.components.RetroButton
|
||||
import space.rcmd.android.sizzle.ui.theme.LocalRetro
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* The Sampler's custom editor: a 16-pad drum-style bank plus a per-pad detail area.
|
||||
*
|
||||
* The bottom half is a 4x4 grid of pads mapped to consecutive notes from C2
|
||||
* ([SamplerPads]). Pressing a pad selects it AND auditions its sample the instant
|
||||
* it's touched (so pads are for both testing and picking which one to edit). The top
|
||||
* half acts on the selected pad: Import a WAV or Record to assign a sample, position
|
||||
* the two waveform markers to set the play slice, CLIP to destructively trim the
|
||||
* sample down to that slice, and CLEAR to empty just that pad (all no undo). Per-pad
|
||||
* volume lives in the fader bank. When the sequencer plays a pad's note the sample
|
||||
* fires at its natural pitch.
|
||||
*/
|
||||
@Composable
|
||||
fun SamplerEditor(vm: AppViewModel, slot: ToolboxSlot) {
|
||||
val c = LocalRetro.current
|
||||
val context = LocalContext.current
|
||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // subscribe: pads/waveform refresh
|
||||
|
||||
var pad by remember(slot.index) { mutableIntStateOf(0) }
|
||||
var importError by remember(slot.index) { mutableStateOf<String?>(null) }
|
||||
val sampleId = slot.string(SamplerPads.key(pad))
|
||||
val sliceStart = slot.float(SamplerPads.sliceStartKey(pad), 0f)
|
||||
val sliceEnd = slot.float(SamplerPads.sliceEndKey(pad), 1f)
|
||||
|
||||
// System file picker: assigns the chosen WAV to the currently-selected pad.
|
||||
// A read/decode failure reports a popup rather than silently doing nothing.
|
||||
val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
val bytes = runCatching {
|
||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
}.getOrNull()
|
||||
when {
|
||||
bytes == null -> importError = "Couldn't read the selected file."
|
||||
!vm.loadSampleInto(slot, pad, uri.toString(), bytes) -> importError =
|
||||
"Couldn't load this sample. Only uncompressed WAV files (8/16/24-bit " +
|
||||
"PCM or 32-bit float) are supported."
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
// ----- Selected pad + source buttons (act on the selected pad) -----
|
||||
Text(
|
||||
"Pad ${pad + 1} · ${Pitch.name(SamplerPads.noteFor(pad))}",
|
||||
color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 13.sp,
|
||||
)
|
||||
Row(
|
||||
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
RetroButton("IMPORT WAV") { picker.launch(arrayOf("audio/*")) }
|
||||
if (vm.isRecording) {
|
||||
RetroButton("■ STOP REC", active = true) { vm.stopRecordingInto(slot, pad) }
|
||||
} else {
|
||||
RetroButton("● RECORD") { vm.startRecording() }
|
||||
}
|
||||
if (sampleId.isNotBlank()) {
|
||||
// Destructively trim the sample to the current slice markers (no undo).
|
||||
RetroButton("✂ CLIP") { vm.trimSamplerPad(slot, pad) }
|
||||
// Peak-normalize the sample to full scale (no undo).
|
||||
RetroButton("NORMALIZE") { vm.normalizeSamplerPad(slot, pad) }
|
||||
// Clear just the selected pad's sample (replaces the old long-press).
|
||||
RetroButton("CLEAR", danger = true) { vm.clearSamplerPad(slot, pad) }
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Waveform + draggable slice markers for the selected pad -----
|
||||
val sample = SampleStore.get(sampleId)
|
||||
val peaks = remember(sampleId, sample?.data?.size) { sample?.let { computePeaks(it.data, 400) } }
|
||||
var dragging by remember { mutableIntStateOf(-1) }
|
||||
Canvas(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(110.dp)
|
||||
.background(c.surface)
|
||||
.pointerInput(sampleId, pad) {
|
||||
detectDragGestures(
|
||||
onDragStart = { off ->
|
||||
val frac = (off.x / size.width).coerceIn(0f, 1f)
|
||||
dragging = if (abs(frac - sliceStart) <= abs(frac - sliceEnd)) 0 else 1
|
||||
},
|
||||
onDrag = { change, _ ->
|
||||
change.consume()
|
||||
val frac = (change.position.x / size.width).coerceIn(0f, 1f)
|
||||
if (dragging == 0) slot.set(SamplerPads.sliceStartKey(pad), frac)
|
||||
else slot.set(SamplerPads.sliceEndKey(pad), frac)
|
||||
vm.bumpForToolbar()
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
val mid = h / 2f
|
||||
if (peaks != null) {
|
||||
val (mins, maxs) = peaks
|
||||
val bw = w / mins.size
|
||||
for (i in mins.indices) {
|
||||
val x = i * bw
|
||||
drawLine(c.text, Offset(x, mid - maxs[i] * mid), Offset(x, mid - mins[i] * mid), strokeWidth = 1f)
|
||||
}
|
||||
}
|
||||
val sx = sliceStart * w
|
||||
val ex = sliceEnd * w
|
||||
drawRect(c.accent.copy(alpha = 0.14f), Offset(sx, 0f), Size(ex - sx, h))
|
||||
drawLine(c.accent, Offset(sx, 0f), Offset(sx, h), strokeWidth = 3f)
|
||||
drawLine(c.playhead, Offset(ex, 0f), Offset(ex, h), strokeWidth = 3f)
|
||||
}
|
||||
Text(
|
||||
if (sample == null) "Pad empty — Import a WAV or Record to assign it."
|
||||
else "${sample.data.size} frames @ ${sample.sampleRate} Hz · drag the markers, then CLIP to trim",
|
||||
color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 10.sp,
|
||||
)
|
||||
|
||||
// ----- Per-pad output volume: 16 vertical sliders laid out 8 x 2 -----
|
||||
Text(
|
||||
"Pad volumes (drag a slider; tap selects that pad):",
|
||||
color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 11.sp,
|
||||
)
|
||||
PadVolumeBank(vm, slot, pad, rev, onSelect = { pad = it })
|
||||
|
||||
// ----- 16-pad bank (bottom section) -----
|
||||
Text(
|
||||
"Pads (from C2 — press to play & select; CLEAR empties the selected pad):",
|
||||
color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 11.sp,
|
||||
)
|
||||
// Pass rev so assign/clear (which only bump revision) redraw the pads: a
|
||||
// changed Int param defeats Compose strong-skipping on these leaves.
|
||||
PadBank(vm, slot, pad, rev, onSelect = { pad = it })
|
||||
}
|
||||
|
||||
importError?.let { msg ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { importError = null },
|
||||
title = { Text("Import failed") },
|
||||
text = { Text(msg) },
|
||||
confirmButton = { TextButton(onClick = { importError = null }) { Text("OK") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The 4x4 pad grid. Pads read ascending left-to-right, top-to-bottom (pad 0 = C2). */
|
||||
@Composable
|
||||
private fun PadBank(vm: AppViewModel, slot: ToolboxSlot, selected: Int, rev: Int, onSelect: (Int) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
for (row in 0 until 4) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
for (col in 0 until 4) {
|
||||
PadCell(vm, slot, row * 4 + col, selected, rev, Modifier.weight(1f), onSelect)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PadCell(
|
||||
vm: AppViewModel,
|
||||
slot: ToolboxSlot,
|
||||
pad: Int,
|
||||
selected: Int,
|
||||
rev: Int,
|
||||
modifier: Modifier,
|
||||
onSelect: (Int) -> Unit,
|
||||
) {
|
||||
@Suppress("UNUSED_PARAMETER") val ignored = rev // param only used to bust skipping
|
||||
val c = LocalRetro.current
|
||||
val isSelected = selected == pad
|
||||
val assigned = slot.string(SamplerPads.key(pad)).isNotBlank()
|
||||
val note = SamplerPads.noteFor(pad)
|
||||
val border = when { isSelected -> c.playhead; assigned -> c.accent; else -> c.grid }
|
||||
val fill = when {
|
||||
isSelected -> c.accent.copy(alpha = 0.28f)
|
||||
assigned -> c.accent.copy(alpha = 0.12f)
|
||||
else -> c.surface
|
||||
}
|
||||
Box(
|
||||
modifier
|
||||
.height(46.dp)
|
||||
.border(if (isSelected) 2.dp else 1.dp, border, RectangleShape)
|
||||
.background(fill)
|
||||
.pointerInput(pad, slot.index) {
|
||||
detectTapGestures(
|
||||
// Fire on touch-DOWN so a pad sounds the instant it's pressed
|
||||
// (like a drum pad), not on release — no "select then tap".
|
||||
// Clearing is done with the CLEAR button, so there is no
|
||||
// long-press action here.
|
||||
onPress = { onSelect(pad); vm.auditionSample(slot, note) },
|
||||
)
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(Pitch.name(note), color = if (assigned) c.text else c.textDim,
|
||||
fontFamily = FontFamily.Monospace, fontSize = 10.sp)
|
||||
Text(if (assigned) "●" else "+", color = if (assigned) c.accent else c.textDim,
|
||||
fontFamily = FontFamily.Monospace, fontSize = 10.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The per-pad volume bank: 16 vertical sliders in an 8-wide, 2-tall grid, so it
|
||||
* reads left-to-right, top-to-bottom just like the [PadBank] pad grid above. */
|
||||
@Composable
|
||||
private fun PadVolumeBank(vm: AppViewModel, slot: ToolboxSlot, selected: Int, rev: Int, onSelect: (Int) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
for (row in 0 until 2) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
for (col in 0 until 8) {
|
||||
val p = row * 8 + col
|
||||
VerticalPadSlider(vm, slot, p, selected == p, rev, Modifier.weight(1f), onSelect)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One vertical fader for a pad's output volume (0..1). Drag anywhere on the track
|
||||
* to set the level; a tap also selects the pad so the detail area follows it. The
|
||||
* fill height is the level; assigned pads read in the accent colour, empty ones dim. */
|
||||
@Composable
|
||||
private fun VerticalPadSlider(
|
||||
vm: AppViewModel,
|
||||
slot: ToolboxSlot,
|
||||
pad: Int,
|
||||
isSelected: Boolean,
|
||||
rev: Int,
|
||||
modifier: Modifier,
|
||||
onSelect: (Int) -> Unit,
|
||||
) {
|
||||
@Suppress("UNUSED_PARAMETER") val ignored = rev // param only used to bust skipping
|
||||
val c = LocalRetro.current
|
||||
val vol = slot.float(SamplerPads.volumeKey(pad), SamplerPads.DEFAULT_VOLUME)
|
||||
val assigned = slot.string(SamplerPads.key(pad)).isNotBlank()
|
||||
val border = when { isSelected -> c.playhead; assigned -> c.accent; else -> c.grid }
|
||||
val fillColor = if (assigned) c.accent.copy(alpha = 0.55f) else c.grid.copy(alpha = 0.55f)
|
||||
Column(modifier, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Canvas(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(96.dp)
|
||||
.background(c.surface)
|
||||
.border(if (isSelected) 2.dp else 1.dp, border, RectangleShape)
|
||||
.pointerInput(pad, slot.index) {
|
||||
detectTapGestures { off ->
|
||||
onSelect(pad)
|
||||
slot.set(SamplerPads.volumeKey(pad), snapVol(1f - off.y / size.height))
|
||||
vm.bumpForToolbar()
|
||||
}
|
||||
}
|
||||
.pointerInput(pad, slot.index) {
|
||||
detectDragGestures(
|
||||
onDragStart = { onSelect(pad) },
|
||||
onDrag = { change, _ ->
|
||||
change.consume()
|
||||
slot.set(SamplerPads.volumeKey(pad), snapVol(1f - change.position.y / size.height))
|
||||
vm.bumpForToolbar()
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
val fillH = vol.coerceIn(0f, 1f) * size.height
|
||||
drawRect(fillColor, Offset(0f, size.height - fillH), Size(size.width, fillH))
|
||||
}
|
||||
Text(
|
||||
Pitch.name(SamplerPads.noteFor(pad)),
|
||||
color = if (assigned) c.text else c.textDim,
|
||||
fontFamily = FontFamily.Monospace, fontSize = 8.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Snap a raw pad-fader value to the default volume when it lands within a small
|
||||
* band, so returning to the standard level is easy to hit (like the mixer fader). */
|
||||
private fun snapVol(raw: Float): Float {
|
||||
val v = raw.coerceIn(0f, 1f)
|
||||
return if (kotlin.math.abs(v - SamplerPads.DEFAULT_VOLUME) < VOL_SNAP_BAND) SamplerPads.DEFAULT_VOLUME else v
|
||||
}
|
||||
|
||||
private const val VOL_SNAP_BAND = 0.03f
|
||||
|
||||
/** Down-sample the waveform to [buckets] min/max pairs for cheap drawing.
|
||||
* Shared with the SoundFont editor's preview. */
|
||||
internal fun computePeaks(data: FloatArray, buckets: Int): Pair<FloatArray, FloatArray> {
|
||||
val mins = FloatArray(buckets)
|
||||
val maxs = FloatArray(buckets)
|
||||
if (data.isEmpty()) return mins to maxs
|
||||
val step = (data.size / buckets).coerceAtLeast(1)
|
||||
for (b in 0 until buckets) {
|
||||
var mn = 1f; var mx = -1f
|
||||
val start = b * step
|
||||
val end = minOf(start + step, data.size)
|
||||
if (start >= data.size) { mins[b] = 0f; maxs[b] = 0f; continue }
|
||||
for (i in start until end) {
|
||||
val v = data[i]
|
||||
if (v < mn) mn = v
|
||||
if (v > mx) mx = v
|
||||
}
|
||||
mins[b] = mn.coerceIn(-1f, 1f)
|
||||
maxs[b] = mx.coerceIn(-1f, 1f)
|
||||
}
|
||||
return mins to maxs
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.ui.toolbox
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.waitForUpOrCancellation
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import space.rcmd.android.sizzle.audio.SampleStore
|
||||
import space.rcmd.android.sizzle.model.Pitch
|
||||
import space.rcmd.android.sizzle.model.ToolboxSlot
|
||||
import space.rcmd.android.sizzle.ui.AppViewModel
|
||||
import space.rcmd.android.sizzle.ui.components.PianoKey
|
||||
import space.rcmd.android.sizzle.ui.components.PianoOctave
|
||||
import space.rcmd.android.sizzle.ui.components.RetroButton
|
||||
import space.rcmd.android.sizzle.ui.theme.LocalRetro
|
||||
|
||||
/**
|
||||
* The SoundFont device editor: load an `.sf2` / `.xi` (or `.wav`) file from local
|
||||
* storage, preview its waveform, set output volume, and audition it pitched across
|
||||
* a two-octave keyboard. The extracted sample plays across the whole keyboard from
|
||||
* the file's root note (see [space.rcmd.android.sizzle.audio.SoundFontLoader]).
|
||||
*/
|
||||
@Composable
|
||||
fun SoundFontEditor(vm: AppViewModel, slot: ToolboxSlot) {
|
||||
val c = LocalRetro.current
|
||||
val context = LocalContext.current
|
||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // subscribe: waveform/info refresh
|
||||
|
||||
val sfId = slot.string("sfSample")
|
||||
val sample = SampleStore.get(sfId)
|
||||
val root = slot.float("sfRoot", 60f).toInt()
|
||||
|
||||
var importError by remember(slot.index) { mutableStateOf<String?>(null) }
|
||||
val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
val bytes = runCatching {
|
||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
}.getOrNull()
|
||||
when {
|
||||
bytes == null -> importError = "Couldn't read the selected file."
|
||||
!vm.loadSoundFont(slot, uri.toString(), bytes) -> importError =
|
||||
"Couldn't load this instrument. Supported formats are .sf2, .xi and " +
|
||||
"uncompressed .wav."
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
RetroButton("LOAD SF2 / XI") {
|
||||
// .sf2/.xi have no reliable MIME; accept any file and detect by content.
|
||||
picker.launch(arrayOf("*/*"))
|
||||
}
|
||||
Text(
|
||||
if (sample == null) {
|
||||
"No instrument loaded — load a .sf2 or .xi file."
|
||||
} else {
|
||||
"Loaded: ${sample.data.size} frames @ ${sample.sampleRate} Hz · root ${Pitch.name(root)}"
|
||||
},
|
||||
color = if (sample == null) c.textDim else c.text,
|
||||
fontFamily = FontFamily.Monospace, fontSize = 10.sp,
|
||||
)
|
||||
|
||||
if (sample != null) {
|
||||
val peaks = remember(sfId, sample.data.size) { computePeaks(sample.data, 400) }
|
||||
Canvas(Modifier.fillMaxWidth().height(96.dp).background(c.surface)) {
|
||||
val (mins, maxs) = peaks
|
||||
val bw = size.width / mins.size
|
||||
val mid = size.height / 2f
|
||||
for (i in mins.indices) {
|
||||
val x = i * bw
|
||||
drawLine(c.text, Offset(x, mid - maxs[i] * mid), Offset(x, mid - mins[i] * mid), strokeWidth = 1f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Volume + ADSR as compact vertical faders (one row) so the audition
|
||||
// keyboard below gets room for full-size keys.
|
||||
Text("Volume / envelope:", color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 11.sp)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
VerticalSlider(vm, slot, rev, "volume", "VOL", 0f, 1f, 0.8f, Modifier.weight(1f))
|
||||
VerticalSlider(vm, slot, rev, "attack", "ATK", 0f, 2f, 0.005f, Modifier.weight(1f))
|
||||
VerticalSlider(vm, slot, rev, "decay", "DEC", 0f, 2f, 0.10f, Modifier.weight(1f))
|
||||
VerticalSlider(vm, slot, rev, "sustain", "SUS", 0f, 1f, 1f, Modifier.weight(1f))
|
||||
VerticalSlider(vm, slot, rev, "release", "REL", 0f, 3f, 0.20f, Modifier.weight(1f))
|
||||
}
|
||||
|
||||
// Audition keyboard — same look and behaviour as the toolbox MIDI piano:
|
||||
// an octave selector over two stacked octaves, hold a key to sound the
|
||||
// loaded instrument.
|
||||
var octave by remember { mutableIntStateOf(3) }
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text("OCT", color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 11.sp)
|
||||
RetroButton("-") { octave = (octave - 1).coerceAtLeast(0) }
|
||||
Text("$octave–${octave + 1}", color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 13.sp)
|
||||
RetroButton("+") { octave = (octave + 1).coerceAtMost(8) }
|
||||
}
|
||||
AuditionOctave(vm, slot.index, (octave + 2) * 12) // upper octave on top
|
||||
AuditionOctave(vm, slot.index, (octave + 1) * 12) // lower octave below
|
||||
}
|
||||
|
||||
importError?.let { msg ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { importError = null },
|
||||
title = { Text("Import failed") },
|
||||
text = { Text(msg) },
|
||||
confirmButton = { TextButton(onClick = { importError = null }) { Text("OK") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A compact vertical fader bound to a numeric slot param ([key], range [min]..[max]).
|
||||
* Drag or tap the track to set the value; the current value shows above and the
|
||||
* short label below. [rev] is passed only to defeat Compose strong-skipping so the
|
||||
* fader refreshes when a preset load changes the value. */
|
||||
@Composable
|
||||
private fun VerticalSlider(
|
||||
vm: AppViewModel,
|
||||
slot: ToolboxSlot,
|
||||
rev: Int,
|
||||
key: String,
|
||||
label: String,
|
||||
min: Float,
|
||||
max: Float,
|
||||
default: Float,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
@Suppress("UNUSED_PARAMETER") val ignored = rev // param only used to bust skipping
|
||||
val c = LocalRetro.current
|
||||
val value = slot.float(key, default).coerceIn(min, max)
|
||||
val frac = if (max > min) (value - min) / (max - min) else 0f
|
||||
|
||||
fun setFromY(y: Float, h: Float) {
|
||||
val f = (1f - y / h).coerceIn(0f, 1f)
|
||||
slot.set(key, min + f * (max - min))
|
||||
vm.bumpForToolbar()
|
||||
}
|
||||
|
||||
Column(modifier, horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(formatSliderValue(value), color = c.text,
|
||||
fontFamily = FontFamily.Monospace, fontSize = 9.sp, maxLines = 1)
|
||||
Canvas(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(120.dp)
|
||||
.background(c.surface)
|
||||
.border(1.dp, c.grid, RectangleShape)
|
||||
.pointerInput(key, slot.index) {
|
||||
detectTapGestures { off -> setFromY(off.y, size.height.toFloat()) }
|
||||
}
|
||||
.pointerInput(key, slot.index) {
|
||||
detectDragGestures { change, _ -> change.consume(); setFromY(change.position.y, size.height.toFloat()) }
|
||||
},
|
||||
) {
|
||||
val fillH = frac.coerceIn(0f, 1f) * size.height
|
||||
drawRect(c.accent.copy(alpha = 0.5f), Offset(0f, size.height - fillH), Size(size.width, fillH))
|
||||
}
|
||||
Text(label, color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 9.sp, maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatSliderValue(v: Float): String =
|
||||
if (v == v.toInt().toFloat()) v.toInt().toString() else String.format("%.2f", v)
|
||||
|
||||
/** One octave of the SoundFont audition keyboard — a real piano octave (like the
|
||||
* toolbox piano and the cell-editor keyboard); hold a key to sound the slot's
|
||||
* instrument. A fixed, full-size height is used because the editor scrolls, and
|
||||
* PianoOctave needs a bounded height to lay its keys out. */
|
||||
@Composable
|
||||
private fun AuditionOctave(vm: AppViewModel, slotIndex: Int, startMidi: Int) {
|
||||
PianoOctave(Modifier.fillMaxWidth().height(150.dp)) { pc, isBlack, keyMod ->
|
||||
val midi = (startMidi + pc).coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
|
||||
val liveMidi = rememberUpdatedState(midi)
|
||||
PianoKey(
|
||||
label = Pitch.name(midi).replace("-", ""),
|
||||
isBlack = isBlack,
|
||||
enabled = true,
|
||||
selected = false,
|
||||
pressed = vm.isNoteHeld(midi),
|
||||
modifier = keyMod.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
awaitFirstDown()
|
||||
val n = liveMidi.value
|
||||
vm.auditionOn(slotIndex, n)
|
||||
waitForUpOrCancellation()
|
||||
vm.auditionOff(n)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.ui.toolbox
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.FileProvider
|
||||
import space.rcmd.android.sizzle.model.ParamSpec
|
||||
import space.rcmd.android.sizzle.model.ToolboxSlot
|
||||
import space.rcmd.android.sizzle.model.ToolboxType
|
||||
import space.rcmd.android.sizzle.ui.AppViewModel
|
||||
import space.rcmd.android.sizzle.ui.isLandscape
|
||||
import space.rcmd.android.sizzle.ui.components.RetroButton
|
||||
import space.rcmd.android.sizzle.ui.components.RetroDropdown
|
||||
import space.rcmd.android.sizzle.ui.theme.LocalRetro
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* The third tab. Top half: a 4x4 grid of 16 slots holding instruments or effects.
|
||||
* - SINGLE tap -> select the tile (highlighted).
|
||||
* - DOUBLE tap -> open its edit window (device picker if empty, params if filled).
|
||||
* - LONG press -> empty the cell.
|
||||
*
|
||||
* Bottom half: a stacked two-octave keyboard (one octave per row) that plays the
|
||||
* currently-selected tile IF it is an instrument, for quick testing.
|
||||
*
|
||||
* The parameter editor is generated automatically from the device's [ToolboxType]
|
||||
* parameter list, so new devices need no bespoke UI.
|
||||
*/
|
||||
@Composable
|
||||
fun ToolboxScreen(vm: AppViewModel) {
|
||||
val c = LocalRetro.current
|
||||
@Suppress("UNUSED_VARIABLE") val redraw = vm.revision
|
||||
|
||||
var selected by remember { mutableStateOf(-1) }
|
||||
var editing by remember { mutableStateOf<Int?>(null) }
|
||||
var picking by remember { mutableStateOf<Int?>(null) }
|
||||
|
||||
// Back closes an open device editor / picker before the app-level handler
|
||||
// switches tabs.
|
||||
BackHandler(enabled = editing != null || picking != null) {
|
||||
if (editing != null) editing = null else picking = null
|
||||
}
|
||||
|
||||
// Shared tile gestures (index-based) so both layouts drive the same state.
|
||||
val onTapTile: (Int) -> Unit = { selected = it }
|
||||
val onDoubleTapTile: (Int) -> Unit = { i -> if (vm.project.toolbox[i].isEmpty) picking = i else editing = i }
|
||||
val onLongPressTile: (Int) -> Unit = { i -> vm.project.toolbox[i].clearSlot(); vm.bumpForToolbar() }
|
||||
|
||||
if (isLandscape()) {
|
||||
// Landscape: a fixed 4x4 grid squashed to fit the height (no scroll) BESIDE
|
||||
// the audition keyboard, which gets the larger width so its CH/OCT/VEL
|
||||
// toolbar fits without horizontal scrolling.
|
||||
Row(Modifier.fillMaxSize().background(c.background)) {
|
||||
ToolboxTileGrid(vm, selected, onTapTile, onDoubleTapTile, onLongPressTile,
|
||||
Modifier.weight(1f).fillMaxHeight())
|
||||
Box(Modifier.width(2.dp).fillMaxHeight().background(c.accent))
|
||||
space.rcmd.android.sizzle.ui.StackedKeyboard(vm, punchIn = false, Modifier.weight(1.3f))
|
||||
}
|
||||
} else {
|
||||
// Portrait: a scrolling grid of square tiles above the keyboard (1.7 : 1).
|
||||
Column(Modifier.fillMaxSize().background(c.background)) {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(4),
|
||||
modifier = Modifier.weight(1.7f).fillMaxWidth().padding(6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
items(vm.project.toolbox) { slot ->
|
||||
SlotTile(
|
||||
rev = vm.revision, slot = slot, selected = selected == slot.index,
|
||||
onTap = { onTapTile(slot.index) },
|
||||
onDoubleTap = { onDoubleTapTile(slot.index) },
|
||||
onLongPress = { onLongPressTile(slot.index) },
|
||||
modifier = Modifier.fillMaxWidth().aspectRatio(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().height(2.dp).background(c.accent)) // fixed divider
|
||||
space.rcmd.android.sizzle.ui.StackedKeyboard(vm, punchIn = false, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
picking?.let { index ->
|
||||
DevicePicker(
|
||||
onDismiss = { picking = null },
|
||||
onPick = { type ->
|
||||
vm.project.toolbox[index].fill(type)
|
||||
vm.bumpForToolbar(); picking = null; editing = index
|
||||
},
|
||||
)
|
||||
}
|
||||
editing?.let { index ->
|
||||
ParamEditor(vm, vm.project.toolbox[index], onClose = { editing = null })
|
||||
}
|
||||
}
|
||||
|
||||
/** A fixed 4x4 grid of the 16 slot tiles that fills [modifier]'s bounds — used in
|
||||
* landscape so every tile fits the short height without scrolling (tiles become
|
||||
* rectangular rather than square). */
|
||||
@Composable
|
||||
private fun ToolboxTileGrid(
|
||||
vm: AppViewModel,
|
||||
selected: Int,
|
||||
onTap: (Int) -> Unit,
|
||||
onDoubleTap: (Int) -> Unit,
|
||||
onLongPress: (Int) -> Unit,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
Column(modifier.padding(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
for (row in 0 until 4) {
|
||||
Row(Modifier.fillMaxWidth().weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
for (col in 0 until 4) {
|
||||
val idx = row * 4 + col
|
||||
SlotTile(
|
||||
rev = vm.revision,
|
||||
slot = vm.project.toolbox[idx],
|
||||
selected = selected == idx,
|
||||
onTap = { onTap(idx) },
|
||||
onDoubleTap = { onDoubleTap(idx) },
|
||||
onLongPress = { onLongPress(idx) },
|
||||
modifier = Modifier.weight(1f).fillMaxHeight(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One toolbox slot tile. [modifier] carries the sizing: a square (portrait grid)
|
||||
* or a weighted cell that fills the height (the landscape fixed grid). */
|
||||
@Composable
|
||||
private fun SlotTile(
|
||||
rev: Int,
|
||||
slot: ToolboxSlot,
|
||||
selected: Boolean,
|
||||
onTap: () -> Unit,
|
||||
onDoubleTap: () -> Unit,
|
||||
onLongPress: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@Suppress("UNUSED_PARAMETER") val ignored = rev // param only used to bust skipping
|
||||
val c = LocalRetro.current
|
||||
val borderColor = when { selected -> c.playhead; !slot.isEmpty -> c.accent; else -> c.grid }
|
||||
val fill = when { selected -> c.accent.copy(alpha = 0.28f); !slot.isEmpty -> c.accent.copy(alpha = 0.12f); else -> c.surface }
|
||||
Box(
|
||||
modifier
|
||||
.border(if (selected) 2.dp else 1.dp, borderColor, RectangleShape)
|
||||
.background(fill)
|
||||
.pointerInput(slot.index, slot.isEmpty) {
|
||||
detectTapGestures(
|
||||
onTap = { onTap() },
|
||||
onDoubleTap = { onDoubleTap() },
|
||||
onLongPress = { onLongPress() },
|
||||
)
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
// Inner padding keeps labels off the tile border; the name clips with an
|
||||
// ellipsis so a long preset name can't spill into (or past) the edges.
|
||||
Column(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 5.dp, vertical = 3.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text("${slot.index + 1}".padStart(2, '0'),
|
||||
color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 10.sp)
|
||||
Text(
|
||||
if (slot.isEmpty) "+" else slot.name,
|
||||
color = if (slot.isEmpty) c.textDim else c.text,
|
||||
fontFamily = FontFamily.Monospace, fontSize = 11.sp,
|
||||
textAlign = TextAlign.Center, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
slot.type?.let {
|
||||
Text(it.kind.name.take(3), color = c.textDim,
|
||||
fontFamily = FontFamily.Monospace, fontSize = 9.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A simple overlay list of all available instruments and effects to load. */
|
||||
@Composable
|
||||
private fun DevicePicker(onDismiss: () -> Unit, onPick: (ToolboxType) -> Unit) {
|
||||
val c = LocalRetro.current
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(c.background.copy(alpha = 0.92f))
|
||||
.pointerInput(Unit) { detectTapGestures { onDismiss() } },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text("SELECT DEVICE", color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 14.sp)
|
||||
ToolboxType.entries.forEach { type ->
|
||||
Text(
|
||||
"[${type.kind.name.take(3)}] ${type.displayName}",
|
||||
color = c.text, fontFamily = FontFamily.Monospace, fontSize = 13.sp,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.pointerInput(type) { detectTapGestures { onPick(type) } }
|
||||
.padding(vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Auto-generated parameter editor. Presets are managed by the [PresetBar]. */
|
||||
@Composable
|
||||
private fun ParamEditor(vm: AppViewModel, slot: ToolboxSlot, onClose: () -> Unit) {
|
||||
val c = LocalRetro.current
|
||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // subscribe so title/params refresh
|
||||
val type = slot.type ?: return
|
||||
|
||||
Box(Modifier.fillMaxSize().background(c.background.copy(alpha = 0.96f))) {
|
||||
Column(Modifier.fillMaxSize().padding(12.dp).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(slot.name, color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 15.sp)
|
||||
Text("CLOSE ✕", color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 13.sp,
|
||||
modifier = Modifier.pointerInput(Unit) { detectTapGestures { onClose() } })
|
||||
}
|
||||
|
||||
// Preset browser: load / save / import / share for this device.
|
||||
PresetBar(vm, slot)
|
||||
|
||||
// The Sampler / SoundFont / Delay get bespoke editors; the reverb has so
|
||||
// many controls it's paged (swipeable sections with dot pagination); the
|
||||
// LFO adds a modulation-target picker; everything else uses the
|
||||
// auto-generated parameter list, laid out two-per-row to fit more.
|
||||
when (type) {
|
||||
ToolboxType.SAMPLER -> SamplerEditor(vm, slot)
|
||||
ToolboxType.SOUNDFONT -> SoundFontEditor(vm, slot)
|
||||
ToolboxType.DELAY -> DelayEditor(vm, slot)
|
||||
ToolboxType.AMBIENCE -> AmbiencePager(vm, slot)
|
||||
ToolboxType.LFO -> {
|
||||
ParamGrid(vm, slot, type.params)
|
||||
LfoTargetPicker(vm, slot)
|
||||
}
|
||||
else -> ParamGrid(vm, slot, type.params)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Lay a list of [params] out two-per-row so the editor fits more controls than a
|
||||
* single stacked column. Each [ParamControl] fills its half-width cell (an odd last
|
||||
* param gets an empty cell beside it). */
|
||||
@Composable
|
||||
private fun ParamGrid(vm: AppViewModel, slot: ToolboxSlot, params: List<ParamSpec>) {
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
var i = 0
|
||||
while (i < params.size) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Box(Modifier.weight(1f)) { ParamControl(vm, slot, params[i]) }
|
||||
if (i + 1 < params.size) {
|
||||
Box(Modifier.weight(1f)) { ParamControl(vm, slot, params[i + 1]) }
|
||||
} else {
|
||||
Spacer(Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
i += 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The reverb has ~17 controls — too many to stack. They're grouped into labelled
|
||||
* sections shown one page at a time in a swipeable [HorizontalPager] with tappable
|
||||
* dot pagination below. Each page uses the same two-column [ParamGrid]. Works the
|
||||
* same in portrait and landscape (the editor overlay is full-width in both). */
|
||||
@Composable
|
||||
private fun AmbiencePager(vm: AppViewModel, slot: ToolboxSlot) {
|
||||
val c = LocalRetro.current
|
||||
val byKey = (slot.type ?: return).params.associateBy { it.key }
|
||||
val pagerState = rememberPagerState(pageCount = { AMBIENCE_SECTIONS.size })
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.fillMaxWidth().height(210.dp),
|
||||
pageSpacing = 12.dp,
|
||||
) { page ->
|
||||
val section = AMBIENCE_SECTIONS[page]
|
||||
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
section.title, color = c.accent,
|
||||
fontFamily = FontFamily.Monospace, fontSize = 12.sp,
|
||||
)
|
||||
ParamGrid(vm, slot, section.keys.mapNotNull { byKey[it] })
|
||||
}
|
||||
}
|
||||
// Dot pagination: tap a dot (or swipe the page) to jump to that section.
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
for (i in AMBIENCE_SECTIONS.indices) {
|
||||
val active = i == pagerState.currentPage
|
||||
Box(
|
||||
Modifier
|
||||
.padding(horizontal = 4.dp)
|
||||
.size(if (active) 10.dp else 7.dp)
|
||||
.background(if (active) c.accent else c.grid, CircleShape)
|
||||
.clickable { scope.launch { pagerState.animateScrollToPage(i) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Grouping of the reverb's parameters into swipeable sections. */
|
||||
private class AmbienceSection(val title: String, val keys: List<String>)
|
||||
|
||||
private val AMBIENCE_SECTIONS = listOf(
|
||||
AmbienceSection("Space", listOf("algo", "predelay", "roomsize", "decay")),
|
||||
AmbienceSection("Tone", listOf("hfdamp", "lfabsorb", "diffusion")),
|
||||
AmbienceSection("Modulation", listOf("modamt", "modrate")),
|
||||
AmbienceSection("Character", listOf("erlevel", "saturation")),
|
||||
AmbienceSection("Mix", listOf("wet", "dry")),
|
||||
AmbienceSection("Ducking", listOf("duckamt", "duckthresh", "duckattack", "duckrelease")),
|
||||
)
|
||||
|
||||
/**
|
||||
* The preset browser bar shown at the top of every device editor: a dropdown of
|
||||
* the presets saved for this device type, plus Save (to the app's on-disk preset
|
||||
* library), Import (from a picked text preset or — for the Sampler — a `.zip`
|
||||
* bundle), and Share (via the system share sheet). Presets are the plain-text
|
||||
* [space.rcmd.android.sizzle.io.PresetIo] format, stored one folder
|
||||
* per instrument/effect.
|
||||
*/
|
||||
@Composable
|
||||
private fun PresetBar(vm: AppViewModel, slot: ToolboxSlot) {
|
||||
val c = LocalRetro.current
|
||||
val context = LocalContext.current
|
||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // refresh the list after save/import
|
||||
val type = slot.type ?: return
|
||||
|
||||
val names = vm.presetNames(type)
|
||||
var showSave by remember { mutableStateOf(false) }
|
||||
var confirmDelete by remember { mutableStateOf<String?>(null) }
|
||||
var importError by remember { mutableStateOf<String?>(null) }
|
||||
// The preset currently chosen in the dropdown (what Delete acts on). Falls back
|
||||
// to the slot's name, then the first available preset.
|
||||
var selected by remember(slot.index) { mutableStateOf<String?>(null) }
|
||||
val current = selected?.takeIf { it in names } ?: slot.name.takeIf { it in names } ?: names.firstOrNull()
|
||||
|
||||
// System file picker for importing a preset (text preset or sampler .zip bundle).
|
||||
// Any read/parse failure surfaces as a popup instead of silently doing nothing.
|
||||
val importer = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
val bytes = runCatching {
|
||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
}.getOrNull()
|
||||
when {
|
||||
bytes == null -> importError = "Couldn't read the selected file."
|
||||
!vm.importPreset(slot, bytes) -> importError =
|
||||
"Couldn't import this preset. The file may be corrupt, or not a valid " +
|
||||
"${type.displayName} preset or bundle."
|
||||
}
|
||||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
// Row 1: the preset browser dropdown, above the action buttons.
|
||||
Row(
|
||||
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (names.isNotEmpty() && current != null) {
|
||||
RetroDropdown(
|
||||
label = "PRESET",
|
||||
options = names,
|
||||
selected = current,
|
||||
onSelect = { selected = it; vm.loadPreset(slot, it) },
|
||||
)
|
||||
} else {
|
||||
Text("no presets", color = c.textDim,
|
||||
fontFamily = FontFamily.Monospace, fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
// Row 2: the action buttons.
|
||||
Row(
|
||||
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// DELETE kept at the far LEFT, isolated by a gap, so reaching for the
|
||||
// other actions can't accidentally hit it.
|
||||
if (current != null) {
|
||||
RetroButton("DELETE", danger = true) { confirmDelete = current }
|
||||
Spacer(Modifier.width(20.dp))
|
||||
}
|
||||
RetroButton("SAVE") { showSave = true }
|
||||
RetroButton("IMPORT") {
|
||||
// Accept text presets and (for the Sampler) zip bundles.
|
||||
importer.launch(arrayOf("application/zip", "text/*", "application/octet-stream", "*/*"))
|
||||
}
|
||||
RetroButton("SHARE") {
|
||||
val name = slot.name.ifBlank { type.displayName }
|
||||
vm.savePreset(slot, name) // ensure a file exists to share
|
||||
vm.presetShareFile(type, name)?.let { sharePresetFile(context, it) }
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"Presets are plain text, saved per device under the app's storage. " +
|
||||
"Sampler presets carry the sample (shared as a .zip bundle).",
|
||||
color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 10.sp,
|
||||
)
|
||||
}
|
||||
|
||||
if (showSave) {
|
||||
var name by remember { mutableStateOf(slot.name.ifBlank { type.displayName }) }
|
||||
AlertDialog(
|
||||
onDismissRequest = { showSave = false },
|
||||
title = { Text("Save preset") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
singleLine = true,
|
||||
label = { Text("Name") },
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
if (name.isNotBlank()) vm.savePreset(slot, name.trim())
|
||||
showSave = false
|
||||
}) { Text("Save") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { showSave = false }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
importError?.let { msg ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { importError = null },
|
||||
title = { Text("Import failed") },
|
||||
text = { Text(msg) },
|
||||
confirmButton = { TextButton(onClick = { importError = null }) { Text("OK") } },
|
||||
)
|
||||
}
|
||||
|
||||
confirmDelete?.let { name ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmDelete = null },
|
||||
title = { Text("Delete preset") },
|
||||
text = { Text("Delete \"$name\"? This cannot be undone.") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
vm.deletePreset(type, name)
|
||||
selected = null
|
||||
confirmDelete = null
|
||||
}) { Text("Delete") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { confirmDelete = null }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Launch the system share sheet for a saved preset file (text) or bundle (zip). */
|
||||
private fun sharePresetFile(context: android.content.Context, file: java.io.File) {
|
||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||
val mime = if (file.name.endsWith(".zip")) "application/zip" else "text/plain"
|
||||
val send = Intent(Intent.ACTION_SEND).apply {
|
||||
type = mime
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(Intent.createChooser(send, "Share preset"))
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.ui.tracker
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.drawText
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import space.rcmd.android.sizzle.model.Arrangement as ArrModel
|
||||
import space.rcmd.android.sizzle.model.LoopRegion
|
||||
import space.rcmd.android.sizzle.ui.AppViewModel
|
||||
import space.rcmd.android.sizzle.ui.components.GlyphCache
|
||||
import space.rcmd.android.sizzle.ui.components.RetroButton
|
||||
import space.rcmd.android.sizzle.ui.theme.LocalRetro
|
||||
|
||||
/**
|
||||
* The lower-half arrangement. Layout (spreadsheet-style, like the sizzletracker
|
||||
* CLI): a fixed left column of lane numbers, a bar ruler across the top, and the
|
||||
* scrollable roll body. The ruler and body share one horizontal scroll so they
|
||||
* stay aligned; the roll body is drawn on a single [Canvas] for performance.
|
||||
*
|
||||
* Each lane is tied to its own tracker block (lane i ⟷ block i). Cells are NOT
|
||||
* toggled by tapping; instead you SELECT cells (tap a single cell, or long-press
|
||||
* and drag to sweep a rectangle) and then use the FILL / CLEAR toggle in the loop
|
||||
* toolbar to enable or disable the whole selection at once. When the playhead
|
||||
* crosses an enabled cell, the sequencer plays that block. Tap a lane number to
|
||||
* edit that block in the tracker above.
|
||||
*
|
||||
* The ruler also shows the A and B loop regions as coloured bands; the A/B
|
||||
* buttons in the toolbar assign the current selection to a region, and tapping a
|
||||
* button that is already highlighted removes that region (and its band).
|
||||
*/
|
||||
@Composable
|
||||
fun ArrangementRoll(vm: AppViewModel) {
|
||||
val c = LocalRetro.current
|
||||
// Composition read: structural edits bump revision (playback does not), so the
|
||||
// lane column / ruler recompose on edits but not per beat. The roll Canvas also
|
||||
// reads vm.revision in its draw phase (below) so cell edits redraw it directly.
|
||||
val redraw = vm.revision
|
||||
@Suppress("UNUSED_EXPRESSION") redraw
|
||||
// Held as State and read ONLY in the draw phase + the follow-scroll collector
|
||||
// below, never in composition, so a playhead advance invalidates the roll draw
|
||||
// alone — no recomposition.
|
||||
val transportState = vm.transport.collectAsState()
|
||||
val arr = vm.project.arrangement
|
||||
val sig = vm.project.timeSignature
|
||||
// Canvas width in beats for the active signature (the roll spans arr.lengthBars
|
||||
// bars). Recomputed on signature change; captured by the pointer handlers below,
|
||||
// which are keyed on (arr, sig) so they see a consistent value.
|
||||
val lengthBeats = arr.canvasBeats(sig)
|
||||
val scroll = rememberScrollState()
|
||||
// Width of the scrollable roll viewport (px), measured below; used to keep the
|
||||
// playhead centred once it reaches the middle of the view.
|
||||
var viewportWidthPx by remember { mutableIntStateOf(0) }
|
||||
val density = LocalDensity.current
|
||||
val measurer = rememberTextMeasurer()
|
||||
val glyphs = remember(measurer) { GlyphCache(measurer) }
|
||||
|
||||
val beatWidth = 20.dp
|
||||
val muteColWidth = 34.dp // wide enough to hit comfortably from the screen edge
|
||||
val laneColWidth = 68.dp // mute toggle + block number
|
||||
val rulerHeight = 18.dp
|
||||
val beatWidthPx = with(density) { beatWidth.toPx() }
|
||||
val rulerHeightPx = with(density) { rulerHeight.toPx() }
|
||||
|
||||
// Rectangular cell selection: anchor (…0) + live corner (…1). Beat < 0 = none.
|
||||
// Unlike before, selecting does NOT change any cell; the toolbar toggle acts
|
||||
// on the selection. Read in composition (for the toolbar's FILL/CLEAR label)
|
||||
// and captured by the Canvas draw below.
|
||||
var selLane0 by remember { mutableIntStateOf(-1) }
|
||||
var selBeat0 by remember { mutableIntStateOf(-1) }
|
||||
var selLane1 by remember { mutableIntStateOf(-1) }
|
||||
var selBeat1 by remember { mutableIntStateOf(-1) }
|
||||
|
||||
val hasSelection = selBeat0 >= 0
|
||||
val laneRange = if (hasSelection) minOf(selLane0, selLane1)..maxOf(selLane0, selLane1) else IntRange.EMPTY
|
||||
val beatRange = if (hasSelection) minOf(selBeat0, selBeat1)..maxOf(selBeat0, selBeat1) else IntRange.EMPTY
|
||||
// Whether every cell in the selection is already filled (drives FILL vs CLEAR).
|
||||
val selectionAllFilled = hasSelection &&
|
||||
laneRange.all { lane -> beatRange.all { beat -> arr.patternAt(lane, beat) != ArrModel.EMPTY } }
|
||||
|
||||
val rulerStyle = remember(c) { TextStyle(color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 9.sp) }
|
||||
val regionAStyle = remember(c) { TextStyle(color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 9.sp) }
|
||||
val regionBStyle = remember(c) { TextStyle(color = c.playhead, fontFamily = FontFamily.Monospace, fontSize = 9.sp) }
|
||||
|
||||
// Follow the playhead by scrolling horizontally. Collects the transport flow
|
||||
// directly rather than keying a LaunchedEffect on a composition read of the beat,
|
||||
// so following the playhead never triggers recomposition. De-duped by beat so we
|
||||
// only animate when the beat actually changes.
|
||||
LaunchedEffect(scroll, beatWidthPx) {
|
||||
var lastBeat = -1
|
||||
vm.transport.collect { t ->
|
||||
if (t.isPlaying && t.currentBeat != lastBeat) {
|
||||
lastBeat = t.currentBeat
|
||||
// Keep the playhead at the middle of the view: with the (coerced) 0
|
||||
// floor this means the roll stays put until the playhead reaches the
|
||||
// centre (~the 3rd bar), then follows it centred. Falls back to a
|
||||
// fixed offset until the viewport width has been measured.
|
||||
val half = if (viewportWidthPx > 0) viewportWidthPx / 2f else 200f
|
||||
val target = (t.currentBeat * beatWidthPx - half).toInt()
|
||||
.coerceIn(0, scroll.maxValue)
|
||||
scroll.animateScrollTo(target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enable or disable every selected cell. A mixed/empty selection fills; a
|
||||
// fully-filled selection clears — so the toolbar button is a true toggle.
|
||||
fun toggleSelectedCells() {
|
||||
if (!hasSelection) return
|
||||
val clear = selectionAllFilled
|
||||
for (lane in laneRange) for (beat in beatRange) {
|
||||
arr.set(lane, beat, if (clear) ArrModel.EMPTY else lane)
|
||||
}
|
||||
vm.bumpForToolbar()
|
||||
}
|
||||
|
||||
fun assignRegion(region: LoopRegion) {
|
||||
if (!hasSelection) return
|
||||
region.start = beatRange.first
|
||||
region.end = beatRange.last + 1
|
||||
region.enabled = true
|
||||
vm.bumpForToolbar()
|
||||
}
|
||||
|
||||
fun clearRegion(region: LoopRegion) {
|
||||
region.enabled = false
|
||||
region.start = 0
|
||||
region.end = 0
|
||||
vm.bumpForToolbar()
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize().background(c.background)) {
|
||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
// ---- Fixed left column: corner + per-lane [mute | block number] ----
|
||||
Column(Modifier.width(laneColWidth).fillMaxHeight()) {
|
||||
Box(Modifier.height(rulerHeight).fillMaxWidth().background(c.surface),
|
||||
contentAlignment = Alignment.Center) {
|
||||
Text("BLK", color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 8.sp)
|
||||
}
|
||||
for (lane in 0 until ArrModel.LANE_COUNT) {
|
||||
val active = vm.activeBlock == lane
|
||||
val muted = arr.laneMuted[lane]
|
||||
Row(Modifier.weight(1f).fillMaxWidth()) {
|
||||
// Mute toggle (left of the block header): mutes this lane.
|
||||
Box(
|
||||
Modifier
|
||||
.width(muteColWidth)
|
||||
.fillMaxHeight()
|
||||
.background(if (muted) c.danger.copy(alpha = 0.30f) else c.surface)
|
||||
.clickable { vm.toggleArrangementLaneMute(lane) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text("M", color = if (muted) c.danger else c.textDim,
|
||||
fontFamily = FontFamily.Monospace, fontSize = 9.sp)
|
||||
}
|
||||
// Block number: tap to edit that block in the tracker above.
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.background(if (active) c.accent.copy(alpha = 0.25f) else c.surface)
|
||||
.clickable { vm.setActiveBlock(lane) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text("${lane + 1}", color = if (active) c.accent else c.textDim,
|
||||
fontFamily = FontFamily.Monospace, fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Scrollable ruler + roll (one Canvas) ----
|
||||
Box(
|
||||
Modifier.weight(1f).fillMaxHeight()
|
||||
.onSizeChanged { viewportWidthPx = it.width }
|
||||
.horizontalScroll(scroll),
|
||||
) {
|
||||
Canvas(
|
||||
Modifier
|
||||
.width(beatWidth * lengthBeats)
|
||||
.fillMaxHeight()
|
||||
// Tap selects a single cell (no toggle). The ruler is
|
||||
// display-only, so ruler taps are ignored.
|
||||
.pointerInput(arr, sig) {
|
||||
detectTapGestures { off ->
|
||||
if (off.y < rulerHeightPx) return@detectTapGestures
|
||||
val laneH = (size.height - rulerHeightPx) / ArrModel.LANE_COUNT
|
||||
val lane = ((off.y - rulerHeightPx) / laneH).toInt()
|
||||
.coerceIn(0, ArrModel.LANE_COUNT - 1)
|
||||
val beat = (off.x / beatWidthPx).toInt().coerceIn(0, lengthBeats - 1)
|
||||
selLane0 = lane; selLane1 = lane
|
||||
selBeat0 = beat; selBeat1 = beat
|
||||
}
|
||||
}
|
||||
// Long-press then drag sweeps out a rectangular selection.
|
||||
.pointerInput(arr, sig) {
|
||||
fun laneAt(off: Offset): Int {
|
||||
val laneH = (size.height - rulerHeightPx) / ArrModel.LANE_COUNT
|
||||
return ((off.y - rulerHeightPx) / laneH).toInt()
|
||||
.coerceIn(0, ArrModel.LANE_COUNT - 1)
|
||||
}
|
||||
fun beatAt(off: Offset): Int =
|
||||
(off.x / beatWidthPx).toInt().coerceIn(0, lengthBeats - 1)
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = { off ->
|
||||
selLane0 = laneAt(off); selLane1 = selLane0
|
||||
selBeat0 = beatAt(off); selBeat1 = selBeat0
|
||||
},
|
||||
onDrag = { change, _ ->
|
||||
selLane1 = laneAt(change.position)
|
||||
selBeat1 = beatAt(change.position)
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
// Subscribe the draw phase to edit revisions (live state read,
|
||||
// not the captured `redraw` Int) so toggling a cell redraws the
|
||||
// roll even when no other draw-read state changed.
|
||||
@Suppress("UNUSED_EXPRESSION") vm.revision
|
||||
// Draw-phase read: a playhead advance invalidates this draw
|
||||
// only, never composition.
|
||||
val transport = transportState.value
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
val laneH = (h - rulerHeightPx) / ArrModel.LANE_COUNT
|
||||
val beatsPerBar = sig.beatsPerBar
|
||||
|
||||
// Viewport culling: with the canvas up to 256 bars wide, only
|
||||
// iterate the beats currently scrolled into view (plus a small
|
||||
// margin) instead of all of them. Reading scroll.value here ties
|
||||
// the draw to scrolling, so the visible slice redraws as it moves.
|
||||
val scrollPx = scroll.value.toFloat()
|
||||
val visW = if (viewportWidthPx > 0) viewportWidthPx.toFloat() else w
|
||||
val firstBeat = (scrollPx / beatWidthPx).toInt().coerceIn(0, (lengthBeats - 1).coerceAtLeast(0))
|
||||
val lastBeat = (((scrollPx + visW) / beatWidthPx).toInt() + 2).coerceIn(1, lengthBeats)
|
||||
|
||||
// Ruler background + per-bar labels/ticks.
|
||||
drawRect(c.surface, Offset(0f, 0f), Size(w, rulerHeightPx))
|
||||
for (beat in firstBeat until lastBeat) {
|
||||
val x = beat * beatWidthPx
|
||||
if (beat % beatsPerBar == 0) {
|
||||
drawLine(c.grid, Offset(x, 0f), Offset(x, h), strokeWidth = 1f)
|
||||
val bar = beat / beatsPerBar + 1
|
||||
val layout = glyphs.measure("$bar", rulerStyle)
|
||||
drawText(layout, topLeft = Offset(x + 2f, (rulerHeightPx - layout.size.height) / 2f))
|
||||
}
|
||||
}
|
||||
|
||||
// A / B loop-region bands on the ruler (drawn over the ticks).
|
||||
fun drawRegionBand(region: LoopRegion, color: androidx.compose.ui.graphics.Color, letter: String, style: TextStyle) {
|
||||
if (!region.isValid) return
|
||||
val x0 = region.start * beatWidthPx
|
||||
val x1 = region.end * beatWidthPx
|
||||
drawRect(color.copy(alpha = 0.25f), Offset(x0, 0f), Size(x1 - x0, rulerHeightPx))
|
||||
drawLine(color, Offset(x0, 0f), Offset(x1, 0f), strokeWidth = 2f)
|
||||
val layout = glyphs.measure(letter, style)
|
||||
// If the region starts on a bar boundary a bar number sits
|
||||
// at x0; nudge the A/B letter past its (1- or 2-digit) width
|
||||
// plus a small gap so they never overlap.
|
||||
val letterX = if (region.start % beatsPerBar == 0) {
|
||||
val barNum = region.start / beatsPerBar + 1
|
||||
x0 + 2f + glyphs.measure("$barNum", rulerStyle).size.width + 3f
|
||||
} else {
|
||||
x0 + 2f
|
||||
}
|
||||
drawText(layout, topLeft = Offset(letterX, (rulerHeightPx - layout.size.height) / 2f))
|
||||
}
|
||||
drawRegionBand(arr.regionA, c.accent, "A", regionAStyle)
|
||||
drawRegionBand(arr.regionB, c.playhead, "B", regionBStyle)
|
||||
|
||||
// Lane cells.
|
||||
for (lane in 0 until ArrModel.LANE_COUNT) {
|
||||
val laneY = rulerHeightPx + lane * laneH
|
||||
for (beat in firstBeat until lastBeat) {
|
||||
val x = beat * beatWidthPx
|
||||
val filled = arr.patternAt(lane, beat) != ArrModel.EMPTY
|
||||
val onPlayhead = transport.isPlaying && beat == transport.currentBeat
|
||||
val inSel = selBeat0 >= 0 && lane in laneRange && beat in beatRange
|
||||
val fill = when {
|
||||
filled -> c.accent.copy(alpha = 0.85f)
|
||||
onPlayhead -> c.playhead.copy(alpha = 0.3f)
|
||||
inSel -> c.bar
|
||||
beat % beatsPerBar == 0 -> c.surface
|
||||
else -> c.grid.copy(alpha = 0.35f)
|
||||
}
|
||||
drawRect(fill, Offset(x + 0.5f, laneY + 0.5f), Size(beatWidthPx - 1f, laneH - 1f))
|
||||
}
|
||||
// Lane separator.
|
||||
drawLine(c.background, Offset(0f, laneY), Offset(w, laneY), strokeWidth = 1f)
|
||||
}
|
||||
|
||||
// Selection outline (rectangle across the selected lanes/beats).
|
||||
if (selBeat0 >= 0) {
|
||||
val bx0 = beatRange.first * beatWidthPx
|
||||
val bx1 = (beatRange.last + 1) * beatWidthPx
|
||||
val ly0 = rulerHeightPx + laneRange.first * laneH
|
||||
val ly1 = rulerHeightPx + (laneRange.last + 1) * laneH
|
||||
drawRect(
|
||||
c.accent,
|
||||
topLeft = Offset(bx0, ly0),
|
||||
size = Size(bx1 - bx0, ly1 - ly0),
|
||||
style = Stroke(width = 2f),
|
||||
)
|
||||
}
|
||||
|
||||
// Playhead marker across the whole roll.
|
||||
if (transport.isPlaying) {
|
||||
val px = transport.currentBeat * beatWidthPx
|
||||
drawLine(c.playhead, Offset(px, 0f), Offset(px, h), strokeWidth = 2f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LoopToolbar(
|
||||
vm = vm,
|
||||
arr = arr,
|
||||
hasSelection = hasSelection,
|
||||
selectionAllFilled = selectionAllFilled,
|
||||
onToggleCells = { toggleSelectedCells() },
|
||||
onTapA = { if (arr.regionA.isValid) clearRegion(arr.regionA) else assignRegion(arr.regionA) },
|
||||
onTapB = { if (arr.regionB.isValid) clearRegion(arr.regionB) else assignRegion(arr.regionB) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoopToolbar(
|
||||
vm: AppViewModel,
|
||||
arr: ArrModel,
|
||||
hasSelection: Boolean,
|
||||
selectionAllFilled: Boolean,
|
||||
onToggleCells: () -> Unit,
|
||||
onTapA: () -> Unit,
|
||||
onTapB: () -> Unit,
|
||||
) {
|
||||
val c = LocalRetro.current
|
||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision
|
||||
Row(
|
||||
Modifier.fillMaxWidth().background(c.surface).horizontalScroll(rememberScrollState()).padding(6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RetroButton("LOOP", active = arr.loopEnabled) {
|
||||
arr.loopEnabled = !arr.loopEnabled; vm.bumpForToolbar()
|
||||
}
|
||||
// Fill/clear the current cell selection (replaces tap-to-toggle on cells).
|
||||
// Fixed width so the button doesn't resize as the label toggles FILL/CLEAR.
|
||||
RetroButton(
|
||||
if (selectionAllFilled) "CLEAR" else "FILL",
|
||||
active = hasSelection, width = 72.dp, onClick = onToggleCells,
|
||||
)
|
||||
RegionControl("A", arr.regionA, onTap = onTapA, vm = vm)
|
||||
RegionControl("B", arr.regionB, onTap = onTapB, vm = vm)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RegionControl(name: String, region: LoopRegion, onTap: () -> Unit, vm: AppViewModel) {
|
||||
val c = LocalRetro.current
|
||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
// Highlighted when the region is set; tapping a highlighted button removes
|
||||
// the region, otherwise it assigns the current selection to it.
|
||||
RetroButton(name, active = region.isValid, onClick = onTap)
|
||||
// Repeat count updates live during playback (no transport reset).
|
||||
RetroButton("-") { vm.changeRegionRepeats(region, -1) }
|
||||
Text(" x${region.repeats} ", color = c.text, fontFamily = FontFamily.Monospace, fontSize = 11.sp)
|
||||
RetroButton("+") { vm.changeRegionRepeats(region, +1) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.ui.tracker
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.drawText
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import space.rcmd.android.sizzle.model.Cell
|
||||
import space.rcmd.android.sizzle.model.CellColumn
|
||||
import space.rcmd.android.sizzle.model.Pattern
|
||||
import space.rcmd.android.sizzle.model.Pitch
|
||||
import space.rcmd.android.sizzle.ui.AppViewModel
|
||||
import space.rcmd.android.sizzle.ui.components.GlyphCache
|
||||
import space.rcmd.android.sizzle.ui.theme.LocalRetro
|
||||
|
||||
/**
|
||||
* The demoscene pattern grid, drawn as ONE [Canvas] instead of a tree of ~180
|
||||
* per-cell composables. Collapsing it into a single draw pass (with a cached
|
||||
* [rememberTextMeasurer] so repeated cell strings like "···" are measured once)
|
||||
* removes the composition/layout churn that made the grid feel sluggish as the
|
||||
* playhead moved — the whole grid is now a couple of hundred draw calls per frame.
|
||||
*
|
||||
* Visuals: beat and bar rows are tinted distinctly; the four vertical tracks get
|
||||
* an alternating tint + separator lines; the cursor cell and playhead row are
|
||||
* highlighted. The only touch gesture is tap-to-focus; notes are entered with the
|
||||
* punch-in keyboard or external controllers.
|
||||
*/
|
||||
@Composable
|
||||
fun PatternGrid(vm: AppViewModel, modifier: Modifier = Modifier) {
|
||||
val c = LocalRetro.current
|
||||
// Read in composition so structural changes (edits, resize, active-block swap —
|
||||
// all of which bump revision) re-fetch `pattern`/`sig` and recompose. Playback
|
||||
// does NOT bump revision, so this never fires per frame during playback.
|
||||
val structureKey = vm.revision
|
||||
@Suppress("UNUSED_EXPRESSION") structureKey
|
||||
// Held as a State and read ONLY inside the draw/gesture lambdas below, never in
|
||||
// composition: a playhead tick then invalidates the draw phase alone, skipping
|
||||
// recomposition + layout entirely.
|
||||
val transportState = vm.transport.collectAsState()
|
||||
val pattern = vm.project.activePattern()
|
||||
val sig = vm.project.timeSignature
|
||||
val measurer = rememberTextMeasurer()
|
||||
val glyphs = remember(measurer) { GlyphCache(measurer) }
|
||||
|
||||
// Cache the text styles per palette (fontSize is resolved with the ambient density).
|
||||
val mono = FontFamily.Monospace
|
||||
val styleText = remember(c) { TextStyle(color = c.text, fontFamily = mono, fontSize = 12.sp) }
|
||||
val styleDimNote = remember(c) { TextStyle(color = c.textDim, fontFamily = mono, fontSize = 12.sp) }
|
||||
val styleVel = remember(c) { TextStyle(color = c.accent, fontFamily = mono, fontSize = 12.sp) }
|
||||
val styleChan = remember(c) { TextStyle(color = c.textDim, fontFamily = mono, fontSize = 12.sp) }
|
||||
val styleGutter = remember(c) { TextStyle(color = c.textDim, fontFamily = mono, fontSize = 11.sp) }
|
||||
|
||||
BoxWithConstraints(modifier.fillMaxSize().background(c.background)) {
|
||||
val density = LocalDensity.current
|
||||
val rowPx = with(density) { 20.dp.toPx() }
|
||||
val gutterPx = with(density) { 30.dp.toPx() }
|
||||
val widthPx = with(density) { maxWidth.toPx() }
|
||||
val heightPx = with(density) { maxHeight.toPx() }
|
||||
|
||||
val trackPx = (widthPx - gutterPx) / Pattern.TRACK_COUNT
|
||||
val notePx = trackPx * 0.5f
|
||||
val velPx = trackPx * 0.25f
|
||||
|
||||
val visibleRows = (heightPx / rowPx).toInt().coerceAtLeast(1)
|
||||
|
||||
// Which row sits at the top of the viewport. Computed on demand (not stored
|
||||
// in composition) so it can be read from the draw phase during playback and
|
||||
// from gestures at touch time, always against the live transport/cursor.
|
||||
fun firstRow(): Int {
|
||||
val t = transportState.value
|
||||
val focusLine = if (t.isPlaying) t.currentLine else vm.cursorLine
|
||||
return (focusLine - visibleRows / 2)
|
||||
.coerceIn(0, (pattern.length - visibleRows).coerceAtLeast(0))
|
||||
}
|
||||
|
||||
fun hitTest(x: Float, y: Float): Triple<Int, CellColumn, Int>? {
|
||||
if (x < gutterPx) return null
|
||||
val line = (firstRow() + (y / rowPx).toInt()).coerceIn(0, pattern.length - 1)
|
||||
val tx = x - gutterPx
|
||||
val track = (tx / trackPx).toInt().coerceIn(0, Pattern.TRACK_COUNT - 1)
|
||||
val within = tx - track * trackPx
|
||||
val col = when {
|
||||
within < notePx -> CellColumn.NOTE
|
||||
within < notePx + velPx -> CellColumn.VELOCITY
|
||||
else -> CellColumn.CHANNEL
|
||||
}
|
||||
return Triple(track, col, line)
|
||||
}
|
||||
|
||||
Canvas(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.pointerInput(pattern, widthPx, heightPx) {
|
||||
// Tap moves the cursor to the cell/column. Note entry is done with
|
||||
// the punch-in keyboard or external controllers — there is no
|
||||
// long-press cell editor.
|
||||
detectTapGestures { off ->
|
||||
hitTest(off.x, off.y)?.let { (t, col, line) -> vm.focusCell(t, line, col) }
|
||||
}
|
||||
},
|
||||
) {
|
||||
// Subscribe the DRAW PHASE to edit revisions. Model mutations (edit,
|
||||
// clear, DEL/paste) bump revision but touch no other snapshot state the
|
||||
// draw reads, so without this a delete would not invalidate the Canvas
|
||||
// even though composition re-ran (the draw lambda is memoised identical).
|
||||
// Reading it here redraws on every edit, draw-only.
|
||||
@Suppress("UNUSED_EXPRESSION") vm.revision
|
||||
// Draw-phase reads: transport (playhead) and, via firstRow(), the cursor
|
||||
// scroll position. A change to either invalidates THIS DRAW only — no
|
||||
// recomposition, no relayout — which is what keeps the playhead smooth.
|
||||
val transport = transportState.value
|
||||
val top = firstRow()
|
||||
// Selection rectangle (draw-phase reads → redraw as the cursor drags it).
|
||||
val selActive = vm.hasSelection
|
||||
val selTracks = vm.selTrackRange
|
||||
val selLines = vm.selLineRange
|
||||
|
||||
// 1) Alternating vertical track tints + separators (full height).
|
||||
for (t in 0 until Pattern.TRACK_COUNT) {
|
||||
val x0 = gutterPx + t * trackPx
|
||||
if (t % 2 == 1) {
|
||||
drawRect(c.grid.copy(alpha = 0.35f), Offset(x0, 0f), Size(trackPx, heightPx))
|
||||
}
|
||||
drawRect(c.grid, Offset(x0, 0f), Size(1f, heightPx)) // separator line
|
||||
}
|
||||
|
||||
// 2) Rows: beat/bar/playhead backgrounds + cursor + text.
|
||||
for (screen in 0 until visibleRows) {
|
||||
val line = top + screen
|
||||
if (line >= pattern.length) break
|
||||
val y = screen * rowPx
|
||||
|
||||
val rowColor = when {
|
||||
transport.isPlaying && line == transport.currentLine -> c.playhead.copy(alpha = 0.22f)
|
||||
line % sig.linesPerBar == 0 -> c.bar.copy(alpha = 0.7f)
|
||||
line % sig.linesPerBeat == 0 -> c.beat.copy(alpha = 0.7f)
|
||||
else -> Color.Transparent
|
||||
}
|
||||
if (rowColor != Color.Transparent) {
|
||||
drawRect(rowColor, Offset(0f, y), Size(widthPx, rowPx))
|
||||
}
|
||||
|
||||
// Gutter line number (hex).
|
||||
drawCentered(glyphs, line.toString(16).uppercase().padStart(2, '0'),
|
||||
styleGutter, 2f, y, gutterPx, rowPx)
|
||||
|
||||
for (t in 0 until Pattern.TRACK_COUNT) {
|
||||
val cell = pattern.cell(t, line)
|
||||
val baseX = gutterPx + t * trackPx
|
||||
val focused = vm.cursorTrack == t && vm.cursorLine == line
|
||||
|
||||
// Selection wash (whole cell, under the cursor highlight).
|
||||
if (selActive && t in selTracks && line in selLines) {
|
||||
drawRect(c.accent.copy(alpha = 0.18f), Offset(baseX, y), Size(trackPx, rowPx))
|
||||
}
|
||||
|
||||
// Cursor cell highlight.
|
||||
if (focused) {
|
||||
val (cx, cw) = when (vm.cursorColumn) {
|
||||
CellColumn.NOTE -> baseX to notePx
|
||||
CellColumn.VELOCITY -> (baseX + notePx) to velPx
|
||||
CellColumn.CHANNEL -> (baseX + notePx + velPx) to velPx
|
||||
}
|
||||
drawRect(c.accent.copy(alpha = 0.30f), Offset(cx, y), Size(cw, rowPx))
|
||||
}
|
||||
|
||||
// NOTE column reads bright even when empty (bright placeholder
|
||||
// dots, per spec). VEL/CHAN use dim dots when empty and their
|
||||
// coloured value when filled, so only the note column is bright.
|
||||
drawCentered(glyphs, noteText(cell), styleText, baseX + 2f, y, notePx, rowPx)
|
||||
drawCentered(glyphs, velText(cell), if (cell.isEmpty) styleDimNote else styleVel,
|
||||
baseX + notePx + 2f, y, velPx, rowPx)
|
||||
drawCentered(glyphs, chanText(cell), if (cell.isEmpty) styleDimNote else styleChan,
|
||||
baseX + notePx + velPx + 2f, y, velPx, rowPx)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Draw [text] vertically centred in a cell of the given width/height. */
|
||||
private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawCentered(
|
||||
glyphs: GlyphCache,
|
||||
text: String,
|
||||
style: TextStyle,
|
||||
x: Float,
|
||||
y: Float,
|
||||
cellWidth: Float,
|
||||
cellHeight: Float,
|
||||
) {
|
||||
val layout = glyphs.measure(text, style)
|
||||
drawText(layout, topLeft = Offset(x, y + (cellHeight - layout.size.height) / 2f))
|
||||
}
|
||||
|
||||
// ---- cell -> text helpers (the tracker's fixed-width formatting) ----
|
||||
private fun noteText(cell: Cell): String = when {
|
||||
cell.isEmpty -> "···"
|
||||
cell.isNoteOff -> "==="
|
||||
else -> Pitch.name(cell.note)
|
||||
}
|
||||
private fun velText(cell: Cell): String =
|
||||
if (cell.isEmpty) ".." else cell.velocity.toString(16).uppercase().padStart(2, '0')
|
||||
private fun chanText(cell: Cell): String =
|
||||
if (cell.isEmpty) ".." else cell.channel.toString().padStart(2, '0')
|
||||
@@ -0,0 +1,226 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.ui.tracker
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import space.rcmd.android.sizzle.model.TimeSignature
|
||||
import space.rcmd.android.sizzle.ui.AppViewModel
|
||||
import space.rcmd.android.sizzle.ui.isLandscape
|
||||
import space.rcmd.android.sizzle.ui.components.RetroButton
|
||||
import space.rcmd.android.sizzle.ui.components.RetroDropdown
|
||||
import space.rcmd.android.sizzle.ui.theme.LocalRetro
|
||||
|
||||
/**
|
||||
* The first tab. Two stacked halves:
|
||||
* - upper: the transport toolbar (3 rows) + the demoscene 4-track pattern grid.
|
||||
* - lower: the 8-lane arrangement piano-roll + its loop toolbar.
|
||||
*
|
||||
* The split is weighted toward the top so the pattern grid keeps room for at least
|
||||
* one full 16-tick bar (16 rows) once the three toolbar rows are accounted for; the
|
||||
* arrangement below is squashed to make that room.
|
||||
*/
|
||||
@Composable
|
||||
fun TrackerScreen(vm: AppViewModel) {
|
||||
if (isLandscape()) TrackerLandscape(vm) else TrackerPortrait(vm)
|
||||
}
|
||||
|
||||
/** Lower half of the tracker: the arrangement roll, or the punch-in keyboard. */
|
||||
@Composable
|
||||
private fun TrackerLower(vm: AppViewModel, modifier: Modifier) {
|
||||
Box(modifier) {
|
||||
if (vm.punchInMode) {
|
||||
space.rcmd.android.sizzle.ui.StackedKeyboard(vm, punchIn = true, Modifier.fillMaxSize())
|
||||
} else {
|
||||
ArrangementRoll(vm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Portrait: transport + grid stacked above the arrangement/keyboard (1.7 : 1). */
|
||||
@Composable
|
||||
private fun TrackerPortrait(vm: AppViewModel) {
|
||||
val c = LocalRetro.current
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
// Upper section: transport + pattern grid. Weighted heavier than the lower
|
||||
// half so >=16 grid rows fit under the (now three-row) toolbar.
|
||||
Box(Modifier.weight(1.7f).fillMaxWidth()) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
TransportToolbar(vm)
|
||||
PatternGrid(vm, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().height(2.dp).background(c.accent))
|
||||
TrackerLower(vm, Modifier.weight(1f).fillMaxWidth())
|
||||
}
|
||||
}
|
||||
|
||||
/** Landscape: transport bar on top, then the pattern grid BESIDE the arrangement/
|
||||
* keyboard so a wide, short screen isn't split into two cramped horizontal bands. */
|
||||
@Composable
|
||||
private fun TrackerLandscape(vm: AppViewModel) {
|
||||
val c = LocalRetro.current
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
TransportToolbar(vm)
|
||||
Box(Modifier.fillMaxWidth().height(2.dp).background(c.accent))
|
||||
// Grid normally gets the larger share; in punch-in mode the keyboard needs
|
||||
// extra width so its channel/octave/velocity toolbar fits without scrolling.
|
||||
val gridWeight = if (vm.punchInMode) 1f else 1.4f
|
||||
val lowerWeight = if (vm.punchInMode) 1.3f else 1f
|
||||
Row(Modifier.weight(1f).fillMaxWidth()) {
|
||||
Box(Modifier.weight(gridWeight).fillMaxHeight()) {
|
||||
PatternGrid(vm, Modifier.fillMaxSize())
|
||||
}
|
||||
Box(Modifier.width(2.dp).fillMaxHeight().background(c.accent))
|
||||
TrackerLower(vm, Modifier.weight(lowerWeight).fillMaxHeight())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The transport toolbar. Three scrolling rows in portrait; in landscape the
|
||||
* length/scale and edit groups are merged into one row so it's only two rows tall,
|
||||
* leaving the (short) height for the pattern grid. */
|
||||
@Composable
|
||||
private fun TransportToolbar(vm: AppViewModel) {
|
||||
val c = LocalRetro.current
|
||||
val landscape = isLandscape()
|
||||
|
||||
@Composable
|
||||
fun scrollRow(topPad: Boolean, content: @Composable RowScope.() -> Unit) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth()
|
||||
.then(if (topPad) Modifier.padding(top = 6.dp) else Modifier)
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxWidth().background(c.surface).padding(6.dp)) {
|
||||
scrollRow(topPad = false) { TransportButtons(vm) }
|
||||
if (landscape) {
|
||||
scrollRow(topPad = true) {
|
||||
PatternButtons(vm)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
EditButtons(vm)
|
||||
}
|
||||
} else {
|
||||
scrollRow(topPad = true) { PatternButtons(vm) }
|
||||
scrollRow(topPad = true) { EditButtons(vm) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Transport + tempo + time signature. */
|
||||
@Composable
|
||||
private fun TransportButtons(vm: AppViewModel) {
|
||||
val c = LocalRetro.current
|
||||
val transport by vm.transport.collectAsState()
|
||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // refresh the tempo readout
|
||||
val project = vm.project
|
||||
RetroButton("⧉ STOP", onClick = vm::stop)
|
||||
// Fixed width so the button doesn't resize as the label toggles.
|
||||
RetroButton(if (transport.isPlaying) "❚❚ PAUSE" else "▶ PLAY",
|
||||
active = transport.isPlaying, width = 112.dp, onClick = vm::playPause)
|
||||
// Tempo nudger: tap -/+ around a fixed-width readout.
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
RetroButton("-", onClick = { vm.setTempo(project.tempoBpm - 1) })
|
||||
Text(
|
||||
"${project.tempoBpm.toInt()} BPM",
|
||||
color = c.text, fontFamily = FontFamily.Monospace, fontSize = 12.sp,
|
||||
textAlign = TextAlign.Center, maxLines = 1,
|
||||
modifier = Modifier.width(72.dp).padding(vertical = 6.dp),
|
||||
)
|
||||
RetroButton("+", onClick = { vm.setTempo(project.tempoBpm + 1) })
|
||||
}
|
||||
RetroDropdown(
|
||||
label = "SIG",
|
||||
options = TimeSignature.entries,
|
||||
selected = project.timeSignature,
|
||||
optionLabel = { it.label },
|
||||
onSelect = vm::setTimeSignature,
|
||||
)
|
||||
}
|
||||
|
||||
/** Active block + length + scale + root. */
|
||||
@Composable
|
||||
private fun PatternButtons(vm: AppViewModel) {
|
||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // refresh blk/len/scale/root readouts
|
||||
val project = vm.project
|
||||
// Which block (0..7) the tracker edits — also settable from the arrangement lanes.
|
||||
RetroDropdown(
|
||||
label = "BLK",
|
||||
options = project.patterns.indices.toList(),
|
||||
selected = vm.activeBlock,
|
||||
optionLabel = { "${it + 1}" },
|
||||
onSelect = vm::setActiveBlock,
|
||||
)
|
||||
RetroDropdown(
|
||||
label = "LEN",
|
||||
options = project.timeSignature.lengthOptions,
|
||||
selected = project.activePattern().length,
|
||||
onSelect = vm::setPatternLength,
|
||||
)
|
||||
RetroDropdown(
|
||||
label = "SCALE",
|
||||
options = space.rcmd.android.sizzle.model.Scale.entries,
|
||||
selected = project.scale,
|
||||
optionLabel = { it.label },
|
||||
onSelect = { project.scale = it; vm.bumpForToolbar() },
|
||||
)
|
||||
RetroDropdown(
|
||||
label = "ROOT",
|
||||
options = (0..11).toList(),
|
||||
selected = project.rootNote,
|
||||
optionLabel = { space.rcmd.android.sizzle.model.Pitch.name(60 + it).dropLast(1) },
|
||||
onSelect = { project.rootNote = it; vm.bumpForToolbar() },
|
||||
)
|
||||
}
|
||||
|
||||
/** Selection + clipboard edit operations, note-off insert, and the KBD toggle.
|
||||
* Cut/copy/paste/delete use compact glyphs (✂ ⧉ ▤ ✕). */
|
||||
@Composable
|
||||
private fun EditButtons(vm: AppViewModel) {
|
||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // defeat strong-skipping so PASTE (canPaste) refreshes
|
||||
RetroButton("SEL", active = vm.selectMode, onClick = vm::toggleSelectMode)
|
||||
RetroButton("✂", onClick = vm::cutSelection) // cut
|
||||
RetroButton("⧉", onClick = vm::copySelection) // copy
|
||||
RetroButton("▤", active = vm.canPaste, onClick = vm::pasteClipboard) // paste
|
||||
RetroButton("✕", onClick = vm::deleteAndAdvance) // delete
|
||||
// Insert a note-off ("===") at the cursor.
|
||||
RetroButton("OFF ===", onClick = vm::noteOffFocused)
|
||||
// Rows the cursor jumps after a punch-in and per external up/down step.
|
||||
RetroDropdown(
|
||||
label = "SKIP",
|
||||
options = listOf(0, 1, 2, 4, 8),
|
||||
selected = vm.skipStep,
|
||||
onSelect = vm::updateSkipStep,
|
||||
)
|
||||
// Swap the bottom half between the arranger and the punch-in keyboard.
|
||||
RetroButton("⌨ KBD", active = vm.punchInMode, onClick = vm::togglePunchIn)
|
||||
}
|
||||
Reference in New Issue
Block a user