diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/MainActivity.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/MainActivity.kt index aaf3542..604d485 100644 --- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/MainActivity.kt +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/MainActivity.kt @@ -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, ) } }, diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/SizzleApp.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/SizzleApp.kt index f6444a7..79d2730 100644 --- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/SizzleApp.kt +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/SizzleApp.kt @@ -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")) } diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/AmbienceReverb.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/AmbienceReverb.kt index 534692f..a8f6101 100644 --- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/AmbienceReverb.kt +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/AmbienceReverb.kt @@ -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 { diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/AudioEngine.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/AudioEngine.kt index 094e68e..210cd29 100644 --- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/AudioEngine.kt +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/AudioEngine.kt @@ -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 diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/MasterRecorder.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/MasterRecorder.kt index ce78175..678acbf 100644 --- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/MasterRecorder.kt +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/MasterRecorder.kt @@ -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 diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/SampleStore.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/SampleStore.kt index ef62af5..b3a6c7c 100644 --- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/SampleStore.kt +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/SampleStore.kt @@ -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) diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/SoundFontLoader.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/SoundFontLoader.kt index 00e97a0..46c900f 100644 --- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/SoundFontLoader.kt +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/SoundFontLoader.kt @@ -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 diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/SynthVoice.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/SynthVoice.kt index eaa0d17..e1c328d 100644 --- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/SynthVoice.kt +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/SynthVoice.kt @@ -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 } } diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/PresetLibrary.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/PresetLibrary.kt index ff67753..4b47b40 100644 --- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/PresetLibrary.kt +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/PresetLibrary.kt @@ -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` diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/ProjectIo.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/ProjectIo.kt index 43822f9..5b77bf6 100644 --- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/ProjectIo.kt +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/ProjectIo.kt @@ -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) { @@ -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) { diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/SettingsStore.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/SettingsStore.kt index d89b454..1fb2126 100644 --- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/SettingsStore.kt +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/SettingsStore.kt @@ -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 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") } } diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/SngFormat.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/SngFormat.kt index 9f1b0f5..62bcff1 100644 --- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/SngFormat.kt +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/SngFormat.kt @@ -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 diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/ThemeIo.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/ThemeIo.kt new file mode 100644 index 0000000..2a17062 --- /dev/null +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/ThemeIo.kt @@ -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() + 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)) + } +} diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/ThemeLibrary.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/ThemeLibrary.kt new file mode 100644 index 0000000..3a7dd0e --- /dev/null +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/ThemeLibrary.kt @@ -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: + * + * /Sunset.szt ← a saved/imported theme + * /.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 = + 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 = 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" + } +} diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Arrangement.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Arrangement.kt index 088405e..b579539 100644 --- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Arrangement.kt +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Arrangement.kt @@ -11,6 +11,12 @@ class Arrangement { /** slots[lane][beat] = patternId, or [EMPTY]. */ val slots: Array = 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) } diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Mixer.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Mixer.kt index 91b7126..a00aa6c 100644 --- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Mixer.kt +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Mixer.kt @@ -11,8 +11,12 @@ class Mixer { // they build the song. Notes on channel 0 sound only through a channel-0 bus. val channels: List = 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) diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Project.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Project.kt index 4212088..1d4dc8a 100644 --- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Project.kt +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Project.kt @@ -34,7 +34,15 @@ class Project { /** 16 toolbox slots (tab 3). Index in the list == on-screen slot number. */ val toolbox: MutableList = 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 diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Toolbox.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Toolbox.kt index dff4cf8..00befb7 100644 --- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Toolbox.kt +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Toolbox.kt @@ -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", diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/App.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/App.kt index 8843329..926b7b9 100644 --- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/App.kt +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/App.kt @@ -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, + ), + ) } } } diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/AppViewModel.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/AppViewModel.kt index a25de85..cf89b85 100644 --- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/AppViewModel.kt +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/AppViewModel.kt @@ -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(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 = + stockThemes + themeLibrary.loadAll() + + /** All theme names for the selector dropdown (stock first, then user). */ + fun themeNames(): List = 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 } diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/Responsive.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/Responsive.kt new file mode 100644 index 0000000..190cd7c --- /dev/null +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/Responsive.kt @@ -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 diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/components/GlyphCache.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/components/GlyphCache.kt index c87ddca..bb35e9f 100644 --- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/components/GlyphCache.kt +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/components/GlyphCache.kt @@ -21,7 +21,10 @@ class GlyphCache(private val measurer: TextMeasurer) { private val byStyle = ArrayList>(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()) diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/components/Widgets.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/components/Widgets.kt index bc4511d..9d374c0 100644 --- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/components/Widgets.kt +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/components/Widgets.kt @@ -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 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 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, 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), - ) - } - } - } - } -} diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/mixer/MixerScreen.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/mixer/MixerScreen.kt index 0b278db..c3fa80a 100644 --- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/mixer/MixerScreen.kt +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/mixer/MixerScreen.kt @@ -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() } } } diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/settings/SettingsScreen.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/settings/SettingsScreen.kt index 3713f2e..6149de1 100644 --- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/settings/SettingsScreen.kt @@ -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(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(null) } var confirmNew by remember { mutableStateOf(false) } + var importError by remember { mutableStateOf(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(null) } + var importError by remember { mutableStateOf(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 diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/SamplerEditor.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/SamplerEditor.kt index 9a72a44..8f9a4fc 100644 --- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/SamplerEditor.kt +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/SamplerEditor.kt @@ -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(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 { diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/SoundFontEditor.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/SoundFontEditor.kt index 4709d01..1c22fdd 100644 --- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/SoundFontEditor.kt +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/SoundFontEditor.kt @@ -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(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]). diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/ToolboxScreen.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/ToolboxScreen.kt index 5221d65..b28b51d 100644 --- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/ToolboxScreen.kt +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/ToolboxScreen.kt @@ -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) { + 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) + +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(null) } + var importError by remember { mutableStateOf(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(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 }, diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/tracker/ArrangementRoll.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/tracker/ArrangementRoll.kt index 06e00cd..4a13bbe 100644 --- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/tracker/ArrangementRoll.kt +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/tracker/ArrangementRoll.kt @@ -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) diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/tracker/TrackerScreen.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/tracker/TrackerScreen.kt index 0c3a886..28324e8 100644 --- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/tracker/TrackerScreen.kt +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/tracker/TrackerScreen.kt @@ -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) +} diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml index eadaa7a..435bfd7 100644 --- a/app/src/main/res/xml/file_paths.xml +++ b/app/src/main/res/xml/file_paths.xml @@ -4,4 +4,5 @@ +