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:
Reactorcoremeltdown
2026-07-17 18:59:51 +02:00
parent a5fdc7b6c4
commit 4bd8aac4d5
61 changed files with 289 additions and 289 deletions

View 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())
}
}

View 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)
}
}
}

View File

@@ -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
}
}
}

File diff suppressed because it is too large Load Diff

View 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()
}
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View 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)
}
}

View 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)
}
}

View File

@@ -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)
}

View 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)
}
}

View File

@@ -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,
)

View File

@@ -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")
}
}

View File

@@ -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",
)
}
}

View File

@@ -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 }

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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",
)
}
}

View 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
}

View 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
}
}

View 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"
}
}

View 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])
}
}
}
}

View File

@@ -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)
}

View File

@@ -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")
}
}

View 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)
}
}

View File

@@ -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"
}
}

View 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))
}
}

View File

@@ -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"
}
}

View File

@@ -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)
}
}

View File

@@ -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
}

View 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)
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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++ }
}

View File

@@ -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()
}

View File

@@ -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))
}
}
}

View 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,
),
)
}
}
}

View 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]
}
}

View 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

View File

@@ -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)
}
},
)
}
}

View File

@@ -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) }
}
}

View File

@@ -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)

View File

@@ -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,
)
}

View File

@@ -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

View File

@@ -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 (ZM, QU) 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) }
}
}
}
}

View 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)
}
}

View File

@@ -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,
)
}
}

View File

@@ -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,
)
}
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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)
}
},
)
}
}

View File

@@ -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"))
}

View File

@@ -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) }
}
}

View File

@@ -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')

View File

@@ -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)
}