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:
Reactorcoremeltdown
2026-07-16 11:26:07 +02:00
31 changed files with 1386 additions and 510 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,4 +4,5 @@
<paths>
<files-path name="presets" path="presets/" />
<files-path name="songs" path="songs/" />
<files-path name="themes" path="themes/" />
</paths>