Merge branch 'feature/recording-landscape-fx-qol'

Master recording, landscape layouts, FX/arpeggiator fixes, theme library,
and a batch of UX/persistence/cleanup improvements.
This commit is contained in:
Reactorcoremeltdown
2026-07-16 11:26:07 +02:00
31 changed files with 1386 additions and 510 deletions

View File

@@ -49,7 +49,7 @@ class MainActivity : ComponentActivity() {
app.project, app.audioEngine, app.inputRouter, app.project, app.audioEngine, app.inputRouter,
app.gamepadInput, app.midiInput, app.keyboardInput, app.gamepadInput, app.midiInput, app.keyboardInput,
app.bindingStore, app.presetLibrary, app.songLibrary, app.bindingStore, app.presetLibrary, app.songLibrary,
app.settingsStore, app.applicationContext, app.settingsStore, app.themeLibrary, app.applicationContext,
) )
} }
}, },

View File

@@ -11,6 +11,7 @@ import com.reactorcoremeltdown.sizzletracker.io.PresetLibrary
import com.reactorcoremeltdown.sizzletracker.io.ProjectStore import com.reactorcoremeltdown.sizzletracker.io.ProjectStore
import com.reactorcoremeltdown.sizzletracker.io.SettingsStore import com.reactorcoremeltdown.sizzletracker.io.SettingsStore
import com.reactorcoremeltdown.sizzletracker.io.SongLibrary import com.reactorcoremeltdown.sizzletracker.io.SongLibrary
import com.reactorcoremeltdown.sizzletracker.io.ThemeLibrary
import com.reactorcoremeltdown.sizzletracker.model.Project import com.reactorcoremeltdown.sizzletracker.model.Project
import java.io.File import java.io.File
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@@ -50,6 +51,9 @@ class SizzleApp : Application() {
/** On-disk `.sng` song library in the app's scoped storage. */ /** On-disk `.sng` song library in the app's scoped storage. */
val songLibrary: SongLibrary by lazy { SongLibrary(File(filesDir, "songs")) } 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). */ /** Autosave of the whole project (crash recovery / resume). */
val projectStore: ProjectStore by lazy { ProjectStore(File(filesDir, "autosave.sng")) } val projectStore: ProjectStore by lazy { ProjectStore(File(filesDir, "autosave.sng")) }

View File

@@ -148,16 +148,15 @@ class AmbienceReverb(sampleRate: Int) : AudioEffect {
} }
private fun recompute(algo: Int, size: Float, decay: Float, hf: Float, lf: Float, diffusion: Float) { 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) { 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 } 2, 3 -> { apfGain = 0.618f; diffusionSens = 1f; bypassER = false }
4 -> { apfGain = 0.7f; diffusionSens = 0.7f; bypassER = true } 4 -> { apfGain = 0.7f; diffusionSens = 0.7f; bypassER = true }
5 -> { apfGain = 0.5f; diffusionSens = 0.5f; bypassER = true } 5 -> { apfGain = 0.5f; diffusionSens = 0.5f; bypassER = true }
else -> { apfGain = 0.75f; diffusionSens = 0.8f; 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. // Prime delay lengths: log-distributed between size-dependent bounds.
val sizeCoeff = (size + 0.5f).coerceIn(0.5f, 2f) 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. // Early reflections (Room/Hall only), scaled by room size.
val pattern = ER_PATTERNS[algo] 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 val erSizeScale = 0.5f + size
for (t in 0 until erCount) { for (t in 0 until erCount) {
erDelaySmp[t] = (pattern[t * 2] * 0.001f * fs * erSizeScale).coerceIn(1f, erLine.maxDelay()) 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) System.arraycopy(nextFb, 0, fbVec, 0, N)
// Late field: sum → make-up gain → saturation. // Late field: sum → make-up gain → saturation. LATE_SCALE keeps the summed
var late = out * 0.125f * makeup // 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) val amt = (satAmt * satMul).coerceIn(0f, 1f)
if (amt > 0.0001f) late += amt * (tanh(late * 2f) * 0.5f - late) if (amt > 0.0001f) late += amt * (tanh(late * 2f) * 0.5f - late)
@@ -313,8 +314,20 @@ class AmbienceReverb(sampleRate: Int) : AudioEffect {
} }
private companion object { 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 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 PHI = 1.6180339887f
const val TWO_PI = 6.28318530718f 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_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) 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( val SIGN_FLIP = floatArrayOf(
1f, -1f, 1f, -1f, -1f, 1f, -1f, 1f, 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, // 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 db2lin(db: Float): Float = pow10(db / 20f)
fun pow10(x: Float): Float = exp(x * 2.302585093f) 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) { fun fwht(v: FloatArray) {
var h = 1 var h = 1
while (h < N) { while (h < N) {
@@ -378,7 +390,7 @@ class AmbienceReverb(sampleRate: Int) : AudioEffect {
} }
h *= 2 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 { fun isPrime(n: Int): Boolean {

View File

@@ -133,6 +133,8 @@ class AudioEngine(
// ---- Shared position state (audio-thread only) ---- // ---- Shared position state (audio-thread only) ----
private var samplesIntoLine = 0.0 private var samplesIntoLine = 0.0
private var pendingTrigger = true private var pendingTrigger = true
/** Samples per tracker line, refreshed once per block (see [fillBlock]). */
private var blockSamplesPerLine = 1.0
// ---- Pattern-loop mode ---- // ---- Pattern-loop mode ----
private var currentLine = 0 private var currentLine = 0
@@ -177,9 +179,6 @@ class AudioEngine(
@Volatile var recordArmed = false; private set @Volatile var recordArmed = false; private set
@Volatile private var recTailBars = 0 @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. */ /** Arm/disarm recording and set the FX-tail length (bars) captured after a stop. */
fun setRecordingArm(armed: Boolean, tailBars: Int) { fun setRecordingArm(armed: Boolean, tailBars: Int) {
recordArmed = armed recordArmed = armed
@@ -317,18 +316,30 @@ class AudioEngine(
} }
// ---------------------------------------------------------------- lifecycle // ---------------------------------------------------------------- lifecycle
/** True while the low-latency native (Oboe) output backend is selected. */
@Volatile var useNativeOutput = false; private set
private var nativeBridge: NativeAudioBridge? = null 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() { fun start() {
if (running) return if (running) return
running = true running = true
// Prefer the native Oboe backend when enabled and its library loaded; // The low-latency native (Oboe) backend is the default. Fall back to the
// otherwise (or if it fails to open) use the Kotlin AudioTrack loop. // Kotlin AudioTrack loop ONLY if it can't initialise — and report why.
if (useNativeOutput && NativeAudioBridge.isAvailable()) { if (NativeAudioBridge.isAvailable()) {
val bridge = NativeAudioBridge(this) 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() 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 ---- // ---- USB / audio output device routing ----
@Volatile private var preferredOutput: android.media.AudioDeviceInfo? = null @Volatile private var preferredOutput: android.media.AudioDeviceInfo? = null
@@ -481,6 +483,9 @@ class AudioEngine(
if (proj != null) { if (proj != null) {
applyLfos(proj) applyLfos(proj)
updateFxParams(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 val anySolo = proj?.mixer?.anySolo() ?: false
// Snapshot the volatile chains once so a concurrent rebuild can't swap them // 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 var s = if (mc.audible(anySolo)) channelSum[ch] * mc.volume else 0f
val chain = chains[ch] val chain = chains[ch]
for (u in chain) s = u.effect.process(s) 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 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 ---- // ---- MIDI LFO modulation ----
private val lfoPhase = DoubleArray(Project.TOOLBOX_SLOTS) private val lfoPhase = DoubleArray(Project.TOOLBOX_SLOTS)
private val lfoSampleHold = DoubleArray(Project.TOOLBOX_SLOTS) { Math.random() * 2 - 1 } private val lfoSampleHold = DoubleArray(Project.TOOLBOX_SLOTS) { Math.random() * 2 - 1 }
@@ -555,16 +578,29 @@ class AudioEngine(
val target = lfo.string("target") val target = lfo.string("target")
val sep = target.indexOf(':') val sep = target.indexOf(':')
if (sep <= 0) continue if (sep <= 0) continue
val tIdx = target.substring(0, sep).toIntOrNull() ?: continue // Parse the target slot index from target[0 until sep] without a substring
if (tIdx == i) continue // or a boxed Int? — this runs every block for each active LFO.
val key = target.substring(sep + 1) 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 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 div = TimeDivision.fromIndex(lfo.float("division", 3f).toInt())
val freq = 1.0 / (div.beatFraction * secondsPerBeat).coerceAtLeast(1e-6) val freq = 1.0 / (div.beatFraction * secondsPerBeat).coerceAtLeast(1e-6)
val advanced = lfoPhase[i] + freq * blockSeconds 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 lfoPhase[i] = advanced % 1.0
val phase = (lfoPhase[i] + lfo.float("phase", 0f)) % 1.0 val phase = (lfoPhase[i] + lfo.float("phase", 0f)) % 1.0
@@ -592,9 +628,11 @@ class AudioEngine(
// ----- Pattern-loop mode (Tracker tab audition) ----- // ----- Pattern-loop mode (Tracker tab audition) -----
private fun advancePatternLoop(proj: Project) { private fun advancePatternLoop(proj: Project) {
val pattern = proj.activePattern() val pattern = proj.activePattern()
val samplesPerLine = samplesPerLine(proj) val samplesPerLine = blockSamplesPerLine
if (pendingTrigger) { if (pendingTrigger) {
val linesPerBar = proj.timeSignature.linesPerBar
if (linesPerBar > 0 && currentLine % linesPerBar == 0) resetBarArps()
triggerPatternLine(proj, pattern, currentLine) triggerPatternLine(proj, pattern, currentLine)
pendingTrigger = false pendingTrigger = false
} }
@@ -644,8 +682,24 @@ class AudioEngine(
val octaves = arpSlot.float("octaves", 1f).toInt() val octaves = arpSlot.float("octaves", 1f).toInt()
val div = TimeDivision.fromIndex(arpSlot.float("division", 3f).toInt()) val div = TimeDivision.fromIndex(arpSlot.float("division", 3f).toInt())
val stepSamples = div.beatFraction * (60.0 / proj.tempoBpm) * sampleRate val stepSamples = div.beatFraction * (60.0 / proj.tempoBpm) * sampleRate
arp.start(note, velocity, octaves, arpSlot.string("mode", "Up"), stepSamples) 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) playNote(proj, bus, arp.currentNote(), velocity)
}
} else { } else {
arp.stop() arp.stop()
playNote(proj, bus, note, velocity) playNote(proj, bus, note, velocity)
@@ -729,6 +783,16 @@ class AudioEngine(
else -> null 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. */ /** The channel's Arpeggiator slot (first one found in its FX slots), or null. */
private fun channelArpSlot(proj: Project, track: Int): ToolboxSlot? { private fun channelArpSlot(proj: Project, track: Int): ToolboxSlot? {
for (idx in proj.mixer.channels[track].fxSlots) { for (idx in proj.mixer.channels[track].fxSlots) {
@@ -746,8 +810,17 @@ class AudioEngine(
if (!a.active) continue if (!a.active) continue
a.samplesLeft -= 1.0 a.samplesLeft -= 1.0
if (a.samplesLeft <= 0.0) { if (a.samplesLeft <= 0.0) {
a.advance() // A pending bar reset plays step 0 as-is; otherwise advance the pattern.
playNote(proj, i % Pattern.TRACK_COUNT, a.currentNote(), a.velocity) 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 a.samplesLeft += a.stepSamples
} }
} }
@@ -759,13 +832,46 @@ class AudioEngine(
private var octaves = 1; private var mode = 0 private var octaves = 1; private var mode = 0
var stepSamples = 0.0; var samplesLeft = 0.0 var stepSamples = 0.0; var samplesLeft = 0.0
private var step = 0; private var dir = 1 private var step = 0; private var dir = 1
/** When true, [resetToBar] realigns the pattern to step 0 on each bar downbeat. */
fun start(note: Int, vel: Int, octs: Int, modeStr: String, stepSamp: Double) { var resetOnBar = false
active = true; base = note; velocity = vel; octaves = octs.coerceIn(1, 4) // Set by [resetToBar] so the next fire plays step 0 without first advancing.
mode = when (modeStr) { "Down" -> 1; "UpDown" -> 2; "Random" -> 3; else -> 0 } private var pendingReset = false
stepSamples = stepSamp.coerceAtLeast(1.0); samplesLeft = stepSamples; step = 0; dir = 1 // 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 stop() { active = false; base = -1 }
fun currentNote(): Int = (base + 12 * step).coerceIn(0, 127) fun currentNote(): Int = (base + 12 * step).coerceIn(0, 127)
@@ -777,7 +883,7 @@ class AudioEngine(
if (step >= octaves - 1) { step = octaves - 1; dir = -1 } if (step >= octaves - 1) { step = octaves - 1; dir = -1 }
else if (step <= 0) { step = 0; 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 else -> step = (step + 1) % octaves
} }
} }
@@ -789,7 +895,9 @@ class AudioEngine(
for (idx in proj.mixer.channels[track].fxSlots) { for (idx in proj.mixer.channels[track].fxSlots) {
if (idx < 0) continue if (idx < 0) continue
val slot = proj.toolbox.getOrNull(idx) ?: 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 return semis
} }
@@ -877,10 +985,14 @@ class AudioEngine(
val list = playlist val list = playlist
if (list.isEmpty()) { playing = false; publish(); return } if (list.isEmpty()) { playing = false; publish(); return }
val linesPerBeat = proj.timeSignature.linesPerBeat val linesPerBeat = proj.timeSignature.linesPerBeat
val samplesPerLine = samplesPerLine(proj) val samplesPerLine = blockSamplesPerLine
val beat = list[playlistIndex.coerceIn(0, list.size - 1)] val beat = list[playlistIndex.coerceIn(0, list.size - 1)]
if (pendingTrigger) { 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) triggerArrangementLine(proj, arr, beat, lineInBeat, linesPerBeat)
pendingTrigger = false pendingTrigger = false
} }
@@ -922,6 +1034,7 @@ class AudioEngine(
// the whole bus). See [triggerPatternLine]. // the whole bus). See [triggerPatternLine].
for (pass in 0..1) { for (pass in 0..1) {
for (lane in 0 until Arrangement.LANE_COUNT) { 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) val p = arr.patternAt(lane, beat)
if (p == Arrangement.EMPTY) continue if (p == Arrangement.EMPTY) continue
val pat = proj.pattern(p) ?: continue val pat = proj.pattern(p) ?: continue

View File

@@ -33,9 +33,6 @@ class MasterRecorder(private val sampleRate: Int) {
@Volatile private var active = false // begin()..completion; guards re-entry @Volatile private var active = false // begin()..completion; guards re-entry
private var writer: Thread? = null 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. */ /** True from [begin] until the writer thread has fully finalized the file. */
val isActive: Boolean get() = active val isActive: Boolean get() = active

View File

@@ -22,14 +22,21 @@ object SampleStore {
fun put(id: String, sample: Sample) { samples[id] = sample } fun put(id: String, sample: Sample) { samples[id] = sample }
fun get(id: String): Sample? = if (id.isEmpty()) null else samples[id] 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]. * 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 * 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. * 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 if (bytes.size < 44) return null
val bb = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN) val bb = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN)
if (bytes[0].toInt().toChar() != 'R' || bytes[1].toInt().toChar() != 'I') return null // "RIFF" if (bytes[0].toInt().toChar() != 'R' || bytes[1].toInt().toChar() != 'I') return null // "RIFF"
@@ -47,6 +54,7 @@ object SampleStore {
while (bb.remaining() >= 8) { while (bb.remaining() >= 8) {
val tag = readTag(bb) val tag = readTag(bb)
val size = bb.int val size = bb.int
if (size < 0) break // corrupt chunk size — stop scanning, use what we have
val next = bb.position() + size val next = bb.position() + size
when (tag) { when (tag) {
"fmt " -> { "fmt " -> {
@@ -59,14 +67,16 @@ object SampleStore {
} }
"data" -> { dataOffset = bb.position(); dataLength = size } "data" -> { dataOffset = bb.position(); dataLength = size }
} }
if (dataOffset >= 0 && audioFormat != 0) { /* keep scanning is fine */ } if (next in 0..bb.limit()) bb.position(next + (size and 1)) else break // word-aligned
if (next <= bb.limit()) bb.position(next + (size and 1)) else break // chunks are word-aligned
} }
if (dataOffset < 0) return null 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 val bytesPerSample = bitsPerSample / 8
if (bytesPerSample == 0) return null if (bytesPerSample == 0) return null
val frameCount = dataLength / (bytesPerSample * channels) val frameCount = dataLength / (bytesPerSample * channels)
if (frameCount <= 0) return null
val out = FloatArray(frameCount) val out = FloatArray(frameCount)
val data = ByteBuffer.wrap(bytes, dataOffset, dataLength).order(ByteOrder.LITTLE_ENDIAN) val data = ByteBuffer.wrap(bytes, dataOffset, dataLength).order(ByteOrder.LITTLE_ENDIAN)

View File

@@ -18,7 +18,7 @@ import java.nio.ByteOrder
*/ */
object SoundFontLoader { 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. */ /** Frames cap so a pathologically large sample region can't exhaust memory. */
private const val MAX_FRAMES = 48_000 * 30 private const val MAX_FRAMES = 48_000 * 30
@@ -26,7 +26,7 @@ object SoundFontLoader {
fun load(bytes: ByteArray): Loaded? = when { fun load(bytes: ByteArray): Loaded? = when {
looksLikeSf2(bytes) -> runCatching { loadSf2(bytes) }.getOrNull() looksLikeSf2(bytes) -> runCatching { loadSf2(bytes) }.getOrNull()
looksLikeXi(bytes) -> runCatching { loadXi(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 // ------------------------------------------------------------------- SF2
@@ -78,7 +78,7 @@ object SoundFontLoader {
val root = if (originalKey in 1..127) originalKey else 60 val root = if (originalKey in 1..127) originalKey else 60
val rate = if (sampleRate in 8000..192000) sampleRate else 44100 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 // -------------------------------------------------------------------- XI
@@ -125,7 +125,7 @@ object SoundFontLoader {
// relativeNote offsets the pitch: root = 60 - relativeNote so playing that // relativeNote offsets the pitch: root = 60 - relativeNote so playing that
// note yields the sample's natural pitch and it transposes from there. // note yields the sample's natural pitch and it transposes from there.
val root = (60 - relativeNote).coerceIn(0, 127) val root = (60 - relativeNote).coerceIn(0, 127)
return Loaded(SampleStore.Sample(out, 8363), root, "XI") return Loaded(SampleStore.Sample(out, 8363), root)
} }
// ----------------------------------------------------------------- helpers // ----------------------------------------------------------------- helpers

View File

@@ -1,8 +1,5 @@
package com.reactorcoremeltdown.sizzletracker.audio 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 * 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. * 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 { companion object {
/** Equal-tempered MIDI note -> frequency in Hz (A4 = 69 = 440 Hz). */ /** 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) fun midiToHz(midi: Int): Double = 440.0 * Math.pow(2.0, (midi - 69) / 12.0)
@Suppress("unused") private const val TWO_PI = 2.0 * PI
@Suppress("unused") private fun s(x: Double) = sin(x) // kept for future waves
} }
} }

View File

@@ -152,8 +152,9 @@ class PresetLibrary(private val baseDir: File) {
} }
} }
/** Load the named preset for [type] into [slot]. */ /** Load the named preset for [type] into [slot]. A missing, unreadable or
fun load(slot: ToolboxSlot, type: ToolboxType, name: String): Boolean { * 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) val file = File(typeDir(type), sanitize(name) + PRESET_EXT)
if (!file.exists()) return false if (!file.exists()) return false
if (!PresetIo.import(slot, file.readText())) 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) ToolboxType.SOUNDFONT -> resolveSoundFont(slot, dir)
else -> {} else -> {}
} }
return true true
} }.getOrDefault(false)
/** /**
* Import a preset supplied as raw text (e.g. picked from Files). Loads it into * 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 * Sampler samples referenced by an absolute path are pulled in; relative refs
* need the zip bundle form (see [importZip]). * 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 if (!PresetIo.import(slot, text)) return false
val type = slot.type ?: return false val type = slot.type ?: return false
when (type) { when (type) {
@@ -181,8 +182,8 @@ class PresetLibrary(private val baseDir: File) {
else -> {} else -> {}
} }
save(slot, slot.name.ifBlank { type.displayName }) 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) — * 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 * 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. * 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() } } val scratch = File(baseDir, IMPORT_DIR).apply { mkdirs(); listFiles()?.forEach { it.delete() } }
var presetText: String? = null var presetText: String? = null
ZipInputStream(input).use { zis -> ZipInputStream(input).use { zis ->
var entry: ZipEntry? = zis.nextEntry var entry: ZipEntry? = zis.nextEntry
while (entry != null) { while (entry != null) {
if (!entry.isDirectory) {
// Use only the base name to guard against zip-slip path traversal. // Use only the base name to guard against zip-slip path traversal.
val target = File(scratch, File(entry.name).name) 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) } 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() zis.closeEntry()
entry = zis.nextEntry entry = zis.nextEntry
} }
} }
// A zip without a valid .szp preset (or with an unrecognised one) is rejected.
val text = presetText ?: return false val text = presetText ?: return false
if (!PresetIo.import(slot, text)) return false if (!PresetIo.import(slot, text)) return false
when (slot.type) { when (slot.type) {
@@ -215,8 +224,8 @@ class PresetLibrary(private val baseDir: File) {
} }
// Persist a self-contained copy into the real library folder. // Persist a self-contained copy into the real library folder.
save(slot, slot.name.ifBlank { slot.type?.displayName ?: "preset" }) 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` * Produce a shareable file for a stored preset: a self-contained `.zip`

View File

@@ -45,7 +45,8 @@ object ProjectIo {
} }
val arr = project.arrangement 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 (lane in arr.slots.indices) {
for (beat in 0 until arr.lengthBeats) { for (beat in 0 until arr.lengthBeats) {
val pid = arr.slots[lane][beat] val pid = arr.slots[lane][beat]
@@ -59,7 +60,7 @@ object ProjectIo {
project.mixer.channels.forEach { ch -> project.mixer.channels.forEach { ch ->
appendLine( appendLine(
"${ch.index},${ch.instrumentSlot},${ch.fxSlots.joinToString(",")}," + "${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 // -------------------------------------------------------------------- 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 * 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 * load a song into the app's shared singleton without swapping the object the
@@ -86,15 +84,19 @@ object ProjectIo {
project.patterns.clear() project.patterns.clear()
for (lane in project.arrangement.slots.indices) for (lane in project.arrangement.slots.indices)
project.arrangement.slots[lane].fill(com.reactorcoremeltdown.sizzletracker.model.Arrangement.EMPTY) project.arrangement.slots[lane].fill(com.reactorcoremeltdown.sizzletracker.model.Arrangement.EMPTY)
project.arrangement.laneMuted.fill(false)
project.toolbox.forEach { it.clearSlot() } project.toolbox.forEach { it.clearSlot() }
project.mixer.channels.forEach { ch -> project.mixer.channels.forEach { ch ->
ch.instrumentSlot = -1; ch.fxSlots.fill(-1); ch.midiChannel = 0 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 section = ""
var current: Pattern? = null var current: Pattern? = null
text.lineSequence().map { it.trim() }.filter { it.isNotEmpty() }.forEach { line -> 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 { when {
line == HEADER -> {} line == HEADER -> {}
line.startsWith("[PATTERN") -> { line.startsWith("[PATTERN") -> {
@@ -110,6 +112,10 @@ object ProjectIo {
val attrs = parseAttrs(line) val attrs = parseAttrs(line)
project.arrangement.lengthBeats = attrs["len"]?.toIntOrNull() ?: 64 project.arrangement.lengthBeats = attrs["len"]?.toIntOrNull() ?: 64
project.arrangement.loopEnabled = attrs["loop"] == "1" 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" section = "ARRANGEMENT"
} }
line.startsWith("[REGION A") -> applyRegion(project.arrangement.regionA, parseAttrs(line)) line.startsWith("[REGION A") -> applyRegion(project.arrangement.regionA, parseAttrs(line))
@@ -124,6 +130,7 @@ object ProjectIo {
section == "TOOLBOX" -> applyToolboxRow(project, line) section == "TOOLBOX" -> applyToolboxRow(project, line)
} }
} }
}
// Guarantee one block per lane (lane i is tied to block i), padding any // 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. // that the file did not define so the arrangement/editor always resolve.
for (id in 0 until com.reactorcoremeltdown.sizzletracker.model.Arrangement.LANE_COUNT) { for (id in 0 until com.reactorcoremeltdown.sizzletracker.model.Arrangement.LANE_COUNT) {
@@ -171,7 +178,10 @@ object ProjectIo {
private fun applyArrangementSlot(p: Project, line: String) { private fun applyArrangementSlot(p: Project, line: String) {
val f = line.split(',') val f = line.split(',')
if (f.size < 3) return if (f.size < 3) return
p.arrangement.set(f[0].toInt(), f[1].toInt(), f[2].toInt()) val lane = f[0].toIntOrNull() ?: return
val beat = f[1].toIntOrNull() ?: return
val pid = f[2].toIntOrNull() ?: return
p.arrangement.set(lane, beat, pid)
} }
private fun applyRegion(region: com.reactorcoremeltdown.sizzletracker.model.LoopRegion, a: Map<String, String>) { private fun applyRegion(region: com.reactorcoremeltdown.sizzletracker.model.LoopRegion, a: Map<String, String>) {
@@ -186,12 +196,13 @@ object ProjectIo {
if (f.size < 10) return if (f.size < 10) return
val idx = f[0].toIntOrNull() ?: return val idx = f[0].toIntOrNull() ?: return
val ch = p.mixer.channels.getOrNull(idx) ?: return val ch = p.mixer.channels.getOrNull(idx) ?: return
ch.instrumentSlot = f[1].toInt() ch.instrumentSlot = f[1].toIntOrNull() ?: -1
for (i in 0 until Mixer.FX_SLOTS) ch.fxSlots[i] = f[2 + i].toInt() for (i in 0 until Mixer.FX_SLOTS) ch.fxSlots[i] = f[2 + i].toIntOrNull() ?: -1
ch.midiChannel = f[6].toInt() ch.midiChannel = f[6].toIntOrNull() ?: 0
ch.volume = f[7].toFloat() ch.volume = f[7].toFloatOrNull() ?: 0.8f
ch.mute = f[8] == "1" ch.mute = f[8] == "1"
ch.solo = f[9] == "1" ch.solo = f[9] == "1"
ch.limiter = f.getOrNull(10) == "1" // optional: absent in older saves
} }
private fun applyToolboxRow(p: Project, line: String) { private fun applyToolboxRow(p: Project, line: String) {

View File

@@ -3,7 +3,6 @@ package com.reactorcoremeltdown.sizzletracker.io
import android.content.Context import android.content.Context
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
@@ -13,14 +12,17 @@ import kotlinx.coroutines.flow.first
/** The DataStore file backing app-level settings. */ /** The DataStore file backing app-level settings. */
private val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore(name = "app_settings") private val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore(name = "app_settings")
/** App-level settings that persist across restarts (theme, audio backend). */ /** App-level settings that persist across restarts (theme, recording, keyboard). */
data class AppSettings( data class AppSettings(
val paletteName: String? = null, val paletteName: String? = null,
val nativeEngine: Boolean = false,
/** Persisted SAF tree URI for master-recording WAV output, or null if unset. */ /** Persisted SAF tree URI for master-recording WAV output, or null if unset. */
val recordDirUri: String? = null, val recordDirUri: String? = null,
/** FX-tail length (bars) captured after the sequencer stops. */ /** FX-tail length (bars) captured after the sequencer stops. */
val recordTailBars: Int = 2, 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() val prefs = context.settingsDataStore.data.first()
return AppSettings( return AppSettings(
paletteName = prefs[PALETTE], paletteName = prefs[PALETTE],
nativeEngine = prefs[NATIVE_ENGINE] ?: false,
recordDirUri = prefs[RECORD_DIR], recordDirUri = prefs[RECORD_DIR],
recordTailBars = prefs[RECORD_TAIL] ?: 2, 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 -> context.settingsDataStore.edit { prefs ->
prefs[PALETTE] = paletteName 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 { private companion object {
val PALETTE = stringPreferencesKey("palette") val PALETTE = stringPreferencesKey("palette")
val NATIVE_ENGINE = booleanPreferencesKey("nativeEngine")
val RECORD_DIR = stringPreferencesKey("recordDirUri") val RECORD_DIR = stringPreferencesKey("recordDirUri")
val RECORD_TAIL = intPreferencesKey("recordTailBars") val RECORD_TAIL = intPreferencesKey("recordTailBars")
val KBD_CHANNEL = intPreferencesKey("kbdChannel")
val KBD_OCTAVE = intPreferencesKey("kbdOctave")
} }
} }

View File

@@ -76,13 +76,12 @@ object SngFormat {
} }
// -------------------------------------------------------------------- read // -------------------------------------------------------------------- read
fun import(text: String): Project = importInto(Project(), text)
/** Parse `.sng` [text] into the existing [project] (resetting its content). */ /** Parse `.sng` [text] into the existing [project] (resetting its content). */
fun importInto(project: Project, text: String): Project { fun importInto(project: Project, text: String): Project {
// Clear blocks and arrangement (keep the 8 lane-blocks, wipe their cells). // 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() } } 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) for (lane in project.arrangement.slots.indices) project.arrangement.slots[lane].fill(Arrangement.EMPTY)
project.arrangement.laneMuted.fill(false)
var blockIndex = 0 var blockIndex = 0
var current: Pattern? = null var current: Pattern? = null

View File

@@ -0,0 +1,76 @@
package com.reactorcoremeltdown.sizzletracker.io
import androidx.compose.ui.graphics.Color
import com.reactorcoremeltdown.sizzletracker.ui.theme.RetroPalette
/**
* Reads and writes a colour [RetroPalette] as plain, human-editable text — the same
* `SIZZLE-THEME 1` format the Settings tab exports, so a person can hand-tweak a
* theme and it round-trips losslessly:
*
* SIZZLE-THEME 1
* NAME=Amber CRT
* background=#0B0D0E
* surface=#15181A
* ...
*
* Parsing is lenient about individual colour lines (a missing/garbled colour falls
* back to the default palette's value) but rejects text that isn't a theme at all,
* so importing a wrong file fails cleanly instead of producing a broken palette.
*/
object ThemeIo {
const val HEADER = "SIZZLE-THEME 1"
fun export(p: RetroPalette): String = buildString {
appendLine(HEADER)
appendLine("NAME=${p.name}")
appendLine("background=${hex(p.background)}")
appendLine("surface=${hex(p.surface)}")
appendLine("grid=${hex(p.grid)}")
appendLine("text=${hex(p.text)}")
appendLine("textDim=${hex(p.textDim)}")
appendLine("accent=${hex(p.accent)}")
appendLine("beat=${hex(p.beat)}")
appendLine("bar=${hex(p.bar)}")
appendLine("playhead=${hex(p.playhead)}")
appendLine("danger=${hex(p.danger)}")
}
/** Parse theme [text] into a [RetroPalette], or null if it isn't a valid theme. */
fun parse(text: String): RetroPalette? {
val map = HashMap<String, String>()
var sawHeader = false
text.lineSequence().map { it.trim() }.filter { it.isNotEmpty() }.forEach { line ->
if (line == HEADER) { sawHeader = true; return@forEach }
val eq = line.indexOf('=')
if (eq > 0) map[line.substring(0, eq).trim()] = line.substring(eq + 1).trim()
}
if (!sawHeader) return null
val name = map["NAME"]?.takeIf { it.isNotBlank() } ?: return null
val d = RetroPalette.AMBER // per-colour fallback
return RetroPalette(
name = name,
background = color(map["background"], d.background),
surface = color(map["surface"], d.surface),
grid = color(map["grid"], d.grid),
text = color(map["text"], d.text),
textDim = color(map["textDim"], d.textDim),
accent = color(map["accent"], d.accent),
beat = color(map["beat"], d.beat),
bar = color(map["bar"], d.bar),
playhead = color(map["playhead"], d.playhead),
danger = color(map["danger"], d.danger),
)
}
private fun hex(c: Color): String =
"#%02X%02X%02X".format((c.red * 255).toInt(), (c.green * 255).toInt(), (c.blue * 255).toInt())
/** Parse "#RRGGBB" (or "RRGGBB") into an opaque colour; [fallback] on any error. */
private fun color(s: String?, fallback: Color): Color {
val hex = s?.trim()?.removePrefix("#") ?: return fallback
if (hex.length != 6) return fallback
val rgb = hex.toLongOrNull(16) ?: return fallback
return Color(0xFF000000L or (rgb and 0xFFFFFF))
}
}

View File

@@ -0,0 +1,62 @@
package com.reactorcoremeltdown.sizzletracker.io
import com.reactorcoremeltdown.sizzletracker.ui.theme.RetroPalette
import java.io.File
/**
* On-device library of USER colour themes, stored as plain [ThemeIo] text:
*
* <baseDir>/Sunset.szt ← a saved/imported theme
* <baseDir>/.export/…​.szt ← transient copy written for sharing
*
* The two built-in themes ([RetroPalette.ALL]) are NOT stored here — they live in
* code and can never be deleted. This library only holds themes the user imports,
* so `delete` here can only ever remove a user theme.
*/
class ThemeLibrary(private val baseDir: File) {
/** Names (without extension) of the stored user themes, alphabetically. */
fun list(): List<String> =
baseDir.listFiles { f -> f.isFile && f.name.endsWith(EXT) }
?.map { it.name.removeSuffix(EXT) }
?.sorted()
?: emptyList()
private fun file(name: String): File = File(baseDir.apply { mkdirs() }, sanitize(name) + EXT)
/** Save [palette] as a user theme (named after it). Returns the written file. */
fun save(palette: RetroPalette): File = file(palette.name).apply { writeText(ThemeIo.export(palette)) }
/** Load a stored user theme by name, or null if missing/unparseable. */
fun load(name: String): RetroPalette? =
file(name).takeIf { it.exists() }?.let { runCatching { ThemeIo.parse(it.readText()) }.getOrNull() }
/** Every stored user theme, skipping any that fail to parse. */
fun loadAll(): List<RetroPalette> = list().mapNotNull { load(it) }
/** Delete a stored user theme. Returns true if it existed. */
fun delete(name: String): Boolean = file(name).let { if (it.exists()) it.delete() else false }
/** Import theme [text] (from a picked file): parse, persist to the library, and
* return the palette — or null if the text isn't a valid theme. */
fun importText(text: String): RetroPalette? {
val palette = runCatching { ThemeIo.parse(text) }.getOrNull() ?: return null
save(palette)
return palette
}
/** Write a one-off `.szt` to a shareable sub-dir and return the file (kept out of
* the library list, like [SongLibrary.writeExport]). */
fun writeExport(palette: RetroPalette): File {
val dir = File(baseDir, EXPORT_DIR).apply { mkdirs() }
return File(dir, sanitize(palette.name) + EXT).apply { writeText(ThemeIo.export(palette)) }
}
private fun sanitize(name: String): String =
name.trim().replace(Regex("[^A-Za-z0-9._ -]"), "_").ifBlank { "theme" }
private companion object {
const val EXT = ".szt"
const val EXPORT_DIR = ".export"
}
}

View File

@@ -11,6 +11,12 @@ class Arrangement {
/** slots[lane][beat] = patternId, or [EMPTY]. */ /** slots[lane][beat] = patternId, or [EMPTY]. */
val slots: Array<IntArray> = Array(LANE_COUNT) { IntArray(MAX_BEATS) { EMPTY } } val slots: Array<IntArray> = Array(LANE_COUNT) { IntArray(MAX_BEATS) { EMPTY } }
/** Per-lane mute. A muted lane's blocks are skipped by the sequencer (its notes
* never sound) while its cells stay in place. Read live on the audio thread. */
val laneMuted: BooleanArray = BooleanArray(LANE_COUNT)
fun isLaneMuted(lane: Int): Boolean = lane in 0 until LANE_COUNT && laneMuted[lane]
/** How many beats are actually in use; the roll can grow up to [MAX_BEATS]. */ /** How many beats are actually in use; the roll can grow up to [MAX_BEATS]. */
var lengthBeats: Int = 64 var lengthBeats: Int = 64
@@ -62,5 +68,4 @@ class LoopRegion(
var repeats: Int = 2, var repeats: Int = 2,
) { ) {
val isValid: Boolean get() = enabled && end > start val isValid: Boolean get() = enabled && end > start
val spanBeats: Int get() = (end - start).coerceAtLeast(0)
} }

View File

@@ -11,8 +11,12 @@ class Mixer {
// they build the song. Notes on channel 0 sound only through a channel-0 bus. // they build the song. Notes on channel 0 sound only through a channel-0 bus.
val channels: List<MixerChannel> = List(CHANNEL_COUNT) { MixerChannel(it, midiChannel = 0) } val channels: List<MixerChannel> = List(CHANNEL_COUNT) { MixerChannel(it, midiChannel = 0) }
/** True if ANY channel is soloed — used to mute the non-soloed ones. */ /** True if ANY channel is soloed — used to mute the non-soloed ones. Indexed
fun anySolo(): Boolean = channels.any { it.solo } * 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 { companion object {
const val CHANNEL_COUNT = Pattern.TRACK_COUNT // 4, kept in lockstep const val CHANNEL_COUNT = Pattern.TRACK_COUNT // 4, kept in lockstep
@@ -35,6 +39,9 @@ class MixerChannel(
var volume: Float = Mixer.DEFAULT_VOLUME, var volume: Float = Mixer.DEFAULT_VOLUME,
var mute: Boolean = false, var mute: Boolean = false,
var solo: 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. */ /** Whether this channel should actually produce sound given the solo state. */
fun audible(anySolo: Boolean): Boolean = !mute && (!anySolo || solo) fun audible(anySolo: Boolean): Boolean = !mute && (!anySolo || solo)

View File

@@ -34,7 +34,15 @@ class Project {
/** 16 toolbox slots (tab 3). Index in the list == on-screen slot number. */ /** 16 toolbox slots (tab 3). Index in the list == on-screen slot number. */
val toolbox: MutableList<ToolboxSlot> = MutableList(TOOLBOX_SLOTS) { ToolboxSlot(it) } val toolbox: MutableList<ToolboxSlot> = MutableList(TOOLBOX_SLOTS) { ToolboxSlot(it) }
fun pattern(id: Int): Pattern? = patterns.firstOrNull { it.id == id } /** Look up a pattern by id. Indexed loop (not `firstOrNull`) so it allocates no
* Iterator — this is called once per output sample on the audio thread. */
fun pattern(id: Int): Pattern? {
for (i in patterns.indices) {
val p = patterns[i]
if (p.id == id) return p
}
return null
}
/** Which pattern the tracker tab is currently editing. */ /** Which pattern the tracker tab is currently editing. */
var activePatternId: Int = 0 var activePatternId: Int = 0

View File

@@ -79,11 +79,17 @@ enum class ToolboxType(
ParamSpec("mode", "Mode", choices = listOf("Up", "Down", "UpDown", "Random")), ParamSpec("mode", "Mode", choices = listOf("Up", "Down", "UpDown", "Random")),
ParamSpec("octaves", "Octaves", 1f, 1f, 4f, isInt = true), ParamSpec("octaves", "Octaves", 1f, 1f, 4f, isInt = true),
ParamSpec("division", "Division", 3f, 0f, 6f, isInt = true), // index into TimeDivision 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( TRANSPOSER(
ToolboxKind.EFFECT, "MIDI 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( LFO(
ToolboxKind.EFFECT, "MIDI LFO", ToolboxKind.EFFECT, "MIDI LFO",

View File

@@ -11,11 +11,18 @@ import androidx.compose.material.icons.filled.GridView
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Tune import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.filled.Widgets 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.Icon
import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults 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.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
@@ -48,9 +55,35 @@ fun SizzleApp(vm: AppViewModel) {
// and intercept first. On the first tab this is disabled, so back falls through // and intercept first. On the first tab this is disabled, so back falls through
// to the system default (leaving the app). // to the system default (leaving the app).
BackHandler(enabled = vm.selectedTab > 0) { vm.selectTab(vm.selectedTab - 1) } BackHandler(enabled = vm.selectedTab > 0) { vm.selectTab(vm.selectedTab - 1) }
if (isLandscape()) {
// Landscape: a side nav rail so the (short) vertical space goes entirely to
// the active screen instead of a bottom bar.
Row(Modifier.fillMaxSize().background(c.background)) {
SideNav(vm)
Box(Modifier.weight(1f).fillMaxHeight()) { ActiveScreen(vm) }
}
} else {
Column(Modifier.fillMaxSize().background(c.background)) { Column(Modifier.fillMaxSize().background(c.background)) {
// ----- Active screen ----- Box(Modifier.weight(1f).fillMaxWidth()) { ActiveScreen(vm) }
Box(Modifier.weight(1f).fillMaxWidth()) { 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) { when (vm.selectedTab) {
0 -> TrackerScreen(vm) 0 -> TrackerScreen(vm)
1 -> MixerScreen(vm) 1 -> MixerScreen(vm)
@@ -58,7 +91,11 @@ fun SizzleApp(vm: AppViewModel) {
3 -> SettingsScreen(vm) 3 -> SettingsScreen(vm)
} }
} }
// ----- Standard Material navigation bar -----
/** Bottom navigation bar (portrait). */
@Composable
private fun BottomNav(vm: AppViewModel) {
val c = LocalRetro.current
NavigationBar(containerColor = c.surface) { NavigationBar(containerColor = c.surface) {
TABS.forEachIndexed { index, tab -> TABS.forEachIndexed { index, tab ->
NavigationBarItem( NavigationBarItem(
@@ -77,4 +114,27 @@ fun SizzleApp(vm: AppViewModel) {
} }
} }
} }
/** Side navigation rail (landscape) — same tabs, laid out vertically down the left
* edge so the screen keeps its full height. */
@Composable
private fun SideNav(vm: AppViewModel) {
val c = LocalRetro.current
NavigationRail(containerColor = c.surface) {
TABS.forEachIndexed { index, tab ->
NavigationRailItem(
selected = vm.selectedTab == index,
onClick = { vm.selectTab(index) },
icon = { Icon(tab.icon, contentDescription = tab.title) },
label = { Text(tab.title) },
colors = NavigationRailItemDefaults.colors(
selectedIconColor = c.background,
selectedTextColor = c.accent,
indicatorColor = c.accent,
unselectedIconColor = c.textDim,
unselectedTextColor = c.textDim,
),
)
}
}
} }

View File

@@ -40,6 +40,7 @@ class AppViewModel(
private val presetLibrary: com.reactorcoremeltdown.sizzletracker.io.PresetLibrary, private val presetLibrary: com.reactorcoremeltdown.sizzletracker.io.PresetLibrary,
private val songLibrary: com.reactorcoremeltdown.sizzletracker.io.SongLibrary, private val songLibrary: com.reactorcoremeltdown.sizzletracker.io.SongLibrary,
private val settingsStore: com.reactorcoremeltdown.sizzletracker.io.SettingsStore, private val settingsStore: com.reactorcoremeltdown.sizzletracker.io.SettingsStore,
private val themeLibrary: com.reactorcoremeltdown.sizzletracker.io.ThemeLibrary,
private val appContext: android.content.Context, private val appContext: android.content.Context,
) : ViewModel() { ) : ViewModel() {
@@ -77,7 +78,8 @@ class AppViewModel(
// ----- Shared keyboard / punch-in state ----- // ----- Shared keyboard / punch-in state -----
// These live on the ViewModel (not per-screen) so the tracker punch-in keyboard // 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. // 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 var skipStep by mutableIntStateOf(1); private set
/** Whether the tracker's lower half shows the punch-in keyboard (vs the arranger). */ /** Whether the tracker's lower half shows the punch-in keyboard (vs the arranger). */
var punchInMode by mutableStateOf(false); private set var punchInMode by mutableStateOf(false); private set
@@ -88,12 +90,17 @@ class AppViewModel(
/** Velocity for keyboard notes (0..127). */ /** Velocity for keyboard notes (0..127). */
var kbdVelocity by mutableIntStateOf(Cell.MAX_VELOCITY); private set 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 togglePunchIn() { punchInMode = !punchInMode }
fun updateKbdChannel(c: Int) { kbdChannel = c.coerceIn(0, Cell.MAX_CHANNEL); touched() } fun updateKbdChannel(c: Int) { kbdChannel = c.coerceIn(0, Cell.MAX_CHANNEL); persistKeyboard(); touched() }
fun updateKbdOctave(o: Int) { kbdOctave = o.coerceIn(0, 8); touched() } fun updateKbdOctave(o: Int) { kbdOctave = o.coerceIn(0, 8); persistKeyboard(); touched() }
fun updateKbdVelocity(v: Int) { kbdVelocity = v.coerceIn(0, Cell.MAX_VELOCITY); 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). */ /** Live transport snapshot from the audio thread (drives the playhead). */
val transport get() = engine.transport val transport get() = engine.transport
@@ -123,12 +130,6 @@ class AppViewModel(
private fun focusedCell(): Cell = private fun focusedCell(): Cell =
project.activePattern().cell(cursorTrack, cursorLine) 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 * 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' * 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 /** 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) { fun punchNote(midi: Int) {
val cell = focusedCell() val cell = focusedCell()
cell.note = midi.coerceIn(Pitch.LOWEST, Pitch.HIGHEST) cell.note = midi.coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
cell.velocity = kbdVelocity.coerceIn(0, Cell.MAX_VELOCITY) cell.velocity = kbdVelocity.coerceIn(0, Cell.MAX_VELOCITY)
cell.channel = kbdChannel cell.channel = kbdChannel
lastNote = cell.note; lastChannel = kbdChannel lastNote = cell.note; lastChannel = kbdChannel
moveCursor(0, skipStep) moveCursorVerticalWrap(skipStep)
touched() touched()
} }
@@ -219,25 +221,23 @@ class AppViewModel(
} }
fun clearFocused() { focusedCell().clear(); touched() } 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. */ /** Insert a note-off ("===") at the cursor and advance by the skip step, like a
fun cellUnderCursor(): Cell = project.activePattern().cell(cursorTrack, cursorLine) * punch-in. The note-off is stamped with the last-remembered channel so it
* releases the same bus the previous note was entered on. */
/** Set the focused cell's note directly (piano popup / live entry). Remembers fun noteOffFocused() {
* it as the last note so subsequent nudges resume from here. */ val cell = focusedCell()
fun setFocusedNote(midi: Int) { cell.note = Cell.OFF
val n = midi.coerceIn(Pitch.LOWEST, Pitch.HIGHEST) cell.channel = lastChannel
focusedCell().note = n moveCursorVerticalWrap(skipStep)
lastNote = n
touched() touched()
} }
/** Set the focused cell's channel directly, remembering it. */ /** DEL button: clear the selection (or focused cell) and advance by the skip
fun setFocusedChannel(channel: Int) { * step. Kept separate from [deleteSelection] so a CUT doesn't move the cursor. */
val ch = channel.coerceIn(1, Cell.MAX_CHANNEL) fun deleteAndAdvance() {
focusedCell().channel = ch deleteSelection()
lastChannel = ch moveCursorVerticalWrap(skipStep)
touched() touched()
} }
@@ -267,6 +267,7 @@ class AppViewModel(
clipboard = Array(tr.count()) { ti -> clipboard = Array(tr.count()) { ti ->
Array(lr.count()) { li -> p.cell(tr.first + ti, lr.first + li).copyOf() } 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). */ /** Clear the selected cells (or the focused cell when nothing is selected). */
@@ -525,6 +526,13 @@ class AppViewModel(
touched() 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 /** Nudge an A/B loop region's repeat count and apply it to live playback
* immediately (without restarting the transport). */ * immediately (without restarting the transport). */
fun changeRegionRepeats(region: com.reactorcoremeltdown.sizzletracker.model.LoopRegion, delta: Int) { fun changeRegionRepeats(region: com.reactorcoremeltdown.sizzletracker.model.LoopRegion, delta: Int) {
@@ -596,6 +604,31 @@ class AppViewModel(
return true 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]. */ /** Clear the sample assigned to a Sampler [slot]'s [pad]. */
fun clearSamplerPad(slot: com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot, pad: Int) { fun clearSamplerPad(slot: com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot, pad: Int) {
val pads = com.reactorcoremeltdown.sizzletracker.model.SamplerPads val pads = com.reactorcoremeltdown.sizzletracker.model.SamplerPads
@@ -622,16 +655,10 @@ class AppViewModel(
inputDeviceId = device?.id inputDeviceId = device?.id
} }
/** Whether the native Oboe output backend is selected. */ /** Set when the native Oboe backend failed to initialise and playback fell back
var nativeEngine by mutableStateOf(false); private set * to the standard AudioTrack path; drives a one-off popup. Null when clear. */
val nativeEngineAvailable: Boolean var nativeAudioError by mutableStateOf<String?>(null); private set
get() = com.reactorcoremeltdown.sizzletracker.audio.NativeAudioBridge.isAvailable() fun dismissNativeAudioError() { nativeAudioError = null }
fun applyNativeEngine(enabled: Boolean) {
engine.setNativeOutput(enabled)
nativeEngine = engine.useNativeOutput
persistSettings()
}
/** Choose the colour theme and persist the choice. */ /** Choose the colour theme and persist the choice. */
fun selectPalette(p: com.reactorcoremeltdown.sizzletracker.ui.theme.RetroPalette) { fun selectPalette(p: com.reactorcoremeltdown.sizzletracker.ui.theme.RetroPalette) {
@@ -639,22 +666,68 @@ class AppViewModel(
persistSettings() persistSettings()
} }
/** Save app-level settings (theme + audio backend) so they survive a restart. */ // ------------------------------------------------------------ colour themes
private fun persistSettings() { private val stockThemes get() = com.reactorcoremeltdown.sizzletracker.ui.theme.RetroPalette.ALL
viewModelScope.launch { settingsStore.save(palette.name, nativeEngine) }
/** The two built-in themes are read-only and can never be deleted. */
fun isStockTheme(name: String): Boolean = stockThemes.any { it.name == name }
/** Every selectable theme: the two stock themes followed by user themes. */
fun allThemes(): List<com.reactorcoremeltdown.sizzletracker.ui.theme.RetroPalette> =
stockThemes + themeLibrary.loadAll()
/** All theme names for the selector dropdown (stock first, then user). */
fun themeNames(): List<String> = allThemes().map { it.name }
/** Apply the theme with [name] (stock or user), persisting the choice. */
fun selectThemeByName(name: String) {
allThemes().firstOrNull { it.name == name }?.let { selectPalette(it) }
} }
// Restore persisted app settings. This init block sits AFTER the `palette` and /** Import a theme from picked bytes and apply it. Returns false if it isn't a
// `nativeEngine` state properties are declared, so their backing MutableStates * valid `.szt` theme (so the caller can show a popup). */
// exist before we touch them (init blocks run in textual order). A tiny blocking fun importTheme(bytes: ByteArray): Boolean {
// read at construction (before the first compose) so the theme applies without val palette = themeLibrary.importText(String(bytes, Charsets.UTF_8)) ?: return false
// a flash of the default palette. 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 { init {
kotlinx.coroutines.runBlocking { kotlinx.coroutines.runBlocking {
val s = settingsStore.load() val s = settingsStore.load()
com.reactorcoremeltdown.sizzletracker.ui.theme.RetroPalette.ALL // Match the saved theme against the stock themes first, then user themes.
.firstOrNull { it.name == s.paletteName }?.let { palette = it } allThemes().firstOrNull { it.name == s.paletteName }?.let { palette = it }
if (s.nativeEngine && nativeEngineAvailable) applyNativeEngine(true)
// 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 // Restore the recording destination + tail, and wire the engine's
// capture staging/completion into the SAF copy. // capture staging/completion into the SAF copy.
@@ -662,6 +735,9 @@ class AppViewModel(
recordDirName = recordDirUri recordDirName = recordDirUri
?.let { runCatching { displayNameForTree(android.net.Uri.parse(it)) }.getOrNull() } ?.let { runCatching { displayNameForTree(android.net.Uri.parse(it)) }.getOrNull() }
recordTailBars = s.recordTailBars.coerceIn(0, 4) 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.recTempDir = appContext.cacheDir
engine.setRecordTailBars(recordTailBars) engine.setRecordTailBars(recordTailBars)
engine.onRecordingStarted = { engine.onRecordingStarted = {
@@ -737,11 +813,18 @@ class AppViewModel(
fun exportSng(): String = fun exportSng(): String =
com.reactorcoremeltdown.sizzletracker.io.SngFormat.export(project) com.reactorcoremeltdown.sizzletracker.io.SngFormat.export(project)
/** Import a reference `.sng` file (in place). */ /** Import a reference `.sng` file (in place). Returns false if the text doesn't
fun importSng(text: String) { * 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) com.reactorcoremeltdown.sizzletracker.io.SngFormat.importInto(project, text)
}.getOrElse { return false }
cursorLine = 0; cursorTrack = 0 cursorLine = 0; cursorTrack = 0
touched() touched()
return true
} }
// -------------------------------------------- on-device project library (full) // -------------------------------------------- on-device project library (full)
@@ -806,11 +889,12 @@ class AppViewModel(
return file 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 { fun loadPreset(slot: com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot, name: String): Boolean {
val type = slot.type ?: return false val type = slot.type ?: return false
val ok = presetLibrary.load(slot, type, name) val ok = presetLibrary.load(slot, type, name)
if (ok) { engine.rebuildFxChains(); touched() } if (ok) { slot.name = name; engine.rebuildFxChains(); touched() }
return ok return ok
} }

View File

@@ -0,0 +1,18 @@
package com.reactorcoremeltdown.sizzletracker.ui
import android.content.res.Configuration
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalConfiguration
/**
* True when the device is currently in landscape orientation. Drives the app's
* side-by-side landscape layouts (a side nav rail, the tracker/toolbox split into
* columns) so wide, short screens — tablets, handheld consoles — use the space
* horizontally instead of squashing everything into short vertical bands.
*
* The Activity handles rotation itself (configChanges), so [LocalConfiguration]
* updates and recomposes callers on every orientation change.
*/
@Composable
fun isLandscape(): Boolean =
LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE

View File

@@ -21,7 +21,10 @@ class GlyphCache(private val measurer: TextMeasurer) {
private val byStyle = ArrayList<HashMap<String, TextLayoutResult>>(6) private val byStyle = ArrayList<HashMap<String, TextLayoutResult>>(6)
fun measure(text: String, style: TextStyle): TextLayoutResult { 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) { if (idx < 0) {
styles.add(style) styles.add(style)
byStyle.add(HashMap()) byStyle.add(HashMap())

View File

@@ -3,12 +3,8 @@ package com.reactorcoremeltdown.sizzletracker.ui.components
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
@@ -90,6 +86,9 @@ fun <T> RetroDropdown(
// Reserve the width of the longest option so the field never resizes as the // Reserve the width of the longest option so the field never resizes as the
// selection changes — a common source of toolbar "jitter". // selection changes — a common source of toolbar "jitter".
val longest = options.maxByOrNull { optionLabel(it).length }?.let(optionLabel).orEmpty() 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) {
Box( Box(
Modifier Modifier
@@ -101,13 +100,13 @@ fun <T> RetroDropdown(
// Invisible sizer (longest option) fixes the width; the visible value // Invisible sizer (longest option) fixes the width; the visible value
// draws on top and clips with an ellipsis if it ever overflows. // draws on top and clips with an ellipsis if it ever overflows.
Text( Text(
"$label:$longest", "$prefix$longest",
color = Color.Transparent, color = Color.Transparent,
fontFamily = FontFamily.Monospace, fontSize = 12.sp, fontFamily = FontFamily.Monospace, fontSize = 12.sp,
maxLines = 1, softWrap = false, maxLines = 1, softWrap = false,
) )
Text( Text(
"$label:${optionLabel(selected)}", "$prefix${optionLabel(selected)}",
color = c.text, fontFamily = FontFamily.Monospace, fontSize = 12.sp, color = c.text, fontFamily = FontFamily.Monospace, fontSize = 12.sp,
maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis, maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis,
) )
@@ -135,21 +134,3 @@ fun SectionLabel(text: String, modifier: Modifier = Modifier) {
) )
} }
/** A grid of pixel "cells" used as a lightweight icon (e.g. play/stop glyphs).
* [rows] are strings of '#' (filled) and ' ' (empty). */
@Composable
fun PixelGlyph(rows: List<String>, color: Color, cell: Int = 3) {
Column {
rows.forEach { line ->
Row(horizontalArrangement = Arrangement.spacedBy(0.dp)) {
line.forEach { ch ->
Box(
Modifier
.size(cell.dp)
.background(if (ch == '#') color else Color.Transparent),
)
}
}
}
}
}

View File

@@ -10,6 +10,7 @@ import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize 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.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue 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.MixerChannel
import com.reactorcoremeltdown.sizzletracker.model.ToolboxKind import com.reactorcoremeltdown.sizzletracker.model.ToolboxKind
import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel 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.RetroButton
import com.reactorcoremeltdown.sizzletracker.ui.components.RetroDropdown import com.reactorcoremeltdown.sizzletracker.ui.components.RetroDropdown
import com.reactorcoremeltdown.sizzletracker.ui.components.SectionLabel 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) { private fun ChannelStrip(vm: AppViewModel, channel: MixerChannel, modifier: Modifier) {
val c = LocalRetro.current val c = LocalRetro.current
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // subscribe so faders/mute/solo/routing refresh @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( Column(
modifier.background(c.surface).padding(6.dp), modifier.background(c.surface).padding(6.dp),
@@ -133,9 +131,41 @@ private fun ChannelStrip(vm: AppViewModel, channel: MixerChannel, modifier: Modi
Text("CH ${channel.index + 1}", color = c.accent, Text("CH ${channel.index + 1}", color = c.accent,
fontFamily = FontFamily.Monospace, fontSize = 13.sp) fontFamily = FontFamily.Monospace, fontSize = 13.sp)
if (isLandscape()) {
// Landscape: two columns per bus — routing on the left, level controls on
// the right — so the fader + LIM/mute/solo are visible without scrolling
// in the short height. The routing column scrolls if a device is extra
// short; the level column never does.
Row(Modifier.weight(1f).fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Column(
Modifier.weight(1f).fillMaxHeight().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(4.dp),
) { ChannelRouting(vm, channel) }
Column(
Modifier.weight(1f).fillMaxHeight(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) { ChannelLevels(vm, channel) }
}
} else {
// Portrait: one column, the fader filling the leftover height.
ChannelRouting(vm, channel)
ChannelLevels(vm, channel)
}
}
}
/** Instrument + FX + MIDI-channel selectors for a mixer [channel]. */
@Composable
private fun ColumnScope.ChannelRouting(vm: AppViewModel, channel: MixerChannel) {
val c = LocalRetro.current
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // defeat strong-skipping so routing refreshes
val toolbox = vm.project.toolbox
val instrumentSlots = toolbox.filter { it.type?.kind == ToolboxKind.INSTRUMENT }
val effectSlots = toolbox.filter { it.type?.kind == ToolboxKind.EFFECT }
SectionLabel("Instrument") SectionLabel("Instrument")
RetroDropdown( RetroDropdown(
label = "INS", label = "",
options = listOf(-1) + instrumentSlots.map { it.index }, options = listOf(-1) + instrumentSlots.map { it.index },
selected = channel.instrumentSlot, selected = channel.instrumentSlot,
optionLabel = { idx -> if (idx < 0) "" else "${idx + 1}:${toolbox[idx].name}" }, optionLabel = { idx -> if (idx < 0) "" else "${idx + 1}:${toolbox[idx].name}" },
@@ -146,7 +176,7 @@ private fun ChannelStrip(vm: AppViewModel, channel: MixerChannel, modifier: Modi
SectionLabel("FX 1-4") SectionLabel("FX 1-4")
for (fx in 0 until Mixer.FX_SLOTS) { for (fx in 0 until Mixer.FX_SLOTS) {
RetroDropdown( RetroDropdown(
label = "FX${fx + 1}", label = "",
options = listOf(-1) + effectSlots.map { it.index }, options = listOf(-1) + effectSlots.map { it.index },
selected = channel.fxSlots[fx], selected = channel.fxSlots[fx],
optionLabel = { idx -> if (idx < 0) "" else "${idx + 1}:${toolbox[idx].name}" }, optionLabel = { idx -> if (idx < 0) "" else "${idx + 1}:${toolbox[idx].name}" },
@@ -166,16 +196,24 @@ private fun ChannelStrip(vm: AppViewModel, channel: MixerChannel, modifier: Modi
) )
RetroButton("+") { channel.midiChannel = (channel.midiChannel + 1).coerceAtMost(16); vm.bumpForToolbar() } RetroButton("+") { channel.midiChannel = (channel.midiChannel + 1).coerceAtMost(16); vm.bumpForToolbar() }
} }
}
/** Volume fader + limiter + mute/solo for a mixer [channel]. The fader fills the
* leftover height of its column. */
@Composable
private fun ColumnScope.ChannelLevels(vm: AppViewModel, channel: MixerChannel) {
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // defeat strong-skipping so LIM/mute/solo refresh
SectionLabel("Volume") SectionLabel("Volume")
// Vertical fader fills the remaining strip height.
VerticalFader( VerticalFader(
value = channel.volume, value = channel.volume,
default = Mixer.DEFAULT_VOLUME, default = Mixer.DEFAULT_VOLUME,
onValueChange = { channel.volume = it; vm.bumpForToolbar() }, onValueChange = { channel.volume = it; vm.bumpForToolbar() },
modifier = Modifier.weight(1f).fillMaxWidth(), 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)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
RetroButton("M", active = channel.mute, modifier = Modifier.weight(1f)) { RetroButton("M", active = channel.mute, modifier = Modifier.weight(1f)) {
channel.mute = !channel.mute; vm.bumpForToolbar() channel.mute = !channel.mute; vm.bumpForToolbar()
@@ -185,7 +223,6 @@ private fun ChannelStrip(vm: AppViewModel, channel: MixerChannel, modifier: Modi
} }
} }
} }
}
/** A vertical volume fader: fill grows from the bottom; tap or drag to set. /** A vertical volume fader: fill grows from the bottom; tap or drag to set.
* Snaps to the [default] level within a small band so returning to the standard * Snaps to the [default] level within a small band so returning to the standard

View File

@@ -27,7 +27,6 @@ import androidx.compose.material.icons.filled.ContentPaste
import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Keyboard 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.Search
import androidx.compose.material.icons.filled.SportsEsports import androidx.compose.material.icons.filled.SportsEsports
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
@@ -37,7 +36,6 @@ import androidx.compose.material3.Card
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -46,7 +44,6 @@ import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -57,16 +54,13 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import com.reactorcoremeltdown.sizzletracker.input.GamepadChord import com.reactorcoremeltdown.sizzletracker.input.GamepadChord
import com.reactorcoremeltdown.sizzletracker.input.GamepadInput import com.reactorcoremeltdown.sizzletracker.input.GamepadInput
import com.reactorcoremeltdown.sizzletracker.input.InputSource import com.reactorcoremeltdown.sizzletracker.input.InputSource
import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
import com.reactorcoremeltdown.sizzletracker.ui.theme.RetroPalette
/** /**
* The fourth tab, built from STANDARD Material 3 components (Cards, Buttons, * The fourth tab, built from STANDARD Material 3 components (Cards, Buttons,
@@ -209,23 +203,34 @@ private fun ProjectCard(vm: AppViewModel) {
val songs = vm.songNames() val songs = vm.songNames()
var selectedName by remember { mutableStateOf<String?>(null) } var selectedName by remember { mutableStateOf<String?>(null) }
val current = selectedName?.takeIf { it in songs } ?: songs.firstOrNull() // Show this session's explicit pick, else the loaded project (its name survives
// restarts via the autosave), else the first saved song.
val current = selectedName?.takeIf { it in songs }
?: vm.songName.takeIf { it in songs }
?: songs.firstOrNull()
var menuOpen by remember { mutableStateOf(false) } var menuOpen by remember { mutableStateOf(false) }
var showSave by remember { mutableStateOf(false) } var showSave by remember { mutableStateOf(false) }
var confirmDelete by remember { mutableStateOf<String?>(null) } var confirmDelete by remember { mutableStateOf<String?>(null) }
var confirmNew by remember { mutableStateOf(false) } var confirmNew by remember { mutableStateOf(false) }
var importError by remember { mutableStateOf<String?>(null) }
// Import a .sng (desktop interchange) into the current project. // Import a .sng (desktop interchange) into the current project. Read/parse
// failures surface as a popup instead of doing nothing.
val importer = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> val importer = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
uri ?: return@rememberLauncherForActivityResult if (uri == null) return@rememberLauncherForActivityResult
runCatching { val bytes = runCatching {
context.contentResolver.openInputStream(uri)?.use { it.readBytes() } 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") { SettingsCard("Project", subtitle = "Full projects saved on this device; .sng for desktop interchange") {
// On-device full-project browser: load (dropdown) + save + delete. // On-device full-project browser: the load dropdown sits ABOVE the action
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { // buttons (save / new / delete) so the selector is clear of them.
Box { Box {
OutlinedButton(onClick = { menuOpen = true }) { Text(current ?: "No projects", maxLines = 1) } OutlinedButton(onClick = { menuOpen = true }) { Text(current ?: "No projects", maxLines = 1) }
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
@@ -240,6 +245,7 @@ private fun ProjectCard(vm: AppViewModel) {
} }
} }
} }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
Button(onClick = { showSave = true }) { Text("Save") } Button(onClick = { showSave = true }) { Text("Save") }
OutlinedButton(onClick = { confirmNew = true }) { Text("New") } OutlinedButton(onClick = { confirmNew = true }) { Text("New") }
if (current != null) { 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) { if (confirmNew) {
AlertDialog( AlertDialog(
onDismissRequest = { confirmNew = false }, onDismissRequest = { confirmNew = false },
@@ -347,48 +362,106 @@ private fun AudioDevicesCard(vm: AppViewModel) {
DeviceRow("Output", outputs, vm.outputDeviceId) { vm.setAudioOutput(it) } DeviceRow("Output", outputs, vm.outputDeviceId) { vm.setAudioOutput(it) }
HorizontalDivider() HorizontalDivider()
DeviceRow("Input", inputs, vm.inputDeviceId) { vm.setAudioInput(it) } 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 @Composable
private fun ThemeCard(vm: AppViewModel) { private fun ThemeCard(vm: AppViewModel) {
val clipboard = LocalClipboardManager.current val context = LocalContext.current
SettingsCard("Color Theme", subtitle = "Pick a palette or export it as text") { @Suppress("UNUSED_VARIABLE") val rev = vm.revision // refresh the list after import/delete
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { val themes = vm.themeNames()
RetroPalette.ALL.forEach { p -> val current = vm.palette.name
FilterChip( var menuOpen by remember { mutableStateOf(false) }
selected = vm.palette.name == p.name, var confirmDelete by remember { mutableStateOf<String?>(null) }
onClick = { vm.selectPalette(p) }, var importError by remember { mutableStateOf<String?>(null) }
label = { Text(p.name) },
leadingIcon = { Icon(Icons.Filled.Palette, 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 },
) )
} }
} }
OutlinedButton(
onClick = { clipboard.setText(AnnotatedString(exportTheme(vm.palette))) },
modifier = Modifier.padding(top = 8.dp),
) { Text("Export theme") }
} }
// Delete only offered for user themes — stock themes are protected.
if (!vm.isStockTheme(current)) {
Button(
onClick = { confirmDelete = current },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
),
) { Text("Delete") }
}
}
HorizontalDivider()
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = { shareThemeFile(context, vm.themeExportFile()) }) {
Icon(Icons.Filled.ContentCopy, null, Modifier.padding(end = 6.dp))
Text("Export .szt")
}
OutlinedButton(onClick = { importer.launch(arrayOf("*/*")) }) {
Icon(Icons.Filled.ContentPaste, null, Modifier.padding(end = 6.dp))
Text("Import .szt")
}
}
Text(
"Stock themes can't be deleted; imported .szt themes appear in the list.",
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
importError?.let { msg ->
AlertDialog(
onDismissRequest = { importError = null },
title = { Text("Import failed") },
text = { Text(msg) },
confirmButton = { TextButton(onClick = { importError = null }) { Text("OK") } },
)
}
confirmDelete?.let { name ->
AlertDialog(
onDismissRequest = { confirmDelete = null },
title = { Text("Delete theme") },
text = { Text("Delete \"$name\"? This cannot be undone.") },
confirmButton = {
TextButton(onClick = { vm.deleteTheme(name); confirmDelete = null }) { Text("Delete") }
},
dismissButton = { TextButton(onClick = { confirmDelete = null }) { Text("Cancel") } },
)
}
}
/** Launch the system share sheet for an exported .szt theme file. */
private fun shareThemeFile(context: Context, file: java.io.File) {
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
val send = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(send, "Share theme"))
} }
/** A bindable action: user-facing label + the action id the input handlers use. */ /** 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 * Searchable + collapsible MIDI binding list. Each module is a Card whose header
* toggles expansion; its actions are real [BindingRow]s whose Learn button arms * toggles expansion; its actions are real [BindingRow]s whose Learn button arms

View File

@@ -15,10 +15,13 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment 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 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 * 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 * ([SamplerPads]). Pressing a pad selects it AND auditions its sample the instant
* for both testing and picking which one to edit); long-pressing a pad clears it. * it's touched (so pads are for both testing and picking which one to edit). The top
* The top half acts on the selected pad: Import a WAV or Record to assign a sample, * half acts on the selected pad: Import a WAV or Record to assign a sample, position
* position the two waveform markers to set the play slice, and CLIP to destructively * the two waveform markers to set the play slice, CLIP to destructively trim the
* trim the sample down to that slice (no undo). Per-pad volume lives in the fader * sample down to that slice, and CLEAR to empty just that pad (all no undo). Per-pad
* bank. When the sequencer plays a pad's note the sample fires at its natural pitch. * volume lives in the fader bank. When the sequencer plays a pad's note the sample
* fires at its natural pitch.
*/ */
@Composable @Composable
fun SamplerEditor(vm: AppViewModel, slot: ToolboxSlot) { 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 @Suppress("UNUSED_VARIABLE") val rev = vm.revision // subscribe: pads/waveform refresh
var pad by remember(slot.index) { mutableIntStateOf(0) } var pad by remember(slot.index) { mutableIntStateOf(0) }
var importError by remember(slot.index) { mutableStateOf<String?>(null) }
val sampleId = slot.string(SamplerPads.key(pad)) val sampleId = slot.string(SamplerPads.key(pad))
val sliceStart = slot.float(SamplerPads.sliceStartKey(pad), 0f) val sliceStart = slot.float(SamplerPads.sliceStartKey(pad), 0f)
val sliceEnd = slot.float(SamplerPads.sliceEndKey(pad), 1f) val sliceEnd = slot.float(SamplerPads.sliceEndKey(pad), 1f)
// System file picker: assigns the chosen WAV to the currently-selected pad. // 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 -> val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
uri ?: return@rememberLauncherForActivityResult if (uri == null) return@rememberLauncherForActivityResult
runCatching { val bytes = runCatching {
context.contentResolver.openInputStream(uri)?.use { it.readBytes() } 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)) { Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -89,6 +101,9 @@ fun SamplerEditor(vm: AppViewModel, slot: ToolboxSlot) {
if (sampleId.isNotBlank()) { if (sampleId.isNotBlank()) {
// Destructively trim the sample to the current slice markers (no undo). // Destructively trim the sample to the current slice markers (no undo).
RetroButton("✂ CLIP") { vm.trimSamplerPad(slot, pad) } 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) } RetroButton("CLEAR", danger = true) { vm.clearSamplerPad(slot, pad) }
} }
} }
@@ -150,13 +165,22 @@ fun SamplerEditor(vm: AppViewModel, slot: ToolboxSlot) {
// ----- 16-pad bank (bottom section) ----- // ----- 16-pad bank (bottom section) -----
Text( 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, color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 11.sp,
) )
// Pass rev so assign/clear (which only bump revision) redraw the pads: a // Pass rev so assign/clear (which only bump revision) redraw the pads: a
// changed Int param defeats Compose strong-skipping on these leaves. // changed Int param defeats Compose strong-skipping on these leaves.
PadBank(vm, slot, pad, rev, onSelect = { pad = it }) 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). */ /** 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) .background(fill)
.pointerInput(pad, slot.index) { .pointerInput(pad, slot.index) {
detectTapGestures( detectTapGestures(
onTap = { onSelect(pad); vm.auditionSample(slot, note) }, // Fire on touch-DOWN so a pad sounds the instant it's pressed
onLongPress = { onSelect(pad); vm.clearSamplerPad(slot, pad) }, // (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, contentAlignment = Alignment.Center,
@@ -261,7 +288,7 @@ private fun VerticalPadSlider(
.pointerInput(pad, slot.index) { .pointerInput(pad, slot.index) {
detectTapGestures { off -> detectTapGestures { off ->
onSelect(pad) 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() vm.bumpForToolbar()
} }
} }
@@ -270,7 +297,7 @@ private fun VerticalPadSlider(
onDragStart = { onSelect(pad) }, onDragStart = { onSelect(pad) },
onDrag = { change, _ -> onDrag = { change, _ ->
change.consume() 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() 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. /** Down-sample the waveform to [buckets] min/max pairs for cheap drawing.
* Shared with the SoundFont editor's preview. */ * Shared with the SoundFont editor's preview. */
internal fun computePeaks(data: FloatArray, buckets: Int): Pair<FloatArray, FloatArray> { internal fun computePeaks(data: FloatArray, buckets: Int): Pair<FloatArray, FloatArray> {

View File

@@ -15,10 +15,13 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -57,11 +60,18 @@ fun SoundFontEditor(vm: AppViewModel, slot: ToolboxSlot) {
val sample = SampleStore.get(sfId) val sample = SampleStore.get(sfId)
val root = slot.float("sfRoot", 60f).toInt() val root = slot.float("sfRoot", 60f).toInt()
var importError by remember(slot.index) { mutableStateOf<String?>(null) }
val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
uri ?: return@rememberLauncherForActivityResult if (uri == null) return@rememberLauncherForActivityResult
runCatching { val bytes = runCatching {
context.contentResolver.openInputStream(uri)?.use { it.readBytes() } 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)) { 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 + 2) * 12) // upper octave on top
AuditionOctave(vm, slot.index, (octave + 1) * 12) // lower octave below 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]). /** A compact vertical fader bound to a numeric slot param ([key], range [min]..[max]).

View File

@@ -6,10 +6,8 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items 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.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
@@ -36,7 +39,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.content.FileProvider 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.ToolboxSlot
import com.reactorcoremeltdown.sizzletracker.model.ToolboxType import com.reactorcoremeltdown.sizzletracker.model.ToolboxType
import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
import com.reactorcoremeltdown.sizzletracker.ui.components.PianoKey import com.reactorcoremeltdown.sizzletracker.ui.isLandscape
import com.reactorcoremeltdown.sizzletracker.ui.components.PianoOctave
import com.reactorcoremeltdown.sizzletracker.ui.components.RetroButton import com.reactorcoremeltdown.sizzletracker.ui.components.RetroButton
import com.reactorcoremeltdown.sizzletracker.ui.components.RetroDropdown import com.reactorcoremeltdown.sizzletracker.ui.components.RetroDropdown
import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro 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. * The third tab. Top half: a 4x4 grid of 16 slots holding instruments or effects.
@@ -84,30 +88,44 @@ fun ToolboxScreen(vm: AppViewModel) {
if (editing != null) editing = null else picking = null if (editing != null) editing = null else picking = null
} }
// Shared tile gestures (index-based) so both layouts drive the same state.
val onTapTile: (Int) -> Unit = { selected = it }
val onDoubleTapTile: (Int) -> Unit = { i -> if (vm.project.toolbox[i].isEmpty) picking = i else editing = i }
val onLongPressTile: (Int) -> Unit = { i -> vm.project.toolbox[i].clearSlot(); vm.bumpForToolbar() }
if (isLandscape()) {
// Landscape: a fixed 4x4 grid squashed to fit the height (no scroll) BESIDE
// the audition keyboard, which gets the larger width so its CH/OCT/VEL
// toolbar fits without horizontal scrolling.
Row(Modifier.fillMaxSize().background(c.background)) {
ToolboxTileGrid(vm, selected, onTapTile, onDoubleTapTile, onLongPressTile,
Modifier.weight(1f).fillMaxHeight())
Box(Modifier.width(2.dp).fillMaxHeight().background(c.accent))
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)) { Column(Modifier.fillMaxSize().background(c.background)) {
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Fixed(4), 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), modifier = Modifier.weight(1.7f).fillMaxWidth().padding(6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
items(vm.project.toolbox) { slot -> items(vm.project.toolbox) { slot ->
SlotTile( SlotTile(
// The changing revision defeats strong-skipping so tiles refresh. rev = vm.revision, slot = slot, selected = selected == slot.index,
rev = vm.revision, onTap = { onTapTile(slot.index) },
slot = slot, onDoubleTap = { onDoubleTapTile(slot.index) },
selected = selected == slot.index, onLongPress = { onLongPressTile(slot.index) },
onTap = { selected = slot.index }, modifier = Modifier.fillMaxWidth().aspectRatio(1f),
onDoubleTap = { if (slot.isEmpty) picking = slot.index else editing = slot.index },
onLongPress = { slot.clearSlot(); vm.bumpForToolbar() },
) )
} }
} }
Box(Modifier.fillMaxWidth().height(2.dp).background(c.accent)) // fixed divider Box(Modifier.fillMaxWidth().height(2.dp).background(c.accent)) // fixed divider
com.reactorcoremeltdown.sizzletracker.ui.StackedKeyboard(vm, punchIn = false, Modifier.weight(1f)) com.reactorcoremeltdown.sizzletracker.ui.StackedKeyboard(vm, punchIn = false, Modifier.weight(1f))
} }
}
picking?.let { index -> picking?.let { index ->
DevicePicker( DevicePicker(
@@ -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 @Composable
private fun SlotTile( private fun SlotTile(
rev: Int, rev: Int,
@@ -131,15 +183,14 @@ private fun SlotTile(
onTap: () -> Unit, onTap: () -> Unit,
onDoubleTap: () -> Unit, onDoubleTap: () -> Unit,
onLongPress: () -> Unit, onLongPress: () -> Unit,
modifier: Modifier = Modifier,
) { ) {
@Suppress("UNUSED_PARAMETER") val ignored = rev // param only used to bust skipping @Suppress("UNUSED_PARAMETER") val ignored = rev // param only used to bust skipping
val c = LocalRetro.current val c = LocalRetro.current
val borderColor = when { selected -> c.playhead; !slot.isEmpty -> c.accent; else -> c.grid } 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 } val fill = when { selected -> c.accent.copy(alpha = 0.28f); !slot.isEmpty -> c.accent.copy(alpha = 0.12f); else -> c.surface }
Box( Box(
Modifier modifier
.fillMaxWidth()
.aspectRatio(1f)
.border(if (selected) 2.dp else 1.dp, borderColor, RectangleShape) .border(if (selected) 2.dp else 1.dp, borderColor, RectangleShape)
.background(fill) .background(fill)
.pointerInput(slot.index, slot.isEmpty) { .pointerInput(slot.index, slot.isEmpty) {
@@ -151,13 +202,19 @@ private fun SlotTile(
}, },
contentAlignment = Alignment.Center, 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'), Text("${slot.index + 1}".padStart(2, '0'),
color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 10.sp) color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 10.sp)
Text( Text(
if (slot.isEmpty) "" else slot.name, if (slot.isEmpty) "" else slot.name,
color = if (slot.isEmpty) c.textDim else c.text, color = if (slot.isEmpty) c.textDim else c.text,
fontFamily = FontFamily.Monospace, fontSize = 11.sp, fontFamily = FontFamily.Monospace, fontSize = 11.sp,
textAlign = TextAlign.Center, maxLines = 1, overflow = TextOverflow.Ellipsis,
) )
slot.type?.let { slot.type?.let {
Text(it.kind.name.take(3), color = c.textDim, 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. */ /** A simple overlay list of all available instruments and effects to load. */
@Composable @Composable
private fun DevicePicker(onDismiss: () -> Unit, onPick: (ToolboxType) -> Unit) { 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. // Preset browser: load / save / import / share for this device.
PresetBar(vm, slot) PresetBar(vm, slot)
// The Sampler gets a bespoke editor (waveform + slicing + record); // The Sampler / SoundFont / Delay get bespoke editors; the reverb has so
// the LFO adds a modulation-target picker; everything else uses the // many controls it's paged (swipeable sections with dot pagination); the
// auto-generated parameter list. // LFO adds a modulation-target picker; everything else uses the
// auto-generated parameter list, laid out two-per-row to fit more.
when (type) { when (type) {
ToolboxType.SAMPLER -> SamplerEditor(vm, slot) ToolboxType.SAMPLER -> SamplerEditor(vm, slot)
ToolboxType.SOUNDFONT -> SoundFontEditor(vm, slot) ToolboxType.SOUNDFONT -> SoundFontEditor(vm, slot)
ToolboxType.DELAY -> DelayEditor(vm, slot) ToolboxType.DELAY -> DelayEditor(vm, slot)
ToolboxType.AMBIENCE -> AmbiencePager(vm, slot)
ToolboxType.LFO -> { ToolboxType.LFO -> {
type.params.forEach { spec -> ParamControl(vm, slot, spec) } ParamGrid(vm, slot, type.params)
LfoTargetPicker(vm, slot) LfoTargetPicker(vm, slot)
} }
else -> type.params.forEach { spec -> ParamControl(vm, slot, spec) } else -> ParamGrid(vm, slot, type.params)
} }
} }
} }
} }
/** Lay a list of [params] out two-per-row so the editor fits more controls than a
* single stacked column. Each [ParamControl] fills its half-width cell (an odd last
* param gets an empty cell beside it). */
@Composable
private fun ParamGrid(vm: AppViewModel, slot: ToolboxSlot, params: List<ParamSpec>) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
var i = 0
while (i < params.size) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box(Modifier.weight(1f)) { ParamControl(vm, slot, params[i]) }
if (i + 1 < params.size) {
Box(Modifier.weight(1f)) { ParamControl(vm, slot, params[i + 1]) }
} else {
Spacer(Modifier.weight(1f))
}
}
i += 2
}
}
}
/** The reverb has ~17 controls — too many to stack. They're grouped into labelled
* sections shown one page at a time in a swipeable [HorizontalPager] with tappable
* dot pagination below. Each page uses the same two-column [ParamGrid]. Works the
* same in portrait and landscape (the editor overlay is full-width in both). */
@Composable
private fun AmbiencePager(vm: AppViewModel, slot: ToolboxSlot) {
val c = LocalRetro.current
val byKey = (slot.type ?: return).params.associateBy { it.key }
val pagerState = rememberPagerState(pageCount = { AMBIENCE_SECTIONS.size })
val scope = rememberCoroutineScope()
Column(Modifier.fillMaxWidth()) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxWidth().height(210.dp),
pageSpacing = 12.dp,
) { page ->
val section = AMBIENCE_SECTIONS[page]
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
section.title, color = c.accent,
fontFamily = FontFamily.Monospace, fontSize = 12.sp,
)
ParamGrid(vm, slot, section.keys.mapNotNull { byKey[it] })
}
}
// Dot pagination: tap a dot (or swipe the page) to jump to that section.
Row(
Modifier.fillMaxWidth().padding(top = 8.dp),
horizontalArrangement = Arrangement.Center,
) {
for (i in AMBIENCE_SECTIONS.indices) {
val active = i == pagerState.currentPage
Box(
Modifier
.padding(horizontal = 4.dp)
.size(if (active) 10.dp else 7.dp)
.background(if (active) c.accent else c.grid, CircleShape)
.clickable { scope.launch { pagerState.animateScrollToPage(i) } },
)
}
}
}
}
/** Grouping of the reverb's parameters into swipeable sections. */
private class AmbienceSection(val title: String, val keys: List<String>)
private val AMBIENCE_SECTIONS = listOf(
AmbienceSection("Space", listOf("algo", "predelay", "roomsize", "decay")),
AmbienceSection("Tone", listOf("hfdamp", "lfabsorb", "diffusion")),
AmbienceSection("Modulation", listOf("modamt", "modrate")),
AmbienceSection("Character", listOf("erlevel", "saturation")),
AmbienceSection("Mix", listOf("wet", "dry")),
AmbienceSection("Ducking", listOf("duckamt", "duckthresh", "duckattack", "duckrelease")),
)
/** /**
* The preset browser bar shown at the top of every device editor: a dropdown of * The 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 * 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) val names = vm.presetNames(type)
var showSave by remember { mutableStateOf(false) } var showSave by remember { mutableStateOf(false) }
var confirmDelete by remember { mutableStateOf<String?>(null) } var confirmDelete by remember { mutableStateOf<String?>(null) }
var importError by remember { mutableStateOf<String?>(null) }
// The preset currently chosen in the dropdown (what Delete acts on). Falls back // The preset currently chosen in the dropdown (what Delete acts on). Falls back
// to the slot's name, then the first available preset. // to the slot's name, then the first available preset.
var selected by remember(slot.index) { mutableStateOf<String?>(null) } var selected by remember(slot.index) { mutableStateOf<String?>(null) }
val current = selected?.takeIf { it in names } ?: slot.name.takeIf { it in names } ?: names.firstOrNull() 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). // 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 -> val importer = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
uri ?: return@rememberLauncherForActivityResult if (uri == null) return@rememberLauncherForActivityResult
runCatching { val bytes = runCatching {
context.contentResolver.openInputStream(uri)?.use { it.readBytes() } 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)) { 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( Row(
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
// DELETE kept at the far LEFT, isolated by a gap, so reaching for the // 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) { if (current != null) {
RetroButton("DELETE", danger = true) { confirmDelete = current } RetroButton("DELETE", danger = true) { confirmDelete = current }
Spacer(Modifier.width(20.dp)) 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.savePreset(slot, name) // ensure a file exists to share
vm.presetShareFile(type, name)?.let { sharePresetFile(context, it) } 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( Text(
"Presets are plain text, saved per device under the app's storage. " + "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 -> confirmDelete?.let { name ->
AlertDialog( AlertDialog(
onDismissRequest = { confirmDelete = null }, onDismissRequest = { confirmDelete = null },

View File

@@ -31,6 +31,7 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText import androidx.compose.ui.text.drawText
@@ -77,12 +78,16 @@ fun ArrangementRoll(vm: AppViewModel) {
val arr = vm.project.arrangement val arr = vm.project.arrangement
val sig = vm.project.timeSignature val sig = vm.project.timeSignature
val scroll = rememberScrollState() 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 density = LocalDensity.current
val measurer = rememberTextMeasurer() val measurer = rememberTextMeasurer()
val glyphs = remember(measurer) { GlyphCache(measurer) } val glyphs = remember(measurer) { GlyphCache(measurer) }
val beatWidth = 20.dp 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 rulerHeight = 18.dp
val beatWidthPx = with(density) { beatWidth.toPx() } val beatWidthPx = with(density) { beatWidth.toPx() }
val rulerHeightPx = with(density) { rulerHeight.toPx() } val rulerHeightPx = with(density) { rulerHeight.toPx() }
@@ -116,7 +121,13 @@ fun ArrangementRoll(vm: AppViewModel) {
vm.transport.collect { t -> vm.transport.collect { t ->
if (t.isPlaying && t.currentBeat != lastBeat) { if (t.isPlaying && t.currentBeat != lastBeat) {
lastBeat = t.currentBeat 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) scroll.animateScrollTo(target)
} }
} }
@@ -151,7 +162,7 @@ fun ArrangementRoll(vm: AppViewModel) {
Column(Modifier.fillMaxSize().background(c.background)) { Column(Modifier.fillMaxSize().background(c.background)) {
Box(Modifier.weight(1f).fillMaxWidth()) { Box(Modifier.weight(1f).fillMaxWidth()) {
Row(Modifier.fillMaxSize()) { Row(Modifier.fillMaxSize()) {
// ---- Fixed left column: corner + lane numbers ---- // ---- Fixed left column: corner + per-lane [mute | block number] ----
Column(Modifier.width(laneColWidth).fillMaxHeight()) { Column(Modifier.width(laneColWidth).fillMaxHeight()) {
Box(Modifier.height(rulerHeight).fillMaxWidth().background(c.surface), Box(Modifier.height(rulerHeight).fillMaxWidth().background(c.surface),
contentAlignment = Alignment.Center) { contentAlignment = Alignment.Center) {
@@ -159,10 +170,25 @@ fun ArrangementRoll(vm: AppViewModel) {
} }
for (lane in 0 until ArrModel.LANE_COUNT) { for (lane in 0 until ArrModel.LANE_COUNT) {
val active = vm.activeBlock == lane val active = vm.activeBlock == lane
val muted = arr.laneMuted[lane]
Row(Modifier.weight(1f).fillMaxWidth()) {
// Mute toggle (left of the block header): mutes this lane.
Box(
Modifier
.width(muteColWidth)
.fillMaxHeight()
.background(if (muted) c.danger.copy(alpha = 0.30f) else c.surface)
.clickable { vm.toggleArrangementLaneMute(lane) },
contentAlignment = Alignment.Center,
) {
Text("M", color = if (muted) c.danger else c.textDim,
fontFamily = FontFamily.Monospace, fontSize = 9.sp)
}
// Block number: tap to edit that block in the tracker above.
Box( Box(
Modifier Modifier
.weight(1f) .weight(1f)
.fillMaxWidth() .fillMaxHeight()
.background(if (active) c.accent.copy(alpha = 0.25f) else c.surface) .background(if (active) c.accent.copy(alpha = 0.25f) else c.surface)
.clickable { vm.setActiveBlock(lane) }, .clickable { vm.setActiveBlock(lane) },
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
@@ -172,9 +198,14 @@ fun ArrangementRoll(vm: AppViewModel) {
} }
} }
} }
}
// ---- Scrollable ruler + roll (one Canvas) ---- // ---- Scrollable ruler + roll (one Canvas) ----
Box(Modifier.weight(1f).fillMaxHeight().horizontalScroll(scroll)) { Box(
Modifier.weight(1f).fillMaxHeight()
.onSizeChanged { viewportWidthPx = it.width }
.horizontalScroll(scroll),
) {
Canvas( Canvas(
Modifier Modifier
.width(beatWidth * arr.lengthBeats) .width(beatWidth * arr.lengthBeats)

View File

@@ -6,6 +6,9 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -16,6 +19,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@@ -23,6 +27,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.reactorcoremeltdown.sizzletracker.model.TimeSignature import com.reactorcoremeltdown.sizzletracker.model.TimeSignature
import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel 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.RetroButton
import com.reactorcoremeltdown.sizzletracker.ui.components.RetroDropdown import com.reactorcoremeltdown.sizzletracker.ui.components.RetroDropdown
import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
@@ -38,6 +43,24 @@ import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
*/ */
@Composable @Composable
fun TrackerScreen(vm: AppViewModel) { 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 val c = LocalRetro.current
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
// Upper section: transport + pattern grid. Weighted heavier than the lower // Upper section: transport + pattern grid. Weighted heavier than the lower
@@ -48,44 +71,81 @@ fun TrackerScreen(vm: AppViewModel) {
PatternGrid(vm, Modifier.weight(1f)) PatternGrid(vm, Modifier.weight(1f))
} }
} }
Box( Box(Modifier.fillMaxWidth().height(2.dp).background(c.accent))
Modifier TrackerLower(vm, Modifier.weight(1f).fillMaxWidth())
.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)
} }
} }
/** 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 @Composable
private fun TransportToolbar(vm: AppViewModel) { private fun TransportToolbar(vm: AppViewModel) {
val c = LocalRetro.current val c = LocalRetro.current
val transport by vm.transport.collectAsState() val landscape = isLandscape()
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // subscribe so tempo/len/sig readouts refresh
val project = vm.project @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)) { Column(Modifier.fillMaxWidth().background(c.surface).padding(6.dp)) {
// Row 1: transport + tempo + time signature + note-off insert. scrollRow(topPad = false) { TransportButtons(vm) }
Row( if (landscape) {
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), scrollRow(topPad = true) {
horizontalArrangement = Arrangement.spacedBy(6.dp), PatternButtons(vm)
) { Spacer(Modifier.width(16.dp))
EditButtons(vm)
}
} else {
scrollRow(topPad = true) { PatternButtons(vm) }
scrollRow(topPad = true) { EditButtons(vm) }
}
}
}
/** Transport + tempo + time signature. */
@Composable
private fun TransportButtons(vm: AppViewModel) {
val c = LocalRetro.current
val transport by vm.transport.collectAsState()
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // refresh the tempo readout
val project = vm.project
RetroButton("⧉ STOP", onClick = vm::stop) RetroButton("⧉ STOP", onClick = vm::stop)
// Fixed width so the button doesn't resize as the label toggles. // Fixed width so the button doesn't resize as the label toggles.
RetroButton(if (transport.isPlaying) "❚❚ PAUSE" else "▶ PLAY", RetroButton(if (transport.isPlaying) "❚❚ PAUSE" else "▶ PLAY",
active = transport.isPlaying, width = 112.dp, onClick = vm::playPause) active = transport.isPlaying, width = 112.dp, onClick = vm::playPause)
// Tempo nudger: tap -/+ around a fixed-width readout. // Tempo nudger: tap -/+ around a fixed-width readout.
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
RetroButton("-", onClick = { vm.setTempo(project.tempoBpm - 1) }) RetroButton("-", onClick = { vm.setTempo(project.tempoBpm - 1) })
Text( Text(
"${project.tempoBpm.toInt()} BPM", "${project.tempoBpm.toInt()} BPM",
@@ -103,12 +163,20 @@ private fun TransportToolbar(vm: AppViewModel) {
onSelect = vm::setTimeSignature, 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.) /** Active block + length + scale + root. */
Row( @Composable
Modifier.fillMaxWidth().padding(top = 6.dp).horizontalScroll(rememberScrollState()), private fun PatternButtons(vm: AppViewModel) {
horizontalArrangement = Arrangement.spacedBy(6.dp), @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( RetroDropdown(
label = "LEN", label = "LEN",
options = project.timeSignature.lengthOptions, options = project.timeSignature.lengthOptions,
@@ -129,30 +197,27 @@ private fun TransportToolbar(vm: AppViewModel) {
optionLabel = { com.reactorcoremeltdown.sizzletracker.model.Pitch.name(60 + it).dropLast(1) }, optionLabel = { com.reactorcoremeltdown.sizzletracker.model.Pitch.name(60 + it).dropLast(1) },
onSelect = { project.rootNote = it; vm.bumpForToolbar() }, 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. // Rows the cursor jumps after a punch-in and per external up/down step.
RetroDropdown( RetroDropdown(
label = "SKIP", label = "SKIP",
options = listOf(1, 2, 4, 8), options = listOf(0, 1, 2, 4, 8),
selected = vm.skipStep, selected = vm.skipStep,
onSelect = vm::updateSkipStep, 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. // Swap the bottom half between the arranger and the punch-in keyboard.
RetroButton("⌨ KBD", active = vm.punchInMode, onClick = vm::togglePunchIn) RetroButton("⌨ KBD", active = vm.punchInMode, onClick = vm::togglePunchIn)
} }
}
}

View File

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