Merge branch 'feature/recording-landscape-fx-qol'
Master recording, landscape layouts, FX/arpeggiator fixes, theme library, and a batch of UX/persistence/cleanup improvements.
This commit is contained in:
@@ -49,7 +49,7 @@ class MainActivity : ComponentActivity() {
|
||||
app.project, app.audioEngine, app.inputRouter,
|
||||
app.gamepadInput, app.midiInput, app.keyboardInput,
|
||||
app.bindingStore, app.presetLibrary, app.songLibrary,
|
||||
app.settingsStore, app.applicationContext,
|
||||
app.settingsStore, app.themeLibrary, app.applicationContext,
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.reactorcoremeltdown.sizzletracker.io.PresetLibrary
|
||||
import com.reactorcoremeltdown.sizzletracker.io.ProjectStore
|
||||
import com.reactorcoremeltdown.sizzletracker.io.SettingsStore
|
||||
import com.reactorcoremeltdown.sizzletracker.io.SongLibrary
|
||||
import com.reactorcoremeltdown.sizzletracker.io.ThemeLibrary
|
||||
import com.reactorcoremeltdown.sizzletracker.model.Project
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.runBlocking
|
||||
@@ -50,6 +51,9 @@ class SizzleApp : Application() {
|
||||
/** 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")) }
|
||||
|
||||
|
||||
@@ -148,16 +148,15 @@ class AmbienceReverb(sampleRate: Int) : AudioEffect {
|
||||
}
|
||||
|
||||
private fun recompute(algo: Int, size: Float, decay: Float, hf: Float, lf: Float, diffusion: Float) {
|
||||
// Topology routing (per the original updateTopologyAndRouting()).
|
||||
// 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 = if (algo == 0) 0.3f else 0.3f; diffusionSens = 1f; bypassER = false }
|
||||
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 }
|
||||
}
|
||||
// Room1/Hall share topology gains but Room uses 0.3; keep Room=0.3, Hall=0.618.
|
||||
if (algo == 0 || algo == 1) apfGain = 0.3f
|
||||
|
||||
// Prime delay lengths: log-distributed between size-dependent bounds.
|
||||
val sizeCoeff = (size + 0.5f).coerceIn(0.5f, 2f)
|
||||
@@ -199,7 +198,7 @@ class AmbienceReverb(sampleRate: Int) : AudioEffect {
|
||||
|
||||
// Early reflections (Room/Hall only), scaled by room size.
|
||||
val pattern = ER_PATTERNS[algo]
|
||||
erCount = if (bypassER) 0 else pattern.size / 2
|
||||
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())
|
||||
@@ -264,8 +263,10 @@ class AmbienceReverb(sampleRate: Int) : AudioEffect {
|
||||
}
|
||||
System.arraycopy(nextFb, 0, fbVec, 0, N)
|
||||
|
||||
// Late field: sum → make-up gain → saturation.
|
||||
var late = out * 0.125f * makeup
|
||||
// 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)
|
||||
|
||||
@@ -313,8 +314,20 @@ class AmbienceReverb(sampleRate: Int) : AudioEffect {
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val N = 16
|
||||
// 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
|
||||
|
||||
@@ -325,10 +338,9 @@ class AmbienceReverb(sampleRate: Int) : AudioEffect {
|
||||
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)
|
||||
|
||||
// Original's fixed sign-flip pattern for the FWHT feedback matrix.
|
||||
// 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,
|
||||
1f, 1f, -1f, -1f, -1f, -1f, 1f, 1f,
|
||||
)
|
||||
|
||||
// ISM early-reflection tap patterns (delayMs, gain)… per algorithm. Plate,
|
||||
@@ -362,7 +374,7 @@ class AmbienceReverb(sampleRate: Int) : AudioEffect {
|
||||
fun db2lin(db: Float): Float = pow10(db / 20f)
|
||||
fun pow10(x: Float): Float = exp(x * 2.302585093f)
|
||||
|
||||
/** In-place size-16 Fast Walsh-Hadamard transform, normalised (÷4) → orthonormal. */
|
||||
/** In-place size-N Fast Walsh-Hadamard transform, normalised (÷√N) → orthonormal. */
|
||||
fun fwht(v: FloatArray) {
|
||||
var h = 1
|
||||
while (h < N) {
|
||||
@@ -378,7 +390,7 @@ class AmbienceReverb(sampleRate: Int) : AudioEffect {
|
||||
}
|
||||
h *= 2
|
||||
}
|
||||
for (k in 0 until N) v[k] *= 0.25f
|
||||
for (k in 0 until N) v[k] *= FWHT_NORM
|
||||
}
|
||||
|
||||
fun isPrime(n: Int): Boolean {
|
||||
|
||||
@@ -133,6 +133,8 @@ class AudioEngine(
|
||||
// ---- Shared position state (audio-thread only) ----
|
||||
private var samplesIntoLine = 0.0
|
||||
private var pendingTrigger = true
|
||||
/** Samples per tracker line, refreshed once per block (see [fillBlock]). */
|
||||
private var blockSamplesPerLine = 1.0
|
||||
|
||||
// ---- Pattern-loop mode ----
|
||||
private var currentLine = 0
|
||||
@@ -177,9 +179,6 @@ class AudioEngine(
|
||||
@Volatile var recordArmed = false; private set
|
||||
@Volatile private var recTailBars = 0
|
||||
|
||||
/** True while a master recording is capturing or finalizing. */
|
||||
val isMasterRecording: Boolean get() = masterRecorder.isActive
|
||||
|
||||
/** Arm/disarm recording and set the FX-tail length (bars) captured after a stop. */
|
||||
fun setRecordingArm(armed: Boolean, tailBars: Int) {
|
||||
recordArmed = armed
|
||||
@@ -317,18 +316,30 @@ class AudioEngine(
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- lifecycle
|
||||
/** True while the low-latency native (Oboe) output backend is selected. */
|
||||
@Volatile var useNativeOutput = false; private set
|
||||
private var nativeBridge: NativeAudioBridge? = null
|
||||
|
||||
/** Invoked (off the main thread) if the native Oboe backend can't initialise and
|
||||
* the engine falls back to the AudioTrack loop, with a user-facing message. */
|
||||
var onNativeInitError: ((String) -> Unit)? = null
|
||||
|
||||
fun start() {
|
||||
if (running) return
|
||||
running = true
|
||||
// Prefer the native Oboe backend when enabled and its library loaded;
|
||||
// otherwise (or if it fails to open) use the Kotlin AudioTrack loop.
|
||||
if (useNativeOutput && NativeAudioBridge.isAvailable()) {
|
||||
// The low-latency native (Oboe) backend is the default. Fall back to the
|
||||
// Kotlin AudioTrack loop ONLY if it can't initialise — and report why.
|
||||
if (NativeAudioBridge.isAvailable()) {
|
||||
val bridge = NativeAudioBridge(this)
|
||||
if (bridge.start(sampleRate, BLOCK_FRAMES)) { nativeBridge = bridge; return }
|
||||
if (bridge.start(sampleRate, BLOCK_FRAMES)) {
|
||||
nativeBridge = bridge
|
||||
return
|
||||
}
|
||||
onNativeInitError?.invoke(
|
||||
"Low-latency audio (Oboe) failed to start — falling back to standard playback.",
|
||||
)
|
||||
} else {
|
||||
onNativeInitError?.invoke(
|
||||
"Low-latency audio (Oboe) is unavailable on this device — using standard playback.",
|
||||
)
|
||||
}
|
||||
startAudioTrack()
|
||||
}
|
||||
@@ -365,15 +376,6 @@ class AudioEngine(
|
||||
}
|
||||
}
|
||||
|
||||
/** Switch output backend between Oboe (native) and AudioTrack, restarting. */
|
||||
fun setNativeOutput(enabled: Boolean) {
|
||||
if (useNativeOutput == enabled) return
|
||||
val wasRunning = running
|
||||
if (wasRunning) release()
|
||||
useNativeOutput = enabled
|
||||
if (wasRunning) start()
|
||||
}
|
||||
|
||||
// ---- USB / audio output device routing ----
|
||||
@Volatile private var preferredOutput: android.media.AudioDeviceInfo? = null
|
||||
|
||||
@@ -481,6 +483,9 @@ class AudioEngine(
|
||||
if (proj != null) {
|
||||
applyLfos(proj)
|
||||
updateFxParams(proj)
|
||||
// Tempo/signature only change at control rate, so derive the per-line
|
||||
// sample count once per block instead of re-dividing every sample.
|
||||
blockSamplesPerLine = samplesPerLine(proj)
|
||||
}
|
||||
val anySolo = proj?.mixer?.anySolo() ?: false
|
||||
// Snapshot the volatile chains once so a concurrent rebuild can't swap them
|
||||
@@ -507,6 +512,12 @@ class AudioEngine(
|
||||
var s = if (mc.audible(anySolo)) channelSum[ch] * mc.volume else 0f
|
||||
val chain = chains[ch]
|
||||
for (u in chain) s = u.effect.process(s)
|
||||
// Isolate a misbehaving effect: a non-finite sample from one
|
||||
// channel's FX must not poison the whole master sum (which would
|
||||
// silence everything until restart).
|
||||
if (!s.isFinite()) s = 0f
|
||||
// Optional per-bus soft-clip limiter (toolbar toggle).
|
||||
if (mc.limiter) s = softClip(s)
|
||||
mix += s
|
||||
}
|
||||
}
|
||||
@@ -536,6 +547,18 @@ class AudioEngine(
|
||||
}
|
||||
}
|
||||
|
||||
// Lock-free xorshift RNG for the audio thread. Math.random() is backed by a
|
||||
// single synchronized java.util.Random, so calling it on the real-time thread
|
||||
// risks lock contention / priority inversion; this never blocks or allocates.
|
||||
private var rngState = 0x9E3779B9.toInt()
|
||||
/** Next pseudo-random float in [0,1). */
|
||||
private fun nextRandomUnit(): Float {
|
||||
var s = rngState
|
||||
s = s xor (s shl 13); s = s xor (s ushr 17); s = s xor (s shl 5)
|
||||
rngState = s
|
||||
return (s.toLong() and 0xFFFFFFFFL).toFloat() * 2.3283064e-10f
|
||||
}
|
||||
|
||||
// ---- MIDI LFO modulation ----
|
||||
private val lfoPhase = DoubleArray(Project.TOOLBOX_SLOTS)
|
||||
private val lfoSampleHold = DoubleArray(Project.TOOLBOX_SLOTS) { Math.random() * 2 - 1 }
|
||||
@@ -555,16 +578,29 @@ class AudioEngine(
|
||||
val target = lfo.string("target")
|
||||
val sep = target.indexOf(':')
|
||||
if (sep <= 0) continue
|
||||
val tIdx = target.substring(0, sep).toIntOrNull() ?: continue
|
||||
if (tIdx == i) continue
|
||||
val key = target.substring(sep + 1)
|
||||
// Parse the target slot index from target[0 until sep] without a substring
|
||||
// or a boxed Int? — this runs every block for each active LFO.
|
||||
var tIdx = 0
|
||||
var validIdx = true
|
||||
for (ci in 0 until sep) {
|
||||
val ch = target[ci]
|
||||
if (ch < '0' || ch > '9') { validIdx = false; break }
|
||||
tIdx = tIdx * 10 + (ch - '0')
|
||||
}
|
||||
if (!validIdx || tIdx == i) continue
|
||||
val tSlot = proj.toolbox.getOrNull(tIdx) ?: continue
|
||||
val spec = tSlot.type?.params?.firstOrNull { it.key == key } ?: continue
|
||||
val params = tSlot.type?.params ?: continue
|
||||
val key = target.substring(sep + 1)
|
||||
// Indexed scan (not firstOrNull {}) so no Iterator allocates per block.
|
||||
var specIdx = -1
|
||||
for (pi in params.indices) if (params[pi].key == key) { specIdx = pi; break }
|
||||
if (specIdx < 0) continue
|
||||
val spec = params[specIdx]
|
||||
|
||||
val div = TimeDivision.fromIndex(lfo.float("division", 3f).toInt())
|
||||
val freq = 1.0 / (div.beatFraction * secondsPerBeat).coerceAtLeast(1e-6)
|
||||
val advanced = lfoPhase[i] + freq * blockSeconds
|
||||
if (advanced >= 1.0) lfoSampleHold[i] = Math.random() * 2 - 1
|
||||
if (advanced >= 1.0) lfoSampleHold[i] = nextRandomUnit() * 2.0 - 1.0
|
||||
lfoPhase[i] = advanced % 1.0
|
||||
val phase = (lfoPhase[i] + lfo.float("phase", 0f)) % 1.0
|
||||
|
||||
@@ -592,9 +628,11 @@ class AudioEngine(
|
||||
// ----- Pattern-loop mode (Tracker tab audition) -----
|
||||
private fun advancePatternLoop(proj: Project) {
|
||||
val pattern = proj.activePattern()
|
||||
val samplesPerLine = samplesPerLine(proj)
|
||||
val samplesPerLine = blockSamplesPerLine
|
||||
|
||||
if (pendingTrigger) {
|
||||
val linesPerBar = proj.timeSignature.linesPerBar
|
||||
if (linesPerBar > 0 && currentLine % linesPerBar == 0) resetBarArps()
|
||||
triggerPatternLine(proj, pattern, currentLine)
|
||||
pendingTrigger = false
|
||||
}
|
||||
@@ -644,8 +682,24 @@ class AudioEngine(
|
||||
val octaves = arpSlot.float("octaves", 1f).toInt()
|
||||
val div = TimeDivision.fromIndex(arpSlot.float("division", 3f).toInt())
|
||||
val stepSamples = div.beatFraction * (60.0 / proj.tempoBpm) * sampleRate
|
||||
arp.start(note, velocity, octaves, arpSlot.string("mode", "Up"), stepSamples)
|
||||
playNote(proj, bus, arp.currentNote(), velocity)
|
||||
val mode = arpSlot.string("mode", "Up")
|
||||
val resetBar = arpSlot.string("resetbar", "Off") == "On"
|
||||
if (arp.active && arp.base == note) {
|
||||
// The same note is already arpeggiating — this trigger is the
|
||||
// pattern looping back over a still-held note. Keep the running
|
||||
// phase (just refresh params) so the pulse stays steady across
|
||||
// the loop point instead of slipping when the loop length isn't
|
||||
// an exact multiple of the arp step (dotted/triplet divisions).
|
||||
arp.refresh(octaves, mode, stepSamples, resetBar)
|
||||
} else {
|
||||
arp.start(note, velocity, octaves, mode, stepSamples, resetBar)
|
||||
// The arpeggiator is monophonic (it cycles octaves of ONE note),
|
||||
// so it must not stack notes. Releasing the bus first means a
|
||||
// chord entered on the same line collapses to a single
|
||||
// arpeggiated note (the last one), not all of them at once.
|
||||
releaseBus(bus)
|
||||
playNote(proj, bus, arp.currentNote(), velocity)
|
||||
}
|
||||
} else {
|
||||
arp.stop()
|
||||
playNote(proj, bus, note, velocity)
|
||||
@@ -729,6 +783,16 @@ class AudioEngine(
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** Realign every active "reset on bar" arpeggiator to step 0 — called on each
|
||||
* bar downbeat, before that line's own note triggers (so a fresh note-on on the
|
||||
* downbeat isn't double-fired). */
|
||||
private fun resetBarArps() {
|
||||
for (i in arps.indices) {
|
||||
val a = arps[i]
|
||||
if (a.active && a.resetOnBar) a.resetToBar()
|
||||
}
|
||||
}
|
||||
|
||||
/** The channel's Arpeggiator slot (first one found in its FX slots), or null. */
|
||||
private fun channelArpSlot(proj: Project, track: Int): ToolboxSlot? {
|
||||
for (idx in proj.mixer.channels[track].fxSlots) {
|
||||
@@ -746,8 +810,17 @@ class AudioEngine(
|
||||
if (!a.active) continue
|
||||
a.samplesLeft -= 1.0
|
||||
if (a.samplesLeft <= 0.0) {
|
||||
a.advance()
|
||||
playNote(proj, i % Pattern.TRACK_COUNT, a.currentNote(), a.velocity)
|
||||
// A pending bar reset plays step 0 as-is; otherwise advance the pattern.
|
||||
if (!a.consumePendingReset()) a.advance()
|
||||
val bus = i % Pattern.TRACK_COUNT
|
||||
// Release the previous step before sounding the next one. Without this
|
||||
// an arp on a *sustaining* instrument (e.g. an SF2/XI with sustain=1
|
||||
// and a long sample) stacks a fresh full-level voice every step until
|
||||
// the whole 8-voice pool is held wide open — a sustained wall of sound
|
||||
// that overloads any following FX (a reverb's make-up gain then slams
|
||||
// the limiter). Releasing keeps the arp monophonic with natural tails.
|
||||
releaseBus(bus)
|
||||
playNote(proj, bus, a.currentNote(), a.velocity)
|
||||
a.samplesLeft += a.stepSamples
|
||||
}
|
||||
}
|
||||
@@ -759,13 +832,46 @@ class AudioEngine(
|
||||
private var octaves = 1; private var mode = 0
|
||||
var stepSamples = 0.0; var samplesLeft = 0.0
|
||||
private var step = 0; private var dir = 1
|
||||
|
||||
fun start(note: Int, vel: Int, octs: Int, modeStr: String, stepSamp: Double) {
|
||||
active = true; base = note; velocity = vel; octaves = octs.coerceIn(1, 4)
|
||||
mode = when (modeStr) { "Down" -> 1; "UpDown" -> 2; "Random" -> 3; else -> 0 }
|
||||
stepSamples = stepSamp.coerceAtLeast(1.0); samplesLeft = stepSamples; step = 0; dir = 1
|
||||
/** When true, [resetToBar] realigns the pattern to step 0 on each bar downbeat. */
|
||||
var resetOnBar = false
|
||||
// Set by [resetToBar] so the next fire plays step 0 without first advancing.
|
||||
private var pendingReset = false
|
||||
// Per-arp lock-free RNG (Random mode) — no Math.random() on the audio thread.
|
||||
private var rng = 0x2545F491.toInt()
|
||||
private fun rnd(bound: Int): Int {
|
||||
var s = rng
|
||||
s = s xor (s shl 13); s = s xor (s ushr 17); s = s xor (s shl 5)
|
||||
rng = s
|
||||
return ((s.toLong() and 0xFFFFFFFFL) % bound).toInt()
|
||||
}
|
||||
|
||||
fun start(note: Int, vel: Int, octs: Int, modeStr: String, stepSamp: Double, resetBar: Boolean) {
|
||||
active = true; base = note; velocity = vel; octaves = octs.coerceIn(1, 4)
|
||||
mode = modeOf(modeStr)
|
||||
stepSamples = stepSamp.coerceAtLeast(1.0); samplesLeft = stepSamples; step = 0; dir = 1
|
||||
resetOnBar = resetBar; pendingReset = false
|
||||
}
|
||||
|
||||
/** Update params WITHOUT resetting the running phase — used when the pattern
|
||||
* loops over a still-arpeggiating note so the steady pulse continues
|
||||
* seamlessly across the loop boundary. */
|
||||
fun refresh(octs: Int, modeStr: String, stepSamp: Double, resetBar: Boolean) {
|
||||
octaves = octs.coerceIn(1, 4)
|
||||
mode = modeOf(modeStr)
|
||||
stepSamples = stepSamp.coerceAtLeast(1.0)
|
||||
resetOnBar = resetBar
|
||||
}
|
||||
|
||||
/** Realign the pattern to step 0 at a bar downbeat: the next [advanceArps]
|
||||
* fire (scheduled immediately) plays step 0 without advancing first. */
|
||||
fun resetToBar() { step = 0; dir = 1; samplesLeft = 0.0; pendingReset = true }
|
||||
|
||||
/** True (once) if a bar reset is pending, so the caller skips [advance]. */
|
||||
fun consumePendingReset(): Boolean { val p = pendingReset; pendingReset = false; return p }
|
||||
|
||||
private fun modeOf(modeStr: String): Int =
|
||||
when (modeStr) { "Down" -> 1; "UpDown" -> 2; "Random" -> 3; else -> 0 }
|
||||
|
||||
fun stop() { active = false; base = -1 }
|
||||
fun currentNote(): Int = (base + 12 * step).coerceIn(0, 127)
|
||||
|
||||
@@ -777,7 +883,7 @@ class AudioEngine(
|
||||
if (step >= octaves - 1) { step = octaves - 1; dir = -1 }
|
||||
else if (step <= 0) { step = 0; dir = 1 }
|
||||
}
|
||||
3 -> step = if (octaves > 1) (Math.random() * octaves).toInt() else 0
|
||||
3 -> step = if (octaves > 1) rnd(octaves) else 0
|
||||
else -> step = (step + 1) % octaves
|
||||
}
|
||||
}
|
||||
@@ -789,7 +895,9 @@ class AudioEngine(
|
||||
for (idx in proj.mixer.channels[track].fxSlots) {
|
||||
if (idx < 0) continue
|
||||
val slot = proj.toolbox.getOrNull(idx) ?: continue
|
||||
if (slot.type == ToolboxType.TRANSPOSER) semis += slot.float("semitones", 0f).toInt()
|
||||
if (slot.type == ToolboxType.TRANSPOSER) {
|
||||
semis += slot.float("semitones", 0f).toInt() + slot.float("octaves", 0f).toInt() * 12
|
||||
}
|
||||
}
|
||||
return semis
|
||||
}
|
||||
@@ -877,10 +985,14 @@ class AudioEngine(
|
||||
val list = playlist
|
||||
if (list.isEmpty()) { playing = false; publish(); return }
|
||||
val linesPerBeat = proj.timeSignature.linesPerBeat
|
||||
val samplesPerLine = samplesPerLine(proj)
|
||||
val samplesPerLine = blockSamplesPerLine
|
||||
val beat = list[playlistIndex.coerceIn(0, list.size - 1)]
|
||||
|
||||
if (pendingTrigger) {
|
||||
// Bar downbeat = first line of a beat that starts a bar (in arrangement
|
||||
// beats). Reset "reset on bar" arps before this line's own triggers.
|
||||
val beatsPerBar = proj.timeSignature.beatsPerBar
|
||||
if (lineInBeat == 0 && beatsPerBar > 0 && beat % beatsPerBar == 0) resetBarArps()
|
||||
triggerArrangementLine(proj, arr, beat, lineInBeat, linesPerBeat)
|
||||
pendingTrigger = false
|
||||
}
|
||||
@@ -922,6 +1034,7 @@ class AudioEngine(
|
||||
// the whole bus). See [triggerPatternLine].
|
||||
for (pass in 0..1) {
|
||||
for (lane in 0 until Arrangement.LANE_COUNT) {
|
||||
if (arr.laneMuted[lane]) continue // muted MIDI lane — skip its blocks
|
||||
val p = arr.patternAt(lane, beat)
|
||||
if (p == Arrangement.EMPTY) continue
|
||||
val pat = proj.pattern(p) ?: continue
|
||||
|
||||
@@ -33,9 +33,6 @@ class MasterRecorder(private val sampleRate: Int) {
|
||||
@Volatile private var active = false // begin()..completion; guards re-entry
|
||||
private var writer: Thread? = null
|
||||
|
||||
/** True while the audio thread should keep pushing samples. */
|
||||
val isCapturing: Boolean get() = capturing
|
||||
|
||||
/** True from [begin] until the writer thread has fully finalized the file. */
|
||||
val isActive: Boolean get() = active
|
||||
|
||||
|
||||
@@ -22,14 +22,21 @@ object SampleStore {
|
||||
|
||||
fun put(id: String, sample: Sample) { samples[id] = sample }
|
||||
fun get(id: String): Sample? = if (id.isEmpty()) null else samples[id]
|
||||
fun has(id: String): Boolean = samples.containsKey(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? {
|
||||
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"
|
||||
@@ -47,6 +54,7 @@ object SampleStore {
|
||||
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 " -> {
|
||||
@@ -59,14 +67,16 @@ object SampleStore {
|
||||
}
|
||||
"data" -> { dataOffset = bb.position(); dataLength = size }
|
||||
}
|
||||
if (dataOffset >= 0 && audioFormat != 0) { /* keep scanning is fine */ }
|
||||
if (next <= bb.limit()) bb.position(next + (size and 1)) else break // chunks are word-aligned
|
||||
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)
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import java.nio.ByteOrder
|
||||
*/
|
||||
object SoundFontLoader {
|
||||
|
||||
class Loaded(val sample: SampleStore.Sample, val rootNote: Int, val kind: String)
|
||||
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
|
||||
@@ -26,7 +26,7 @@ object SoundFontLoader {
|
||||
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, "WAV") }
|
||||
else -> SampleStore.decodeWav(bytes)?.let { Loaded(it, 60) }
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- SF2
|
||||
@@ -78,7 +78,7 @@ object SoundFontLoader {
|
||||
|
||||
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, "SF2")
|
||||
return Loaded(SampleStore.Sample(out, rate), root)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- XI
|
||||
@@ -125,7 +125,7 @@ object SoundFontLoader {
|
||||
// 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, "XI")
|
||||
return Loaded(SampleStore.Sample(out, 8363), root)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- helpers
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
package com.reactorcoremeltdown.sizzletracker.audio
|
||||
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -101,8 +98,5 @@ class SynthVoice(private val sampleRate: Int) {
|
||||
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)
|
||||
|
||||
@Suppress("unused") private const val TWO_PI = 2.0 * PI
|
||||
@Suppress("unused") private fun s(x: Double) = sin(x) // kept for future waves
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,8 +152,9 @@ class PresetLibrary(private val baseDir: File) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Load the named preset for [type] into [slot]. */
|
||||
fun load(slot: ToolboxSlot, type: ToolboxType, name: String): Boolean {
|
||||
/** 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
|
||||
@@ -163,8 +164,8 @@ class PresetLibrary(private val baseDir: File) {
|
||||
ToolboxType.SOUNDFONT -> resolveSoundFont(slot, dir)
|
||||
else -> {}
|
||||
}
|
||||
return true
|
||||
}
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
|
||||
/**
|
||||
* Import a preset supplied as raw text (e.g. picked from Files). Loads it into
|
||||
@@ -172,7 +173,7 @@ class PresetLibrary(private val baseDir: File) {
|
||||
* 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 {
|
||||
fun importText(slot: ToolboxSlot, text: String): Boolean = runCatching {
|
||||
if (!PresetIo.import(slot, text)) return false
|
||||
val type = slot.type ?: return false
|
||||
when (type) {
|
||||
@@ -181,8 +182,8 @@ class PresetLibrary(private val baseDir: File) {
|
||||
else -> {}
|
||||
}
|
||||
save(slot, slot.name.ifBlank { type.displayName })
|
||||
return true
|
||||
}
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
|
||||
/**
|
||||
* Import a preset packaged as a `.zip` bundle (a `.szp` plus its sample WAVs) —
|
||||
@@ -190,22 +191,30 @@ class PresetLibrary(private val baseDir: File) {
|
||||
* 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 {
|
||||
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) {
|
||||
if (!entry.isDirectory) {
|
||||
// Use only the base name to guard against zip-slip path traversal.
|
||||
val target = File(scratch, File(entry.name).name)
|
||||
// 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) }
|
||||
if (target.name.endsWith(PRESET_EXT)) presetText = target.readText()
|
||||
// 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) {
|
||||
@@ -215,8 +224,8 @@ class PresetLibrary(private val baseDir: File) {
|
||||
}
|
||||
// Persist a self-contained copy into the real library folder.
|
||||
save(slot, slot.name.ifBlank { slot.type?.displayName ?: "preset" })
|
||||
return true
|
||||
}
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
|
||||
/**
|
||||
* Produce a shareable file for a stored preset: a self-contained `.zip`
|
||||
|
||||
@@ -45,7 +45,8 @@ object ProjectIo {
|
||||
}
|
||||
|
||||
val arr = project.arrangement
|
||||
appendLine("[ARRANGEMENT len=${arr.lengthBeats} loop=${arr.loopEnabled.b()}]")
|
||||
val mutes = arr.laneMuted.joinToString("") { if (it) "1" else "0" }
|
||||
appendLine("[ARRANGEMENT len=${arr.lengthBeats} loop=${arr.loopEnabled.b()} mutes=$mutes]")
|
||||
for (lane in arr.slots.indices) {
|
||||
for (beat in 0 until arr.lengthBeats) {
|
||||
val pid = arr.slots[lane][beat]
|
||||
@@ -59,7 +60,7 @@ object ProjectIo {
|
||||
project.mixer.channels.forEach { ch ->
|
||||
appendLine(
|
||||
"${ch.index},${ch.instrumentSlot},${ch.fxSlots.joinToString(",")}," +
|
||||
"${ch.midiChannel},${ch.volume},${ch.mute.b()},${ch.solo.b()}",
|
||||
"${ch.midiChannel},${ch.volume},${ch.mute.b()},${ch.solo.b()},${ch.limiter.b()}",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -73,9 +74,6 @@ object ProjectIo {
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- read
|
||||
/** Parse [text] into a fresh [Project]. Malformed lines are skipped. */
|
||||
fun load(text: String): Project = loadInto(Project(), text)
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -86,15 +84,19 @@ object ProjectIo {
|
||||
project.patterns.clear()
|
||||
for (lane in project.arrangement.slots.indices)
|
||||
project.arrangement.slots[lane].fill(com.reactorcoremeltdown.sizzletracker.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.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") -> {
|
||||
@@ -110,6 +112,10 @@ object ProjectIo {
|
||||
val attrs = parseAttrs(line)
|
||||
project.arrangement.lengthBeats = attrs["len"]?.toIntOrNull() ?: 64
|
||||
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))
|
||||
@@ -123,6 +129,7 @@ object ProjectIo {
|
||||
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.
|
||||
@@ -171,7 +178,10 @@ object ProjectIo {
|
||||
private fun applyArrangementSlot(p: Project, line: String) {
|
||||
val f = line.split(',')
|
||||
if (f.size < 3) return
|
||||
p.arrangement.set(f[0].toInt(), f[1].toInt(), f[2].toInt())
|
||||
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: com.reactorcoremeltdown.sizzletracker.model.LoopRegion, a: Map<String, String>) {
|
||||
@@ -186,12 +196,13 @@ object ProjectIo {
|
||||
if (f.size < 10) return
|
||||
val idx = f[0].toIntOrNull() ?: return
|
||||
val ch = p.mixer.channels.getOrNull(idx) ?: return
|
||||
ch.instrumentSlot = f[1].toInt()
|
||||
for (i in 0 until Mixer.FX_SLOTS) ch.fxSlots[i] = f[2 + i].toInt()
|
||||
ch.midiChannel = f[6].toInt()
|
||||
ch.volume = f[7].toFloat()
|
||||
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) {
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.reactorcoremeltdown.sizzletracker.io
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
@@ -13,14 +12,17 @@ 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, audio backend). */
|
||||
/** App-level settings that persist across restarts (theme, recording, keyboard). */
|
||||
data class AppSettings(
|
||||
val paletteName: String? = null,
|
||||
val nativeEngine: Boolean = false,
|
||||
/** 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,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -38,16 +40,16 @@ class SettingsStore(private val context: Context) {
|
||||
val prefs = context.settingsDataStore.data.first()
|
||||
return AppSettings(
|
||||
paletteName = prefs[PALETTE],
|
||||
nativeEngine = prefs[NATIVE_ENGINE] ?: false,
|
||||
recordDirUri = prefs[RECORD_DIR],
|
||||
recordTailBars = prefs[RECORD_TAIL] ?: 2,
|
||||
kbdChannel = prefs[KBD_CHANNEL] ?: 0,
|
||||
kbdOctave = prefs[KBD_OCTAVE] ?: 3,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun save(paletteName: String, nativeEngine: Boolean) {
|
||||
suspend fun save(paletteName: String) {
|
||||
context.settingsDataStore.edit { prefs ->
|
||||
prefs[PALETTE] = paletteName
|
||||
prefs[NATIVE_ENGINE] = nativeEngine
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,10 +62,19 @@ class SettingsStore(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 NATIVE_ENGINE = booleanPreferencesKey("nativeEngine")
|
||||
val RECORD_DIR = stringPreferencesKey("recordDirUri")
|
||||
val RECORD_TAIL = intPreferencesKey("recordTailBars")
|
||||
val KBD_CHANNEL = intPreferencesKey("kbdChannel")
|
||||
val KBD_OCTAVE = intPreferencesKey("kbdOctave")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,13 +76,12 @@ object SngFormat {
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- read
|
||||
fun import(text: String): Project = importInto(Project(), text)
|
||||
|
||||
/** 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
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.reactorcoremeltdown.sizzletracker.io
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.theme.RetroPalette
|
||||
|
||||
/**
|
||||
* Reads and writes a colour [RetroPalette] as plain, human-editable text — the same
|
||||
* `SIZZLE-THEME 1` format the Settings tab exports, so a person can hand-tweak a
|
||||
* theme and it round-trips losslessly:
|
||||
*
|
||||
* SIZZLE-THEME 1
|
||||
* NAME=Amber CRT
|
||||
* background=#0B0D0E
|
||||
* surface=#15181A
|
||||
* ...
|
||||
*
|
||||
* Parsing is lenient about individual colour lines (a missing/garbled colour falls
|
||||
* back to the default palette's value) but rejects text that isn't a theme at all,
|
||||
* so importing a wrong file fails cleanly instead of producing a broken palette.
|
||||
*/
|
||||
object ThemeIo {
|
||||
const val HEADER = "SIZZLE-THEME 1"
|
||||
|
||||
fun export(p: RetroPalette): String = buildString {
|
||||
appendLine(HEADER)
|
||||
appendLine("NAME=${p.name}")
|
||||
appendLine("background=${hex(p.background)}")
|
||||
appendLine("surface=${hex(p.surface)}")
|
||||
appendLine("grid=${hex(p.grid)}")
|
||||
appendLine("text=${hex(p.text)}")
|
||||
appendLine("textDim=${hex(p.textDim)}")
|
||||
appendLine("accent=${hex(p.accent)}")
|
||||
appendLine("beat=${hex(p.beat)}")
|
||||
appendLine("bar=${hex(p.bar)}")
|
||||
appendLine("playhead=${hex(p.playhead)}")
|
||||
appendLine("danger=${hex(p.danger)}")
|
||||
}
|
||||
|
||||
/** Parse theme [text] into a [RetroPalette], or null if it isn't a valid theme. */
|
||||
fun parse(text: String): RetroPalette? {
|
||||
val map = HashMap<String, String>()
|
||||
var sawHeader = false
|
||||
text.lineSequence().map { it.trim() }.filter { it.isNotEmpty() }.forEach { line ->
|
||||
if (line == HEADER) { sawHeader = true; return@forEach }
|
||||
val eq = line.indexOf('=')
|
||||
if (eq > 0) map[line.substring(0, eq).trim()] = line.substring(eq + 1).trim()
|
||||
}
|
||||
if (!sawHeader) return null
|
||||
val name = map["NAME"]?.takeIf { it.isNotBlank() } ?: return null
|
||||
val d = RetroPalette.AMBER // per-colour fallback
|
||||
return RetroPalette(
|
||||
name = name,
|
||||
background = color(map["background"], d.background),
|
||||
surface = color(map["surface"], d.surface),
|
||||
grid = color(map["grid"], d.grid),
|
||||
text = color(map["text"], d.text),
|
||||
textDim = color(map["textDim"], d.textDim),
|
||||
accent = color(map["accent"], d.accent),
|
||||
beat = color(map["beat"], d.beat),
|
||||
bar = color(map["bar"], d.bar),
|
||||
playhead = color(map["playhead"], d.playhead),
|
||||
danger = color(map["danger"], d.danger),
|
||||
)
|
||||
}
|
||||
|
||||
private fun hex(c: Color): String =
|
||||
"#%02X%02X%02X".format((c.red * 255).toInt(), (c.green * 255).toInt(), (c.blue * 255).toInt())
|
||||
|
||||
/** Parse "#RRGGBB" (or "RRGGBB") into an opaque colour; [fallback] on any error. */
|
||||
private fun color(s: String?, fallback: Color): Color {
|
||||
val hex = s?.trim()?.removePrefix("#") ?: return fallback
|
||||
if (hex.length != 6) return fallback
|
||||
val rgb = hex.toLongOrNull(16) ?: return fallback
|
||||
return Color(0xFF000000L or (rgb and 0xFFFFFF))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.reactorcoremeltdown.sizzletracker.io
|
||||
|
||||
import com.reactorcoremeltdown.sizzletracker.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"
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,12 @@ 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]
|
||||
|
||||
/** How many beats are actually in use; the roll can grow up to [MAX_BEATS]. */
|
||||
var lengthBeats: Int = 64
|
||||
|
||||
@@ -62,5 +68,4 @@ class LoopRegion(
|
||||
var repeats: Int = 2,
|
||||
) {
|
||||
val isValid: Boolean get() = enabled && end > start
|
||||
val spanBeats: Int get() = (end - start).coerceAtLeast(0)
|
||||
}
|
||||
|
||||
@@ -11,8 +11,12 @@ class Mixer {
|
||||
// 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. */
|
||||
fun anySolo(): Boolean = channels.any { it.solo }
|
||||
/** 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
|
||||
@@ -35,6 +39,9 @@ class MixerChannel(
|
||||
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)
|
||||
|
||||
@@ -34,7 +34,15 @@ class Project {
|
||||
/** 16 toolbox slots (tab 3). Index in the list == on-screen slot number. */
|
||||
val toolbox: MutableList<ToolboxSlot> = MutableList(TOOLBOX_SLOTS) { ToolboxSlot(it) }
|
||||
|
||||
fun pattern(id: Int): Pattern? = patterns.firstOrNull { it.id == id }
|
||||
/** 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
|
||||
|
||||
@@ -79,11 +79,17 @@ enum class ToolboxType(
|
||||
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("semitones", "Semitones", 0f, -24f, 24f, isInt = true)),
|
||||
listOf(
|
||||
ParamSpec("octaves", "Octaves", 0f, -4f, 4f, isInt = true),
|
||||
ParamSpec("semitones", "Semitones", 0f, -24f, 24f, isInt = true),
|
||||
),
|
||||
),
|
||||
LFO(
|
||||
ToolboxKind.EFFECT, "MIDI LFO",
|
||||
|
||||
@@ -11,11 +11,18 @@ 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
|
||||
@@ -48,33 +55,86 @@ fun SizzleApp(vm: AppViewModel) {
|
||||
// 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) }
|
||||
Column(Modifier.fillMaxSize().background(c.background)) {
|
||||
// ----- Active screen -----
|
||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||
when (vm.selectedTab) {
|
||||
0 -> TrackerScreen(vm)
|
||||
1 -> MixerScreen(vm)
|
||||
2 -> ToolboxScreen(vm)
|
||||
3 -> SettingsScreen(vm)
|
||||
}
|
||||
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) }
|
||||
}
|
||||
// ----- Standard Material navigation bar -----
|
||||
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,
|
||||
),
|
||||
)
|
||||
}
|
||||
} 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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ class AppViewModel(
|
||||
private val presetLibrary: com.reactorcoremeltdown.sizzletracker.io.PresetLibrary,
|
||||
private val songLibrary: com.reactorcoremeltdown.sizzletracker.io.SongLibrary,
|
||||
private val settingsStore: com.reactorcoremeltdown.sizzletracker.io.SettingsStore,
|
||||
private val themeLibrary: com.reactorcoremeltdown.sizzletracker.io.ThemeLibrary,
|
||||
private val appContext: android.content.Context,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -77,7 +78,8 @@ class AppViewModel(
|
||||
// ----- 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 (1/2/4/8). */
|
||||
/** 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
|
||||
@@ -88,12 +90,17 @@ class AppViewModel(
|
||||
/** Velocity for keyboard notes (0..127). */
|
||||
var kbdVelocity by mutableIntStateOf(Cell.MAX_VELOCITY); private set
|
||||
|
||||
fun updateSkipStep(n: Int) { skipStep = n.coerceIn(1, 8) }
|
||||
fun updateSkipStep(n: Int) { skipStep = n.coerceIn(0, 8) }
|
||||
fun togglePunchIn() { punchInMode = !punchInMode }
|
||||
fun updateKbdChannel(c: Int) { kbdChannel = c.coerceIn(0, Cell.MAX_CHANNEL); touched() }
|
||||
fun updateKbdOctave(o: Int) { kbdOctave = o.coerceIn(0, 8); touched() }
|
||||
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
|
||||
|
||||
@@ -123,12 +130,6 @@ class AppViewModel(
|
||||
private fun focusedCell(): Cell =
|
||||
project.activePattern().cell(cursorTrack, cursorLine)
|
||||
|
||||
fun moveCursor(dTrack: Int, dLine: Int) {
|
||||
val p = project.activePattern()
|
||||
cursorTrack = (cursorTrack + dTrack).coerceIn(0, Pattern.TRACK_COUNT - 1)
|
||||
cursorLine = (cursorLine + dLine).coerceIn(0, p.length - 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'
|
||||
@@ -159,14 +160,15 @@ class AppViewModel(
|
||||
}
|
||||
|
||||
/** Punch a note into the focused cell (velocity/channel from the keyboard) and
|
||||
* advance the cursor by [skipStep] rows — the touch-keyboard entry path. */
|
||||
* 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
|
||||
moveCursor(0, skipStep)
|
||||
moveCursorVerticalWrap(skipStep)
|
||||
touched()
|
||||
}
|
||||
|
||||
@@ -219,25 +221,23 @@ class AppViewModel(
|
||||
}
|
||||
|
||||
fun clearFocused() { focusedCell().clear(); touched() }
|
||||
fun noteOffFocused() { focusedCell().note = Cell.OFF; touched() }
|
||||
|
||||
/** The cell under the cursor — for editor popups to read/display live values. */
|
||||
fun cellUnderCursor(): Cell = project.activePattern().cell(cursorTrack, cursorLine)
|
||||
|
||||
/** Set the focused cell's note directly (piano popup / live entry). Remembers
|
||||
* it as the last note so subsequent nudges resume from here. */
|
||||
fun setFocusedNote(midi: Int) {
|
||||
val n = midi.coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
|
||||
focusedCell().note = n
|
||||
lastNote = n
|
||||
/** 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()
|
||||
}
|
||||
|
||||
/** Set the focused cell's channel directly, remembering it. */
|
||||
fun setFocusedChannel(channel: Int) {
|
||||
val ch = channel.coerceIn(1, Cell.MAX_CHANNEL)
|
||||
focusedCell().channel = ch
|
||||
lastChannel = ch
|
||||
/** 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()
|
||||
}
|
||||
|
||||
@@ -267,6 +267,7 @@ class AppViewModel(
|
||||
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). */
|
||||
@@ -525,6 +526,13 @@ class AppViewModel(
|
||||
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: com.reactorcoremeltdown.sizzletracker.model.LoopRegion, delta: Int) {
|
||||
@@ -596,6 +604,31 @@ class AppViewModel(
|
||||
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: com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot, pad: Int): Boolean {
|
||||
val pads = com.reactorcoremeltdown.sizzletracker.model.SamplerPads
|
||||
val store = com.reactorcoremeltdown.sizzletracker.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, com.reactorcoremeltdown.sizzletracker.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: com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot, pad: Int) {
|
||||
val pads = com.reactorcoremeltdown.sizzletracker.model.SamplerPads
|
||||
@@ -622,16 +655,10 @@ class AppViewModel(
|
||||
inputDeviceId = device?.id
|
||||
}
|
||||
|
||||
/** Whether the native Oboe output backend is selected. */
|
||||
var nativeEngine by mutableStateOf(false); private set
|
||||
val nativeEngineAvailable: Boolean
|
||||
get() = com.reactorcoremeltdown.sizzletracker.audio.NativeAudioBridge.isAvailable()
|
||||
|
||||
fun applyNativeEngine(enabled: Boolean) {
|
||||
engine.setNativeOutput(enabled)
|
||||
nativeEngine = engine.useNativeOutput
|
||||
persistSettings()
|
||||
}
|
||||
/** 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: com.reactorcoremeltdown.sizzletracker.ui.theme.RetroPalette) {
|
||||
@@ -639,22 +666,68 @@ class AppViewModel(
|
||||
persistSettings()
|
||||
}
|
||||
|
||||
/** Save app-level settings (theme + audio backend) so they survive a restart. */
|
||||
private fun persistSettings() {
|
||||
viewModelScope.launch { settingsStore.save(palette.name, nativeEngine) }
|
||||
// ------------------------------------------------------------ colour themes
|
||||
private val stockThemes get() = com.reactorcoremeltdown.sizzletracker.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<com.reactorcoremeltdown.sizzletracker.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) }
|
||||
}
|
||||
|
||||
// Restore persisted app settings. This init block sits AFTER the `palette` and
|
||||
// `nativeEngine` state properties are declared, so their backing MutableStates
|
||||
// exist before we touch them (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 palette.
|
||||
/** 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(com.reactorcoremeltdown.sizzletracker.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()
|
||||
com.reactorcoremeltdown.sizzletracker.ui.theme.RetroPalette.ALL
|
||||
.firstOrNull { it.name == s.paletteName }?.let { palette = it }
|
||||
if (s.nativeEngine && nativeEngineAvailable) applyNativeEngine(true)
|
||||
// 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.
|
||||
@@ -662,6 +735,9 @@ class AppViewModel(
|
||||
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 = {
|
||||
@@ -737,11 +813,18 @@ class AppViewModel(
|
||||
fun exportSng(): String =
|
||||
com.reactorcoremeltdown.sizzletracker.io.SngFormat.export(project)
|
||||
|
||||
/** Import a reference `.sng` file (in place). */
|
||||
fun importSng(text: String) {
|
||||
com.reactorcoremeltdown.sizzletracker.io.SngFormat.importInto(project, text)
|
||||
/** 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 {
|
||||
com.reactorcoremeltdown.sizzletracker.io.SngFormat.importInto(project, text)
|
||||
}.getOrElse { return false }
|
||||
cursorLine = 0; cursorTrack = 0
|
||||
touched()
|
||||
return true
|
||||
}
|
||||
|
||||
// -------------------------------------------- on-device project library (full)
|
||||
@@ -806,11 +889,12 @@ class AppViewModel(
|
||||
return file
|
||||
}
|
||||
|
||||
/** Load a named preset for [slot]'s current type into it. */
|
||||
/** 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: com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot, name: String): Boolean {
|
||||
val type = slot.type ?: return false
|
||||
val ok = presetLibrary.load(slot, type, name)
|
||||
if (ok) { engine.rebuildFxChains(); touched() }
|
||||
if (ok) { slot.name = name; engine.rebuildFxChains(); touched() }
|
||||
return ok
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.reactorcoremeltdown.sizzletracker.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
|
||||
@@ -21,7 +21,10 @@ class GlyphCache(private val measurer: TextMeasurer) {
|
||||
private val byStyle = ArrayList<HashMap<String, TextLayoutResult>>(6)
|
||||
|
||||
fun measure(text: String, style: TextStyle): TextLayoutResult {
|
||||
var idx = styles.indexOfFirst { it === style }
|
||||
// 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())
|
||||
|
||||
@@ -3,12 +3,8 @@ package com.reactorcoremeltdown.sizzletracker.ui.components
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
@@ -90,6 +86,9 @@ fun <T> RetroDropdown(
|
||||
// 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
|
||||
@@ -101,13 +100,13 @@ fun <T> RetroDropdown(
|
||||
// Invisible sizer (longest option) fixes the width; the visible value
|
||||
// draws on top and clips with an ellipsis if it ever overflows.
|
||||
Text(
|
||||
"$label:$longest ▾",
|
||||
"$prefix$longest ▾",
|
||||
color = Color.Transparent,
|
||||
fontFamily = FontFamily.Monospace, fontSize = 12.sp,
|
||||
maxLines = 1, softWrap = false,
|
||||
)
|
||||
Text(
|
||||
"$label:${optionLabel(selected)} ▾",
|
||||
"$prefix${optionLabel(selected)} ▾",
|
||||
color = c.text, fontFamily = FontFamily.Monospace, fontSize = 12.sp,
|
||||
maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
@@ -135,21 +134,3 @@ fun SectionLabel(text: String, modifier: Modifier = Modifier) {
|
||||
)
|
||||
}
|
||||
|
||||
/** A grid of pixel "cells" used as a lightweight icon (e.g. play/stop glyphs).
|
||||
* [rows] are strings of '#' (filled) and ' ' (empty). */
|
||||
@Composable
|
||||
fun PixelGlyph(rows: List<String>, color: Color, cell: Int = 3) {
|
||||
Column {
|
||||
rows.forEach { line ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(0.dp)) {
|
||||
line.forEach { ch ->
|
||||
Box(
|
||||
Modifier
|
||||
.size(cell.dp)
|
||||
.background(if (ch == '#') color else Color.Transparent),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ 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
|
||||
@@ -18,6 +19,7 @@ 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
|
||||
@@ -33,6 +35,7 @@ import com.reactorcoremeltdown.sizzletracker.model.Mixer
|
||||
import com.reactorcoremeltdown.sizzletracker.model.MixerChannel
|
||||
import com.reactorcoremeltdown.sizzletracker.model.ToolboxKind
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.isLandscape
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.components.RetroButton
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.components.RetroDropdown
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.components.SectionLabel
|
||||
@@ -120,11 +123,6 @@ private fun RecordToolbar(vm: AppViewModel) {
|
||||
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
|
||||
val toolbox = vm.project.toolbox
|
||||
|
||||
// Toolbox slots that hold an instrument / an effect, as (index -> label).
|
||||
val instrumentSlots = toolbox.filter { it.type?.kind == ToolboxKind.INSTRUMENT }
|
||||
val effectSlots = toolbox.filter { it.type?.kind == ToolboxKind.EFFECT }
|
||||
|
||||
Column(
|
||||
modifier.background(c.surface).padding(6.dp),
|
||||
@@ -133,56 +131,95 @@ private fun ChannelStrip(vm: AppViewModel, channel: MixerChannel, modifier: Modi
|
||||
Text("CH ${channel.index + 1}", color = c.accent,
|
||||
fontFamily = FontFamily.Monospace, fontSize = 13.sp)
|
||||
|
||||
SectionLabel("Instrument")
|
||||
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 = "INS",
|
||||
options = listOf(-1) + instrumentSlots.map { it.index },
|
||||
selected = channel.instrumentSlot,
|
||||
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.instrumentSlot = it; vm.bumpForToolbar() },
|
||||
onSelect = { channel.fxSlots[fx] = it; vm.rebuildAudioRouting() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
SectionLabel("FX 1-4")
|
||||
for (fx in 0 until Mixer.FX_SLOTS) {
|
||||
RetroDropdown(
|
||||
label = "FX${fx + 1}",
|
||||
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() }
|
||||
}
|
||||
|
||||
SectionLabel("Volume")
|
||||
// Vertical fader fills the remaining strip height.
|
||||
VerticalFader(
|
||||
value = channel.volume,
|
||||
default = Mixer.DEFAULT_VOLUME,
|
||||
onValueChange = { channel.volume = it; vm.bumpForToolbar() },
|
||||
modifier = Modifier.weight(1f).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() }
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
/** 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ 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.Palette
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.SportsEsports
|
||||
import androidx.compose.material3.AlertDialog
|
||||
@@ -37,7 +36,6 @@ import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -46,7 +44,6 @@ import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -57,16 +54,13 @@ 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.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.FileProvider
|
||||
import com.reactorcoremeltdown.sizzletracker.input.GamepadChord
|
||||
import com.reactorcoremeltdown.sizzletracker.input.GamepadInput
|
||||
import com.reactorcoremeltdown.sizzletracker.input.InputSource
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.theme.RetroPalette
|
||||
|
||||
/**
|
||||
* The fourth tab, built from STANDARD Material 3 components (Cards, Buttons,
|
||||
@@ -209,37 +203,49 @@ private fun ProjectCard(vm: AppViewModel) {
|
||||
val songs = vm.songNames()
|
||||
|
||||
var selectedName by remember { mutableStateOf<String?>(null) }
|
||||
val current = selectedName?.takeIf { it in songs } ?: songs.firstOrNull()
|
||||
// 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.
|
||||
// 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 ->
|
||||
uri ?: return@rememberLauncherForActivityResult
|
||||
runCatching {
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
val bytes = runCatching {
|
||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
}.getOrNull()?.let { vm.importSng(String(it, Charsets.UTF_8)) }
|
||||
}.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: load (dropdown) + save + delete.
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
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
|
||||
})
|
||||
}
|
||||
// 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) {
|
||||
@@ -296,6 +302,15 @@ private fun ProjectCard(vm: AppViewModel) {
|
||||
)
|
||||
}
|
||||
|
||||
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 },
|
||||
@@ -347,48 +362,106 @@ private fun AudioDevicesCard(vm: AppViewModel) {
|
||||
DeviceRow("Output", outputs, vm.outputDeviceId) { vm.setAudioOutput(it) }
|
||||
HorizontalDivider()
|
||||
DeviceRow("Input", inputs, vm.inputDeviceId) { vm.setAudioInput(it) }
|
||||
HorizontalDivider()
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column {
|
||||
Text("Low-latency engine (Oboe)", style = MaterialTheme.typography.bodyMedium)
|
||||
Text(
|
||||
if (vm.nativeEngineAvailable) "Native AAudio output" else "Native library unavailable",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = vm.nativeEngine,
|
||||
enabled = vm.nativeEngineAvailable,
|
||||
onCheckedChange = { vm.applyNativeEngine(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThemeCard(vm: AppViewModel) {
|
||||
val clipboard = LocalClipboardManager.current
|
||||
SettingsCard("Color Theme", subtitle = "Pick a palette or export it as text") {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
RetroPalette.ALL.forEach { p ->
|
||||
FilterChip(
|
||||
selected = vm.palette.name == p.name,
|
||||
onClick = { vm.selectPalette(p) },
|
||||
label = { Text(p.name) },
|
||||
leadingIcon = { Icon(Icons.Filled.Palette, null) },
|
||||
)
|
||||
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") }
|
||||
}
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { clipboard.setText(AnnotatedString(exportTheme(vm.palette))) },
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
) { Text("Export theme") }
|
||||
|
||||
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. */
|
||||
@@ -538,19 +611,6 @@ private fun SettingsCard(
|
||||
}
|
||||
}
|
||||
|
||||
private fun exportTheme(p: RetroPalette): String = buildString {
|
||||
appendLine("SIZZLE-THEME 1")
|
||||
appendLine("NAME=${p.name}")
|
||||
fun hex(color: Color) =
|
||||
"#%02X%02X%02X".format((color.red * 255).toInt(), (color.green * 255).toInt(), (color.blue * 255).toInt())
|
||||
appendLine("background=${hex(p.background)}")
|
||||
appendLine("surface=${hex(p.surface)}")
|
||||
appendLine("accent=${hex(p.accent)}")
|
||||
appendLine("beat=${hex(p.beat)}")
|
||||
appendLine("bar=${hex(p.bar)}")
|
||||
appendLine("playhead=${hex(p.playhead)}")
|
||||
}
|
||||
|
||||
/**
|
||||
* Searchable + collapsible MIDI binding list. Each module is a Card whose header
|
||||
* toggles expansion; its actions are real [BindingRow]s whose Learn button arms
|
||||
|
||||
@@ -15,10 +15,13 @@ 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
|
||||
@@ -44,12 +47,13 @@ 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]). Tapping a pad selects it AND auditions its sample (so pads are
|
||||
* for both testing and picking which one to edit); long-pressing a pad clears it.
|
||||
* 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, and CLIP to destructively
|
||||
* trim the sample down to that slice (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.
|
||||
* ([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) {
|
||||
@@ -58,16 +62,24 @@ fun SamplerEditor(vm: AppViewModel, slot: ToolboxSlot) {
|
||||
@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 ->
|
||||
uri ?: return@rememberLauncherForActivityResult
|
||||
runCatching {
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
val bytes = runCatching {
|
||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
}.getOrNull()?.let { bytes -> vm.loadSampleInto(slot, pad, uri.toString(), bytes) }
|
||||
}.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)) {
|
||||
@@ -89,6 +101,9 @@ fun SamplerEditor(vm: AppViewModel, slot: ToolboxSlot) {
|
||||
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) }
|
||||
}
|
||||
}
|
||||
@@ -150,13 +165,22 @@ fun SamplerEditor(vm: AppViewModel, slot: ToolboxSlot) {
|
||||
|
||||
// ----- 16-pad bank (bottom section) -----
|
||||
Text(
|
||||
"Pads (from C2 — tap to test & select, long-press to clear):",
|
||||
"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). */
|
||||
@@ -201,8 +225,11 @@ private fun PadCell(
|
||||
.background(fill)
|
||||
.pointerInput(pad, slot.index) {
|
||||
detectTapGestures(
|
||||
onTap = { onSelect(pad); vm.auditionSample(slot, note) },
|
||||
onLongPress = { onSelect(pad); vm.clearSamplerPad(slot, pad) },
|
||||
// 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,
|
||||
@@ -261,7 +288,7 @@ private fun VerticalPadSlider(
|
||||
.pointerInput(pad, slot.index) {
|
||||
detectTapGestures { off ->
|
||||
onSelect(pad)
|
||||
slot.set(SamplerPads.volumeKey(pad), (1f - off.y / size.height).coerceIn(0f, 1f))
|
||||
slot.set(SamplerPads.volumeKey(pad), snapVol(1f - off.y / size.height))
|
||||
vm.bumpForToolbar()
|
||||
}
|
||||
}
|
||||
@@ -270,7 +297,7 @@ private fun VerticalPadSlider(
|
||||
onDragStart = { onSelect(pad) },
|
||||
onDrag = { change, _ ->
|
||||
change.consume()
|
||||
slot.set(SamplerPads.volumeKey(pad), (1f - change.position.y / size.height).coerceIn(0f, 1f))
|
||||
slot.set(SamplerPads.volumeKey(pad), snapVol(1f - change.position.y / size.height))
|
||||
vm.bumpForToolbar()
|
||||
},
|
||||
)
|
||||
@@ -287,6 +314,15 @@ private fun VerticalPadSlider(
|
||||
}
|
||||
}
|
||||
|
||||
/** 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> {
|
||||
|
||||
@@ -15,10 +15,13 @@ 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
|
||||
@@ -57,11 +60,18 @@ fun SoundFontEditor(vm: AppViewModel, slot: ToolboxSlot) {
|
||||
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 ->
|
||||
uri ?: return@rememberLauncherForActivityResult
|
||||
runCatching {
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
val bytes = runCatching {
|
||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
}.getOrNull()?.let { bytes -> vm.loadSoundFont(slot, uri.toString(), bytes) }
|
||||
}.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)) {
|
||||
@@ -119,6 +129,15 @@ fun SoundFontEditor(vm: AppViewModel, slot: ToolboxSlot) {
|
||||
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]).
|
||||
|
||||
@@ -6,10 +6,8 @@ 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.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.waitForUpOrCancellation
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -17,15 +15,20 @@ 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
|
||||
@@ -36,7 +39,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -44,18 +47,19 @@ 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 com.reactorcoremeltdown.sizzletracker.model.Pitch
|
||||
import com.reactorcoremeltdown.sizzletracker.model.ParamSpec
|
||||
import com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot
|
||||
import com.reactorcoremeltdown.sizzletracker.model.ToolboxType
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.components.PianoKey
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.components.PianoOctave
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.isLandscape
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.components.RetroButton
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.components.RetroDropdown
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* The third tab. Top half: a 4x4 grid of 16 slots holding instruments or effects.
|
||||
@@ -84,29 +88,43 @@ fun ToolboxScreen(vm: AppViewModel) {
|
||||
if (editing != null) editing = null else picking = null
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize().background(c.background)) {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(4),
|
||||
// Same top:bottom split as the tracker/arranger (1.7 : 1): tiles above,
|
||||
// the piano audition keyboard below.
|
||||
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(
|
||||
// The changing revision defeats strong-skipping so tiles refresh.
|
||||
rev = vm.revision,
|
||||
slot = slot,
|
||||
selected = selected == slot.index,
|
||||
onTap = { selected = slot.index },
|
||||
onDoubleTap = { if (slot.isEmpty) picking = slot.index else editing = slot.index },
|
||||
onLongPress = { slot.clearSlot(); vm.bumpForToolbar() },
|
||||
)
|
||||
}
|
||||
// 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))
|
||||
com.reactorcoremeltdown.sizzletracker.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
|
||||
com.reactorcoremeltdown.sizzletracker.ui.StackedKeyboard(vm, punchIn = false, Modifier.weight(1f))
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().height(2.dp).background(c.accent)) // fixed divider
|
||||
com.reactorcoremeltdown.sizzletracker.ui.StackedKeyboard(vm, punchIn = false, Modifier.weight(1f))
|
||||
}
|
||||
|
||||
picking?.let { index ->
|
||||
@@ -123,6 +141,40 @@ fun ToolboxScreen(vm: AppViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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,
|
||||
@@ -131,15 +183,14 @@ private fun SlotTile(
|
||||
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
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
modifier
|
||||
.border(if (selected) 2.dp else 1.dp, borderColor, RectangleShape)
|
||||
.background(fill)
|
||||
.pointerInput(slot.index, slot.isEmpty) {
|
||||
@@ -151,13 +202,19 @@ private fun SlotTile(
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
// 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,
|
||||
@@ -167,8 +224,6 @@ private fun SlotTile(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- MIDI piano
|
||||
|
||||
/** A simple overlay list of all available instruments and effects to load. */
|
||||
@Composable
|
||||
private fun DevicePicker(onDismiss: () -> Unit, onPick: (ToolboxType) -> Unit) {
|
||||
@@ -213,23 +268,103 @@ private fun ParamEditor(vm: AppViewModel, slot: ToolboxSlot, onClose: () -> Unit
|
||||
// Preset browser: load / save / import / share for this device.
|
||||
PresetBar(vm, slot)
|
||||
|
||||
// The Sampler gets a bespoke editor (waveform + slicing + record);
|
||||
// the LFO adds a modulation-target picker; everything else uses the
|
||||
// auto-generated parameter list.
|
||||
// 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 -> {
|
||||
type.params.forEach { spec -> ParamControl(vm, slot, spec) }
|
||||
ParamGrid(vm, slot, type.params)
|
||||
LfoTargetPicker(vm, slot)
|
||||
}
|
||||
else -> type.params.forEach { spec -> ParamControl(vm, slot, spec) }
|
||||
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
|
||||
@@ -248,27 +383,53 @@ private fun PresetBar(vm: AppViewModel, slot: ToolboxSlot) {
|
||||
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 ->
|
||||
uri ?: return@rememberLauncherForActivityResult
|
||||
runCatching {
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
val bytes = runCatching {
|
||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
}.getOrNull()?.let { bytes -> vm.importPreset(slot, bytes) }
|
||||
}.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
|
||||
// preset dropdown (now on the right) can't accidentally hit it.
|
||||
// other actions can't accidentally hit it.
|
||||
if (current != null) {
|
||||
RetroButton("DELETE", danger = true) { confirmDelete = current }
|
||||
Spacer(Modifier.width(20.dp))
|
||||
@@ -283,19 +444,6 @@ private fun PresetBar(vm: AppViewModel, slot: ToolboxSlot) {
|
||||
vm.savePreset(slot, name) // ensure a file exists to share
|
||||
vm.presetShareFile(type, name)?.let { sharePresetFile(context, it) }
|
||||
}
|
||||
Spacer(Modifier.width(20.dp))
|
||||
// Rightmost: the preset browser dropdown.
|
||||
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)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"Presets are plain text, saved per device under the app's storage. " +
|
||||
@@ -327,6 +475,15 @@ private fun PresetBar(vm: AppViewModel, slot: ToolboxSlot) {
|
||||
)
|
||||
}
|
||||
|
||||
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 },
|
||||
|
||||
@@ -31,6 +31,7 @@ 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
|
||||
@@ -77,12 +78,16 @@ fun ArrangementRoll(vm: AppViewModel) {
|
||||
val arr = vm.project.arrangement
|
||||
val sig = vm.project.timeSignature
|
||||
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 laneColWidth = 34.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() }
|
||||
@@ -116,7 +121,13 @@ fun ArrangementRoll(vm: AppViewModel) {
|
||||
vm.transport.collect { t ->
|
||||
if (t.isPlaying && t.currentBeat != lastBeat) {
|
||||
lastBeat = t.currentBeat
|
||||
val target = (t.currentBeat * beatWidthPx - 200f).toInt().coerceAtLeast(0)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -151,7 +162,7 @@ fun ArrangementRoll(vm: AppViewModel) {
|
||||
Column(Modifier.fillMaxSize().background(c.background)) {
|
||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
// ---- Fixed left column: corner + lane numbers ----
|
||||
// ---- 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) {
|
||||
@@ -159,22 +170,42 @@ fun ArrangementRoll(vm: AppViewModel) {
|
||||
}
|
||||
for (lane in 0 until ArrModel.LANE_COUNT) {
|
||||
val active = vm.activeBlock == lane
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.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)
|
||||
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().horizontalScroll(scroll)) {
|
||||
Box(
|
||||
Modifier.weight(1f).fillMaxHeight()
|
||||
.onSizeChanged { viewportWidthPx = it.width }
|
||||
.horizontalScroll(scroll),
|
||||
) {
|
||||
Canvas(
|
||||
Modifier
|
||||
.width(beatWidth * arr.lengthBeats)
|
||||
|
||||
@@ -6,6 +6,9 @@ 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
|
||||
@@ -16,6 +19,7 @@ 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
|
||||
@@ -23,6 +27,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.reactorcoremeltdown.sizzletracker.model.TimeSignature
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.isLandscape
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.components.RetroButton
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.components.RetroDropdown
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
|
||||
@@ -38,6 +43,24 @@ import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
|
||||
*/
|
||||
@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) {
|
||||
com.reactorcoremeltdown.sizzletracker.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
|
||||
@@ -48,111 +71,153 @@ fun TrackerScreen(vm: AppViewModel) {
|
||||
PatternGrid(vm, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(2.dp)
|
||||
.background(c.accent),
|
||||
)
|
||||
// Lower section: the arrangement roll, or — in punch-in mode — the stacked
|
||||
// touch keyboard, so the bottom real estate serves note entry too.
|
||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||
if (vm.punchInMode) {
|
||||
com.reactorcoremeltdown.sizzletracker.ui.StackedKeyboard(vm, punchIn = true, Modifier.fillMaxSize())
|
||||
} else {
|
||||
ArrangementRoll(vm)
|
||||
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 two-row transport toolbar at the top of the upper half. */
|
||||
/** 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 transport by vm.transport.collectAsState()
|
||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // subscribe so tempo/len/sig readouts refresh
|
||||
val project = vm.project
|
||||
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)) {
|
||||
// Row 1: transport + tempo + time signature + note-off insert.
|
||||
Row(
|
||||
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
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 = androidx.compose.ui.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) })
|
||||
scrollRow(topPad = false) { TransportButtons(vm) }
|
||||
if (landscape) {
|
||||
scrollRow(topPad = true) {
|
||||
PatternButtons(vm)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
EditButtons(vm)
|
||||
}
|
||||
RetroDropdown(
|
||||
label = "SIG",
|
||||
options = TimeSignature.entries,
|
||||
selected = project.timeSignature,
|
||||
optionLabel = { it.label },
|
||||
onSelect = vm::setTimeSignature,
|
||||
)
|
||||
}
|
||||
// Row 2: length + scale + root, then the punch-in/nav skip step at the end.
|
||||
// (The active block is chosen from the arrangement, so no BLK selector here.)
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(top = 6.dp).horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
RetroDropdown(
|
||||
label = "LEN",
|
||||
options = project.timeSignature.lengthOptions,
|
||||
selected = project.activePattern().length,
|
||||
onSelect = vm::setPatternLength,
|
||||
)
|
||||
RetroDropdown(
|
||||
label = "SCALE",
|
||||
options = com.reactorcoremeltdown.sizzletracker.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 = { com.reactorcoremeltdown.sizzletracker.model.Pitch.name(60 + it).dropLast(1) },
|
||||
onSelect = { project.rootNote = it; vm.bumpForToolbar() },
|
||||
)
|
||||
// Rows the cursor jumps after a punch-in and per external up/down step.
|
||||
RetroDropdown(
|
||||
label = "SKIP",
|
||||
options = listOf(1, 2, 4, 8),
|
||||
selected = vm.skipStep,
|
||||
onSelect = vm::updateSkipStep,
|
||||
)
|
||||
}
|
||||
// Row 3: selection + clipboard edit operations. SEL toggles rectangular
|
||||
// select mode (the cursor then drags out a region); the rest act on that
|
||||
// region, or on the single focused cell when nothing is selected.
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(top = 6.dp).horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
RetroButton("SEL", active = vm.selectMode, onClick = vm::toggleSelectMode)
|
||||
RetroButton("CUT", onClick = vm::cutSelection)
|
||||
RetroButton("COPY", onClick = vm::copySelection)
|
||||
RetroButton("PASTE", active = vm.canPaste, onClick = vm::pasteClipboard)
|
||||
RetroButton("DEL", onClick = vm::deleteSelection)
|
||||
// Insert a note-off ("===") at the cursor.
|
||||
RetroButton("OFF ===", onClick = vm::noteOffFocused)
|
||||
// Swap the bottom half between the arranger and the punch-in keyboard.
|
||||
RetroButton("⌨ KBD", active = vm.punchInMode, onClick = vm::togglePunchIn)
|
||||
} 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 = com.reactorcoremeltdown.sizzletracker.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 = { com.reactorcoremeltdown.sizzletracker.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)
|
||||
}
|
||||
|
||||
@@ -4,4 +4,5 @@
|
||||
<paths>
|
||||
<files-path name="presets" path="presets/" />
|
||||
<files-path name="songs" path="songs/" />
|
||||
<files-path name="themes" path="themes/" />
|
||||
</paths>
|
||||
|
||||
Reference in New Issue
Block a user