Merge branch 'feature/recording-landscape-fx-qol'
Master recording, landscape layouts, FX/arpeggiator fixes, theme library, and a batch of UX/persistence/cleanup improvements.
This commit is contained in:
@@ -49,7 +49,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
app.project, app.audioEngine, app.inputRouter,
|
app.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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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")) }
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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")
|
||||||
playNote(proj, bus, arp.currentNote(), velocity)
|
val resetBar = arpSlot.string("resetbar", "Off") == "On"
|
||||||
|
if (arp.active && arp.base == note) {
|
||||||
|
// The same note is already arpeggiating — this trigger is the
|
||||||
|
// pattern looping back over a still-held note. Keep the running
|
||||||
|
// phase (just refresh params) so the pulse stays steady across
|
||||||
|
// the loop point instead of slipping when the loop length isn't
|
||||||
|
// an exact multiple of the arp step (dotted/triplet divisions).
|
||||||
|
arp.refresh(octaves, mode, stepSamples, resetBar)
|
||||||
|
} else {
|
||||||
|
arp.start(note, velocity, octaves, mode, stepSamples, resetBar)
|
||||||
|
// The arpeggiator is monophonic (it cycles octaves of ONE note),
|
||||||
|
// so it must not stack notes. Releasing the bus first means a
|
||||||
|
// chord entered on the same line collapses to a single
|
||||||
|
// arpeggiated note (the last one), not all of them at once.
|
||||||
|
releaseBus(bus)
|
||||||
|
playNote(proj, bus, arp.currentNote(), velocity)
|
||||||
|
}
|
||||||
} else {
|
} 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 safeName = File(entry.name).name
|
||||||
val target = File(scratch, 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`
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -123,6 +129,7 @@ object ProjectIo {
|
|||||||
section == "MIXER" -> applyMixerRow(project, line)
|
section == "MIXER" -> applyMixerRow(project, line)
|
||||||
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.
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package com.reactorcoremeltdown.sizzletracker.io
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.ui.theme.RetroPalette
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads and writes a colour [RetroPalette] as plain, human-editable text — the same
|
||||||
|
* `SIZZLE-THEME 1` format the Settings tab exports, so a person can hand-tweak a
|
||||||
|
* theme and it round-trips losslessly:
|
||||||
|
*
|
||||||
|
* SIZZLE-THEME 1
|
||||||
|
* NAME=Amber CRT
|
||||||
|
* background=#0B0D0E
|
||||||
|
* surface=#15181A
|
||||||
|
* ...
|
||||||
|
*
|
||||||
|
* Parsing is lenient about individual colour lines (a missing/garbled colour falls
|
||||||
|
* back to the default palette's value) but rejects text that isn't a theme at all,
|
||||||
|
* so importing a wrong file fails cleanly instead of producing a broken palette.
|
||||||
|
*/
|
||||||
|
object ThemeIo {
|
||||||
|
const val HEADER = "SIZZLE-THEME 1"
|
||||||
|
|
||||||
|
fun export(p: RetroPalette): String = buildString {
|
||||||
|
appendLine(HEADER)
|
||||||
|
appendLine("NAME=${p.name}")
|
||||||
|
appendLine("background=${hex(p.background)}")
|
||||||
|
appendLine("surface=${hex(p.surface)}")
|
||||||
|
appendLine("grid=${hex(p.grid)}")
|
||||||
|
appendLine("text=${hex(p.text)}")
|
||||||
|
appendLine("textDim=${hex(p.textDim)}")
|
||||||
|
appendLine("accent=${hex(p.accent)}")
|
||||||
|
appendLine("beat=${hex(p.beat)}")
|
||||||
|
appendLine("bar=${hex(p.bar)}")
|
||||||
|
appendLine("playhead=${hex(p.playhead)}")
|
||||||
|
appendLine("danger=${hex(p.danger)}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse theme [text] into a [RetroPalette], or null if it isn't a valid theme. */
|
||||||
|
fun parse(text: String): RetroPalette? {
|
||||||
|
val map = HashMap<String, String>()
|
||||||
|
var sawHeader = false
|
||||||
|
text.lineSequence().map { it.trim() }.filter { it.isNotEmpty() }.forEach { line ->
|
||||||
|
if (line == HEADER) { sawHeader = true; return@forEach }
|
||||||
|
val eq = line.indexOf('=')
|
||||||
|
if (eq > 0) map[line.substring(0, eq).trim()] = line.substring(eq + 1).trim()
|
||||||
|
}
|
||||||
|
if (!sawHeader) return null
|
||||||
|
val name = map["NAME"]?.takeIf { it.isNotBlank() } ?: return null
|
||||||
|
val d = RetroPalette.AMBER // per-colour fallback
|
||||||
|
return RetroPalette(
|
||||||
|
name = name,
|
||||||
|
background = color(map["background"], d.background),
|
||||||
|
surface = color(map["surface"], d.surface),
|
||||||
|
grid = color(map["grid"], d.grid),
|
||||||
|
text = color(map["text"], d.text),
|
||||||
|
textDim = color(map["textDim"], d.textDim),
|
||||||
|
accent = color(map["accent"], d.accent),
|
||||||
|
beat = color(map["beat"], d.beat),
|
||||||
|
bar = color(map["bar"], d.bar),
|
||||||
|
playhead = color(map["playhead"], d.playhead),
|
||||||
|
danger = color(map["danger"], d.danger),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hex(c: Color): String =
|
||||||
|
"#%02X%02X%02X".format((c.red * 255).toInt(), (c.green * 255).toInt(), (c.blue * 255).toInt())
|
||||||
|
|
||||||
|
/** Parse "#RRGGBB" (or "RRGGBB") into an opaque colour; [fallback] on any error. */
|
||||||
|
private fun color(s: String?, fallback: Color): Color {
|
||||||
|
val hex = s?.trim()?.removePrefix("#") ?: return fallback
|
||||||
|
if (hex.length != 6) return fallback
|
||||||
|
val rgb = hex.toLongOrNull(16) ?: return fallback
|
||||||
|
return Color(0xFF000000L or (rgb and 0xFFFFFF))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.reactorcoremeltdown.sizzletracker.io
|
||||||
|
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.ui.theme.RetroPalette
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On-device library of USER colour themes, stored as plain [ThemeIo] text:
|
||||||
|
*
|
||||||
|
* <baseDir>/Sunset.szt ← a saved/imported theme
|
||||||
|
* <baseDir>/.export/….szt ← transient copy written for sharing
|
||||||
|
*
|
||||||
|
* The two built-in themes ([RetroPalette.ALL]) are NOT stored here — they live in
|
||||||
|
* code and can never be deleted. This library only holds themes the user imports,
|
||||||
|
* so `delete` here can only ever remove a user theme.
|
||||||
|
*/
|
||||||
|
class ThemeLibrary(private val baseDir: File) {
|
||||||
|
|
||||||
|
/** Names (without extension) of the stored user themes, alphabetically. */
|
||||||
|
fun list(): List<String> =
|
||||||
|
baseDir.listFiles { f -> f.isFile && f.name.endsWith(EXT) }
|
||||||
|
?.map { it.name.removeSuffix(EXT) }
|
||||||
|
?.sorted()
|
||||||
|
?: emptyList()
|
||||||
|
|
||||||
|
private fun file(name: String): File = File(baseDir.apply { mkdirs() }, sanitize(name) + EXT)
|
||||||
|
|
||||||
|
/** Save [palette] as a user theme (named after it). Returns the written file. */
|
||||||
|
fun save(palette: RetroPalette): File = file(palette.name).apply { writeText(ThemeIo.export(palette)) }
|
||||||
|
|
||||||
|
/** Load a stored user theme by name, or null if missing/unparseable. */
|
||||||
|
fun load(name: String): RetroPalette? =
|
||||||
|
file(name).takeIf { it.exists() }?.let { runCatching { ThemeIo.parse(it.readText()) }.getOrNull() }
|
||||||
|
|
||||||
|
/** Every stored user theme, skipping any that fail to parse. */
|
||||||
|
fun loadAll(): List<RetroPalette> = list().mapNotNull { load(it) }
|
||||||
|
|
||||||
|
/** Delete a stored user theme. Returns true if it existed. */
|
||||||
|
fun delete(name: String): Boolean = file(name).let { if (it.exists()) it.delete() else false }
|
||||||
|
|
||||||
|
/** Import theme [text] (from a picked file): parse, persist to the library, and
|
||||||
|
* return the palette — or null if the text isn't a valid theme. */
|
||||||
|
fun importText(text: String): RetroPalette? {
|
||||||
|
val palette = runCatching { ThemeIo.parse(text) }.getOrNull() ?: return null
|
||||||
|
save(palette)
|
||||||
|
return palette
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write a one-off `.szt` to a shareable sub-dir and return the file (kept out of
|
||||||
|
* the library list, like [SongLibrary.writeExport]). */
|
||||||
|
fun writeExport(palette: RetroPalette): File {
|
||||||
|
val dir = File(baseDir, EXPORT_DIR).apply { mkdirs() }
|
||||||
|
return File(dir, sanitize(palette.name) + EXT).apply { writeText(ThemeIo.export(palette)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sanitize(name: String): String =
|
||||||
|
name.trim().replace(Regex("[^A-Za-z0-9._ -]"), "_").ifBlank { "theme" }
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val EXT = ".szt"
|
||||||
|
const val EXPORT_DIR = ".export"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,12 @@ class Arrangement {
|
|||||||
/** slots[lane][beat] = patternId, or [EMPTY]. */
|
/** 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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,33 +55,86 @@ 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) }
|
||||||
Column(Modifier.fillMaxSize().background(c.background)) {
|
if (isLandscape()) {
|
||||||
// ----- Active screen -----
|
// Landscape: a side nav rail so the (short) vertical space goes entirely to
|
||||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
// the active screen instead of a bottom bar.
|
||||||
when (vm.selectedTab) {
|
Row(Modifier.fillMaxSize().background(c.background)) {
|
||||||
0 -> TrackerScreen(vm)
|
SideNav(vm)
|
||||||
1 -> MixerScreen(vm)
|
Box(Modifier.weight(1f).fillMaxHeight()) { ActiveScreen(vm) }
|
||||||
2 -> ToolboxScreen(vm)
|
|
||||||
3 -> SettingsScreen(vm)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// ----- Standard Material navigation bar -----
|
} else {
|
||||||
NavigationBar(containerColor = c.surface) {
|
Column(Modifier.fillMaxSize().background(c.background)) {
|
||||||
TABS.forEachIndexed { index, tab ->
|
Box(Modifier.weight(1f).fillMaxWidth()) { ActiveScreen(vm) }
|
||||||
NavigationBarItem(
|
BottomNav(vm)
|
||||||
selected = vm.selectedTab == index,
|
}
|
||||||
onClick = { vm.selectTab(index) },
|
}
|
||||||
icon = { Icon(tab.icon, contentDescription = tab.title) },
|
|
||||||
label = { Text(tab.title) },
|
// App-wide notice if the low-latency (Oboe) backend couldn't start and playback
|
||||||
colors = NavigationBarItemDefaults.colors(
|
// fell back to the standard path.
|
||||||
selectedIconColor = c.background,
|
vm.nativeAudioError?.let { msg ->
|
||||||
selectedTextColor = c.accent,
|
AlertDialog(
|
||||||
indicatorColor = c.accent,
|
onDismissRequest = { vm.dismissNativeAudioError() },
|
||||||
unselectedIconColor = c.textDim,
|
title = { Text("Audio engine") },
|
||||||
unselectedTextColor = c.textDim,
|
text = { Text(msg) },
|
||||||
),
|
confirmButton = { TextButton(onClick = { vm.dismissNativeAudioError() }) { Text("OK") } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The active tab's screen. */
|
||||||
|
@Composable
|
||||||
|
private fun ActiveScreen(vm: AppViewModel) {
|
||||||
|
when (vm.selectedTab) {
|
||||||
|
0 -> TrackerScreen(vm)
|
||||||
|
1 -> MixerScreen(vm)
|
||||||
|
2 -> ToolboxScreen(vm)
|
||||||
|
3 -> SettingsScreen(vm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bottom navigation bar (portrait). */
|
||||||
|
@Composable
|
||||||
|
private fun BottomNav(vm: AppViewModel) {
|
||||||
|
val c = LocalRetro.current
|
||||||
|
NavigationBar(containerColor = c.surface) {
|
||||||
|
TABS.forEachIndexed { index, tab ->
|
||||||
|
NavigationBarItem(
|
||||||
|
selected = vm.selectedTab == index,
|
||||||
|
onClick = { vm.selectTab(index) },
|
||||||
|
icon = { Icon(tab.icon, contentDescription = tab.title) },
|
||||||
|
label = { Text(tab.title) },
|
||||||
|
colors = NavigationBarItemDefaults.colors(
|
||||||
|
selectedIconColor = c.background,
|
||||||
|
selectedTextColor = c.accent,
|
||||||
|
indicatorColor = c.accent,
|
||||||
|
unselectedIconColor = c.textDim,
|
||||||
|
unselectedTextColor = c.textDim,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Side navigation rail (landscape) — same tabs, laid out vertically down the left
|
||||||
|
* edge so the screen keeps its full height. */
|
||||||
|
@Composable
|
||||||
|
private fun SideNav(vm: AppViewModel) {
|
||||||
|
val c = LocalRetro.current
|
||||||
|
NavigationRail(containerColor = c.surface) {
|
||||||
|
TABS.forEachIndexed { index, tab ->
|
||||||
|
NavigationRailItem(
|
||||||
|
selected = vm.selectedTab == index,
|
||||||
|
onClick = { vm.selectTab(index) },
|
||||||
|
icon = { Icon(tab.icon, contentDescription = tab.title) },
|
||||||
|
label = { Text(tab.title) },
|
||||||
|
colors = NavigationRailItemDefaults.colors(
|
||||||
|
selectedIconColor = c.background,
|
||||||
|
selectedTextColor = c.accent,
|
||||||
|
indicatorColor = c.accent,
|
||||||
|
unselectedIconColor = c.textDim,
|
||||||
|
unselectedTextColor = c.textDim,
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ class AppViewModel(
|
|||||||
private val presetLibrary: com.reactorcoremeltdown.sizzletracker.io.PresetLibrary,
|
private val 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
|
||||||
com.reactorcoremeltdown.sizzletracker.io.SngFormat.importInto(project, text)
|
* wiping the song with garbage. */
|
||||||
|
fun importSng(text: String): Boolean {
|
||||||
|
val looksLikeSng = Regex("(?m)^\\s*(version|bpm|sig|block)\\b").containsMatchIn(text)
|
||||||
|
if (!looksLikeSng) return false
|
||||||
|
runCatching {
|
||||||
|
com.reactorcoremeltdown.sizzletracker.io.SngFormat.importInto(project, text)
|
||||||
|
}.getOrElse { return false }
|
||||||
cursorLine = 0; cursorTrack = 0
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.reactorcoremeltdown.sizzletracker.ui
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the device is currently in landscape orientation. Drives the app's
|
||||||
|
* side-by-side landscape layouts (a side nav rail, the tracker/toolbox split into
|
||||||
|
* columns) so wide, short screens — tablets, handheld consoles — use the space
|
||||||
|
* horizontally instead of squashing everything into short vertical bands.
|
||||||
|
*
|
||||||
|
* The Activity handles rotation itself (configChanges), so [LocalConfiguration]
|
||||||
|
* updates and recomposes callers on every orientation change.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun isLandscape(): Boolean =
|
||||||
|
LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||||
@@ -21,7 +21,10 @@ class GlyphCache(private val measurer: TextMeasurer) {
|
|||||||
private val byStyle = ArrayList<HashMap<String, TextLayoutResult>>(6)
|
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())
|
||||||
|
|||||||
@@ -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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,56 +131,95 @@ 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)
|
||||||
|
|
||||||
SectionLabel("Instrument")
|
if (isLandscape()) {
|
||||||
|
// Landscape: two columns per bus — routing on the left, level controls on
|
||||||
|
// the right — so the fader + LIM/mute/solo are visible without scrolling
|
||||||
|
// in the short height. The routing column scrolls if a device is extra
|
||||||
|
// short; the level column never does.
|
||||||
|
Row(Modifier.weight(1f).fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
Column(
|
||||||
|
Modifier.weight(1f).fillMaxHeight().verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) { ChannelRouting(vm, channel) }
|
||||||
|
Column(
|
||||||
|
Modifier.weight(1f).fillMaxHeight(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) { ChannelLevels(vm, channel) }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Portrait: one column, the fader filling the leftover height.
|
||||||
|
ChannelRouting(vm, channel)
|
||||||
|
ChannelLevels(vm, channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Instrument + FX + MIDI-channel selectors for a mixer [channel]. */
|
||||||
|
@Composable
|
||||||
|
private fun ColumnScope.ChannelRouting(vm: AppViewModel, channel: MixerChannel) {
|
||||||
|
val c = LocalRetro.current
|
||||||
|
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // defeat strong-skipping so routing refreshes
|
||||||
|
val toolbox = vm.project.toolbox
|
||||||
|
val instrumentSlots = toolbox.filter { it.type?.kind == ToolboxKind.INSTRUMENT }
|
||||||
|
val effectSlots = toolbox.filter { it.type?.kind == ToolboxKind.EFFECT }
|
||||||
|
|
||||||
|
SectionLabel("Instrument")
|
||||||
|
RetroDropdown(
|
||||||
|
label = "",
|
||||||
|
options = listOf(-1) + instrumentSlots.map { it.index },
|
||||||
|
selected = channel.instrumentSlot,
|
||||||
|
optionLabel = { idx -> if (idx < 0) "—" else "${idx + 1}:${toolbox[idx].name}" },
|
||||||
|
onSelect = { channel.instrumentSlot = it; vm.bumpForToolbar() },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
SectionLabel("FX 1-4")
|
||||||
|
for (fx in 0 until Mixer.FX_SLOTS) {
|
||||||
RetroDropdown(
|
RetroDropdown(
|
||||||
label = "INS",
|
label = "",
|
||||||
options = listOf(-1) + instrumentSlots.map { it.index },
|
options = listOf(-1) + effectSlots.map { it.index },
|
||||||
selected = channel.instrumentSlot,
|
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}" },
|
||||||
onSelect = { channel.instrumentSlot = it; vm.bumpForToolbar() },
|
onSelect = { channel.fxSlots[fx] = it; vm.rebuildAudioRouting() },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
SectionLabel("FX 1-4")
|
SectionLabel("MIDI Ch")
|
||||||
for (fx in 0 until Mixer.FX_SLOTS) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
RetroDropdown(
|
RetroButton("-") { channel.midiChannel = (channel.midiChannel - 1).coerceAtLeast(0); vm.bumpForToolbar() }
|
||||||
label = "FX${fx + 1}",
|
Text(
|
||||||
options = listOf(-1) + effectSlots.map { it.index },
|
"${channel.midiChannel}", color = c.text,
|
||||||
selected = channel.fxSlots[fx],
|
fontFamily = FontFamily.Monospace, fontSize = 12.sp,
|
||||||
optionLabel = { idx -> if (idx < 0) "—" else "${idx + 1}:${toolbox[idx].name}" },
|
textAlign = TextAlign.Center, maxLines = 1,
|
||||||
onSelect = { channel.fxSlots[fx] = it; vm.rebuildAudioRouting() },
|
modifier = Modifier.width(28.dp),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
SectionLabel("MIDI Ch")
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
RetroButton("-") { channel.midiChannel = (channel.midiChannel - 1).coerceAtLeast(0); vm.bumpForToolbar() }
|
|
||||||
Text(
|
|
||||||
"${channel.midiChannel}", color = c.text,
|
|
||||||
fontFamily = FontFamily.Monospace, fontSize = 12.sp,
|
|
||||||
textAlign = TextAlign.Center, maxLines = 1,
|
|
||||||
modifier = Modifier.width(28.dp),
|
|
||||||
)
|
|
||||||
RetroButton("+") { channel.midiChannel = (channel.midiChannel + 1).coerceAtMost(16); vm.bumpForToolbar() }
|
|
||||||
}
|
|
||||||
|
|
||||||
SectionLabel("Volume")
|
|
||||||
// Vertical fader fills the remaining strip height.
|
|
||||||
VerticalFader(
|
|
||||||
value = channel.volume,
|
|
||||||
default = Mixer.DEFAULT_VOLUME,
|
|
||||||
onValueChange = { channel.volume = it; vm.bumpForToolbar() },
|
|
||||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
|
||||||
)
|
)
|
||||||
|
RetroButton("+") { channel.midiChannel = (channel.midiChannel + 1).coerceAtMost(16); vm.bumpForToolbar() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
/** Volume fader + limiter + mute/solo for a mixer [channel]. The fader fills the
|
||||||
RetroButton("M", active = channel.mute, modifier = Modifier.weight(1f)) {
|
* leftover height of its column. */
|
||||||
channel.mute = !channel.mute; vm.bumpForToolbar()
|
@Composable
|
||||||
}
|
private fun ColumnScope.ChannelLevels(vm: AppViewModel, channel: MixerChannel) {
|
||||||
RetroButton("S", active = channel.solo, modifier = Modifier.weight(1f)) {
|
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // defeat strong-skipping so LIM/mute/solo refresh
|
||||||
channel.solo = !channel.solo; vm.bumpForToolbar()
|
SectionLabel("Volume")
|
||||||
}
|
VerticalFader(
|
||||||
|
value = channel.volume,
|
||||||
|
default = Mixer.DEFAULT_VOLUME,
|
||||||
|
onValueChange = { channel.volume = it; vm.bumpForToolbar() },
|
||||||
|
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||||
|
)
|
||||||
|
// Per-bus soft-clip limiter, sitting above mute/solo.
|
||||||
|
RetroButton("LIM", active = channel.limiter, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
channel.limiter = !channel.limiter; vm.bumpForToolbar()
|
||||||
|
}
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
RetroButton("M", active = channel.mute, modifier = Modifier.weight(1f)) {
|
||||||
|
channel.mute = !channel.mute; vm.bumpForToolbar()
|
||||||
|
}
|
||||||
|
RetroButton("S", active = channel.solo, modifier = Modifier.weight(1f)) {
|
||||||
|
channel.solo = !channel.solo; vm.bumpForToolbar()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import androidx.compose.material.icons.filled.ContentPaste
|
|||||||
import androidx.compose.material.icons.filled.ExpandLess
|
import androidx.compose.material.icons.filled.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,37 +203,49 @@ 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 }) {
|
||||||
if (songs.isEmpty()) {
|
if (songs.isEmpty()) {
|
||||||
DropdownMenuItem(text = { Text("No saved projects") }, onClick = { menuOpen = false }, enabled = false)
|
DropdownMenuItem(text = { Text("No saved projects") }, onClick = { menuOpen = false }, enabled = false)
|
||||||
} else {
|
} else {
|
||||||
songs.forEach { s ->
|
songs.forEach { s ->
|
||||||
DropdownMenuItem(text = { Text(s) }, onClick = {
|
DropdownMenuItem(text = { Text(s) }, onClick = {
|
||||||
vm.loadSong(s); selectedName = s; menuOpen = false
|
vm.loadSong(s); selectedName = s; menuOpen = false
|
||||||
})
|
})
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Delete only offered for user themes — stock themes are protected.
|
||||||
|
if (!vm.isStockTheme(current)) {
|
||||||
|
Button(
|
||||||
|
onClick = { confirmDelete = current },
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.error,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onError,
|
||||||
|
),
|
||||||
|
) { Text("Delete") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
OutlinedButton(
|
|
||||||
onClick = { clipboard.setText(AnnotatedString(exportTheme(vm.palette))) },
|
HorizontalDivider()
|
||||||
modifier = Modifier.padding(top = 8.dp),
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
) { Text("Export theme") }
|
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
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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]).
|
||||||
|
|||||||
@@ -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,29 +88,43 @@ fun ToolboxScreen(vm: AppViewModel) {
|
|||||||
if (editing != null) editing = null else picking = null
|
if (editing != null) editing = null else picking = null
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize().background(c.background)) {
|
// Shared tile gestures (index-based) so both layouts drive the same state.
|
||||||
LazyVerticalGrid(
|
val onTapTile: (Int) -> Unit = { selected = it }
|
||||||
columns = GridCells.Fixed(4),
|
val onDoubleTapTile: (Int) -> Unit = { i -> if (vm.project.toolbox[i].isEmpty) picking = i else editing = i }
|
||||||
// Same top:bottom split as the tracker/arranger (1.7 : 1): tiles above,
|
val onLongPressTile: (Int) -> Unit = { i -> vm.project.toolbox[i].clearSlot(); vm.bumpForToolbar() }
|
||||||
// the piano audition keyboard below.
|
|
||||||
modifier = Modifier.weight(1.7f).fillMaxWidth().padding(6.dp),
|
if (isLandscape()) {
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
// Landscape: a fixed 4x4 grid squashed to fit the height (no scroll) BESIDE
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
// the audition keyboard, which gets the larger width so its CH/OCT/VEL
|
||||||
) {
|
// toolbar fits without horizontal scrolling.
|
||||||
items(vm.project.toolbox) { slot ->
|
Row(Modifier.fillMaxSize().background(c.background)) {
|
||||||
SlotTile(
|
ToolboxTileGrid(vm, selected, onTapTile, onDoubleTapTile, onLongPressTile,
|
||||||
// The changing revision defeats strong-skipping so tiles refresh.
|
Modifier.weight(1f).fillMaxHeight())
|
||||||
rev = vm.revision,
|
Box(Modifier.width(2.dp).fillMaxHeight().background(c.accent))
|
||||||
slot = slot,
|
com.reactorcoremeltdown.sizzletracker.ui.StackedKeyboard(vm, punchIn = false, Modifier.weight(1.3f))
|
||||||
selected = selected == slot.index,
|
}
|
||||||
onTap = { selected = slot.index },
|
} else {
|
||||||
onDoubleTap = { if (slot.isEmpty) picking = slot.index else editing = slot.index },
|
// Portrait: a scrolling grid of square tiles above the keyboard (1.7 : 1).
|
||||||
onLongPress = { slot.clearSlot(); vm.bumpForToolbar() },
|
Column(Modifier.fillMaxSize().background(c.background)) {
|
||||||
)
|
LazyVerticalGrid(
|
||||||
}
|
columns = GridCells.Fixed(4),
|
||||||
|
modifier = Modifier.weight(1.7f).fillMaxWidth().padding(6.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
items(vm.project.toolbox) { slot ->
|
||||||
|
SlotTile(
|
||||||
|
rev = vm.revision, slot = slot, selected = selected == slot.index,
|
||||||
|
onTap = { onTapTile(slot.index) },
|
||||||
|
onDoubleTap = { onDoubleTapTile(slot.index) },
|
||||||
|
onLongPress = { onLongPressTile(slot.index) },
|
||||||
|
modifier = Modifier.fillMaxWidth().aspectRatio(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box(Modifier.fillMaxWidth().height(2.dp).background(c.accent)) // fixed divider
|
||||||
|
com.reactorcoremeltdown.sizzletracker.ui.StackedKeyboard(vm, punchIn = false, Modifier.weight(1f))
|
||||||
}
|
}
|
||||||
Box(Modifier.fillMaxWidth().height(2.dp).background(c.accent)) // fixed divider
|
|
||||||
com.reactorcoremeltdown.sizzletracker.ui.StackedKeyboard(vm, punchIn = false, Modifier.weight(1f))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
picking?.let { index ->
|
picking?.let { index ->
|
||||||
@@ -123,6 +141,40 @@ fun ToolboxScreen(vm: AppViewModel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A fixed 4x4 grid of the 16 slot tiles that fills [modifier]'s bounds — used in
|
||||||
|
* landscape so every tile fits the short height without scrolling (tiles become
|
||||||
|
* rectangular rather than square). */
|
||||||
|
@Composable
|
||||||
|
private fun ToolboxTileGrid(
|
||||||
|
vm: AppViewModel,
|
||||||
|
selected: Int,
|
||||||
|
onTap: (Int) -> Unit,
|
||||||
|
onDoubleTap: (Int) -> Unit,
|
||||||
|
onLongPress: (Int) -> Unit,
|
||||||
|
modifier: Modifier,
|
||||||
|
) {
|
||||||
|
Column(modifier.padding(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
for (row in 0 until 4) {
|
||||||
|
Row(Modifier.fillMaxWidth().weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
for (col in 0 until 4) {
|
||||||
|
val idx = row * 4 + col
|
||||||
|
SlotTile(
|
||||||
|
rev = vm.revision,
|
||||||
|
slot = vm.project.toolbox[idx],
|
||||||
|
selected = selected == idx,
|
||||||
|
onTap = { onTap(idx) },
|
||||||
|
onDoubleTap = { onDoubleTap(idx) },
|
||||||
|
onLongPress = { onLongPress(idx) },
|
||||||
|
modifier = Modifier.weight(1f).fillMaxHeight(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One toolbox slot tile. [modifier] carries the sizing: a square (portrait grid)
|
||||||
|
* or a weighted cell that fills the height (the landscape fixed grid). */
|
||||||
@Composable
|
@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 },
|
||||||
|
|||||||
@@ -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,22 +170,42 @@ 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
|
||||||
Box(
|
val muted = arr.laneMuted[lane]
|
||||||
Modifier
|
Row(Modifier.weight(1f).fillMaxWidth()) {
|
||||||
.weight(1f)
|
// Mute toggle (left of the block header): mutes this lane.
|
||||||
.fillMaxWidth()
|
Box(
|
||||||
.background(if (active) c.accent.copy(alpha = 0.25f) else c.surface)
|
Modifier
|
||||||
.clickable { vm.setActiveBlock(lane) },
|
.width(muteColWidth)
|
||||||
contentAlignment = Alignment.Center,
|
.fillMaxHeight()
|
||||||
) {
|
.background(if (muted) c.danger.copy(alpha = 0.30f) else c.surface)
|
||||||
Text("${lane + 1}", color = if (active) c.accent else c.textDim,
|
.clickable { vm.toggleArrangementLaneMute(lane) },
|
||||||
fontFamily = FontFamily.Monospace, fontSize = 11.sp)
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text("M", color = if (muted) c.danger else c.textDim,
|
||||||
|
fontFamily = FontFamily.Monospace, fontSize = 9.sp)
|
||||||
|
}
|
||||||
|
// Block number: tap to edit that block in the tracker above.
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.background(if (active) c.accent.copy(alpha = 0.25f) else c.surface)
|
||||||
|
.clickable { vm.setActiveBlock(lane) },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text("${lane + 1}", color = if (active) c.accent else c.textDim,
|
||||||
|
fontFamily = FontFamily.Monospace, fontSize = 11.sp)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Scrollable ruler + roll (one Canvas) ----
|
// ---- 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)
|
||||||
|
|||||||
@@ -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,111 +71,153 @@ 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),
|
|
||||||
)
|
/** Landscape: transport bar on top, then the pattern grid BESIDE the arrangement/
|
||||||
// Lower section: the arrangement roll, or — in punch-in mode — the stacked
|
* keyboard so a wide, short screen isn't split into two cramped horizontal bands. */
|
||||||
// touch keyboard, so the bottom real estate serves note entry too.
|
@Composable
|
||||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
private fun TrackerLandscape(vm: AppViewModel) {
|
||||||
if (vm.punchInMode) {
|
val c = LocalRetro.current
|
||||||
com.reactorcoremeltdown.sizzletracker.ui.StackedKeyboard(vm, punchIn = true, Modifier.fillMaxSize())
|
Column(Modifier.fillMaxSize()) {
|
||||||
} else {
|
TransportToolbar(vm)
|
||||||
ArrangementRoll(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))
|
||||||
RetroButton("⧉ STOP", onClick = vm::stop)
|
EditButtons(vm)
|
||||||
// Fixed width so the button doesn't resize as the label toggles.
|
|
||||||
RetroButton(if (transport.isPlaying) "❚❚ PAUSE" else "▶ PLAY",
|
|
||||||
active = transport.isPlaying, width = 112.dp, onClick = vm::playPause)
|
|
||||||
// Tempo nudger: tap -/+ around a fixed-width readout.
|
|
||||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
|
||||||
RetroButton("-", onClick = { vm.setTempo(project.tempoBpm - 1) })
|
|
||||||
Text(
|
|
||||||
"${project.tempoBpm.toInt()} BPM",
|
|
||||||
color = c.text, fontFamily = FontFamily.Monospace, fontSize = 12.sp,
|
|
||||||
textAlign = TextAlign.Center, maxLines = 1,
|
|
||||||
modifier = Modifier.width(72.dp).padding(vertical = 6.dp),
|
|
||||||
)
|
|
||||||
RetroButton("+", onClick = { vm.setTempo(project.tempoBpm + 1) })
|
|
||||||
}
|
}
|
||||||
RetroDropdown(
|
} else {
|
||||||
label = "SIG",
|
scrollRow(topPad = true) { PatternButtons(vm) }
|
||||||
options = TimeSignature.entries,
|
scrollRow(topPad = true) { EditButtons(vm) }
|
||||||
selected = project.timeSignature,
|
|
||||||
optionLabel = { it.label },
|
|
||||||
onSelect = vm::setTimeSignature,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// Row 2: length + scale + root, then the punch-in/nav skip step at the end.
|
|
||||||
// (The active block is chosen from the arrangement, so no BLK selector here.)
|
|
||||||
Row(
|
|
||||||
Modifier.fillMaxWidth().padding(top = 6.dp).horizontalScroll(rememberScrollState()),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
) {
|
|
||||||
RetroDropdown(
|
|
||||||
label = "LEN",
|
|
||||||
options = project.timeSignature.lengthOptions,
|
|
||||||
selected = project.activePattern().length,
|
|
||||||
onSelect = vm::setPatternLength,
|
|
||||||
)
|
|
||||||
RetroDropdown(
|
|
||||||
label = "SCALE",
|
|
||||||
options = com.reactorcoremeltdown.sizzletracker.model.Scale.entries,
|
|
||||||
selected = project.scale,
|
|
||||||
optionLabel = { it.label },
|
|
||||||
onSelect = { project.scale = it; vm.bumpForToolbar() },
|
|
||||||
)
|
|
||||||
RetroDropdown(
|
|
||||||
label = "ROOT",
|
|
||||||
options = (0..11).toList(),
|
|
||||||
selected = project.rootNote,
|
|
||||||
optionLabel = { com.reactorcoremeltdown.sizzletracker.model.Pitch.name(60 + it).dropLast(1) },
|
|
||||||
onSelect = { project.rootNote = it; vm.bumpForToolbar() },
|
|
||||||
)
|
|
||||||
// Rows the cursor jumps after a punch-in and per external up/down step.
|
|
||||||
RetroDropdown(
|
|
||||||
label = "SKIP",
|
|
||||||
options = listOf(1, 2, 4, 8),
|
|
||||||
selected = vm.skipStep,
|
|
||||||
onSelect = vm::updateSkipStep,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// Row 3: selection + clipboard edit operations. SEL toggles rectangular
|
|
||||||
// select mode (the cursor then drags out a region); the rest act on that
|
|
||||||
// region, or on the single focused cell when nothing is selected.
|
|
||||||
Row(
|
|
||||||
Modifier.fillMaxWidth().padding(top = 6.dp).horizontalScroll(rememberScrollState()),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
) {
|
|
||||||
RetroButton("SEL", active = vm.selectMode, onClick = vm::toggleSelectMode)
|
|
||||||
RetroButton("CUT", onClick = vm::cutSelection)
|
|
||||||
RetroButton("COPY", onClick = vm::copySelection)
|
|
||||||
RetroButton("PASTE", active = vm.canPaste, onClick = vm::pasteClipboard)
|
|
||||||
RetroButton("DEL", onClick = vm::deleteSelection)
|
|
||||||
// Insert a note-off ("===") at the cursor.
|
|
||||||
RetroButton("OFF ===", onClick = vm::noteOffFocused)
|
|
||||||
// Swap the bottom half between the arranger and the punch-in keyboard.
|
|
||||||
RetroButton("⌨ KBD", active = vm.punchInMode, onClick = vm::togglePunchIn)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Transport + tempo + time signature. */
|
||||||
|
@Composable
|
||||||
|
private fun TransportButtons(vm: AppViewModel) {
|
||||||
|
val c = LocalRetro.current
|
||||||
|
val transport by vm.transport.collectAsState()
|
||||||
|
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // refresh the tempo readout
|
||||||
|
val project = vm.project
|
||||||
|
RetroButton("⧉ STOP", onClick = vm::stop)
|
||||||
|
// Fixed width so the button doesn't resize as the label toggles.
|
||||||
|
RetroButton(if (transport.isPlaying) "❚❚ PAUSE" else "▶ PLAY",
|
||||||
|
active = transport.isPlaying, width = 112.dp, onClick = vm::playPause)
|
||||||
|
// Tempo nudger: tap -/+ around a fixed-width readout.
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
RetroButton("-", onClick = { vm.setTempo(project.tempoBpm - 1) })
|
||||||
|
Text(
|
||||||
|
"${project.tempoBpm.toInt()} BPM",
|
||||||
|
color = c.text, fontFamily = FontFamily.Monospace, fontSize = 12.sp,
|
||||||
|
textAlign = TextAlign.Center, maxLines = 1,
|
||||||
|
modifier = Modifier.width(72.dp).padding(vertical = 6.dp),
|
||||||
|
)
|
||||||
|
RetroButton("+", onClick = { vm.setTempo(project.tempoBpm + 1) })
|
||||||
|
}
|
||||||
|
RetroDropdown(
|
||||||
|
label = "SIG",
|
||||||
|
options = TimeSignature.entries,
|
||||||
|
selected = project.timeSignature,
|
||||||
|
optionLabel = { it.label },
|
||||||
|
onSelect = vm::setTimeSignature,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Active block + length + scale + root. */
|
||||||
|
@Composable
|
||||||
|
private fun PatternButtons(vm: AppViewModel) {
|
||||||
|
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // refresh blk/len/scale/root readouts
|
||||||
|
val project = vm.project
|
||||||
|
// Which block (0..7) the tracker edits — also settable from the arrangement lanes.
|
||||||
|
RetroDropdown(
|
||||||
|
label = "BLK",
|
||||||
|
options = project.patterns.indices.toList(),
|
||||||
|
selected = vm.activeBlock,
|
||||||
|
optionLabel = { "${it + 1}" },
|
||||||
|
onSelect = vm::setActiveBlock,
|
||||||
|
)
|
||||||
|
RetroDropdown(
|
||||||
|
label = "LEN",
|
||||||
|
options = project.timeSignature.lengthOptions,
|
||||||
|
selected = project.activePattern().length,
|
||||||
|
onSelect = vm::setPatternLength,
|
||||||
|
)
|
||||||
|
RetroDropdown(
|
||||||
|
label = "SCALE",
|
||||||
|
options = com.reactorcoremeltdown.sizzletracker.model.Scale.entries,
|
||||||
|
selected = project.scale,
|
||||||
|
optionLabel = { it.label },
|
||||||
|
onSelect = { project.scale = it; vm.bumpForToolbar() },
|
||||||
|
)
|
||||||
|
RetroDropdown(
|
||||||
|
label = "ROOT",
|
||||||
|
options = (0..11).toList(),
|
||||||
|
selected = project.rootNote,
|
||||||
|
optionLabel = { com.reactorcoremeltdown.sizzletracker.model.Pitch.name(60 + it).dropLast(1) },
|
||||||
|
onSelect = { project.rootNote = it; vm.bumpForToolbar() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Selection + clipboard edit operations, note-off insert, and the KBD toggle.
|
||||||
|
* Cut/copy/paste/delete use compact glyphs (✂ ⧉ ▤ ✕). */
|
||||||
|
@Composable
|
||||||
|
private fun EditButtons(vm: AppViewModel) {
|
||||||
|
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // defeat strong-skipping so PASTE (canPaste) refreshes
|
||||||
|
RetroButton("SEL", active = vm.selectMode, onClick = vm::toggleSelectMode)
|
||||||
|
RetroButton("✂", onClick = vm::cutSelection) // cut
|
||||||
|
RetroButton("⧉", onClick = vm::copySelection) // copy
|
||||||
|
RetroButton("▤", active = vm.canPaste, onClick = vm::pasteClipboard) // paste
|
||||||
|
RetroButton("✕", onClick = vm::deleteAndAdvance) // delete
|
||||||
|
// Insert a note-off ("===") at the cursor.
|
||||||
|
RetroButton("OFF ===", onClick = vm::noteOffFocused)
|
||||||
|
// Rows the cursor jumps after a punch-in and per external up/down step.
|
||||||
|
RetroDropdown(
|
||||||
|
label = "SKIP",
|
||||||
|
options = listOf(0, 1, 2, 4, 8),
|
||||||
|
selected = vm.skipStep,
|
||||||
|
onSelect = vm::updateSkipStep,
|
||||||
|
)
|
||||||
|
// Swap the bottom half between the arranger and the punch-in keyboard.
|
||||||
|
RetroButton("⌨ KBD", active = vm.punchInMode, onClick = vm::togglePunchIn)
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,4 +4,5 @@
|
|||||||
<paths>
|
<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>
|
||||||
|
|||||||
Reference in New Issue
Block a user