diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f245f37..c96586c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -22,8 +22,8 @@ android { // (android.media.midi) and AAudio low-latency audio we rely on. minSdk = 26 targetSdk = 34 - versionCode = 1 - versionName = "0.1.0" + versionCode = 3 + versionName = "0.3.0" // We provide our own instrumentation runner if/when tests are added. testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/main/java/space/rcmd/android/sizzle/audio/AudioEngine.kt b/app/src/main/java/space/rcmd/android/sizzle/audio/AudioEngine.kt index 1e8db1c..1b83fc5 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/audio/AudioEngine.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/audio/AudioEngine.kt @@ -739,7 +739,7 @@ class AudioEngine( when { vs != null -> allocSampleVoice(bus).noteOn(vs.sample, note, velocity, vs.root, vs.sliceStart, vs.sliceEnd, vs.volume, - vs.attack, vs.decay, vs.sustain, vs.release) + vs.attack, vs.decay, vs.sustain, vs.release, vs.loop, vs.crossfadeMs) // Empty bus (no instrument — e.g. the default channel 0) or a Sampler // with an empty pad → silent, matching the live audition path. slot?.type == null || slot.type == ToolboxType.SAMPLER -> {} @@ -757,6 +757,7 @@ class AudioEngine( val sliceStart: Float, val sliceEnd: Float, val volume: Float, val attack: Float = 0.002f, val decay: Float = 0f, val sustain: Float = 1f, val release: Float = 0.02f, + val loop: Boolean = false, val crossfadeMs: Float = 0f, ) /** @@ -791,6 +792,8 @@ class AudioEngine( decay = slot.float("decay", 0.10f), sustain = slot.float("sustain", 1f), release = slot.float("release", 0.20f), + loop = slot.string("loop", "Off") == "On", + crossfadeMs = slot.float("loopXfade", 0f), ) } else -> null @@ -924,7 +927,7 @@ class AudioEngine( if (vs != null) { val v = auditionSampleVoices.firstOrNull { !it.isActive } ?: auditionSampleVoices[0].also { it.kill() } v.noteOn(vs.sample, midi, 110, vs.root, vs.sliceStart, vs.sliceEnd, vs.volume, - vs.attack, vs.decay, vs.sustain, vs.release) + vs.attack, vs.decay, vs.sustain, vs.release, vs.loop, vs.crossfadeMs) return } // A Sampler with an empty pad stays silent (audition is a one-shot with no @@ -963,7 +966,7 @@ class AudioEngine( val v = (0 until BUS_POLYPHONY).map { busSampleVoices[base + it] } .firstOrNull { !it.isActive } ?: busSampleVoices[base].also { it.kill() } v.noteOn(vs.sample, midi, velocity, vs.root, vs.sliceStart, vs.sliceEnd, vs.volume, - vs.attack, vs.decay, vs.sustain, vs.release) + vs.attack, vs.decay, vs.sustain, vs.release, vs.loop, vs.crossfadeMs) } // Sampler with an empty pad → silent (no synth fallback). type == ToolboxType.SAMPLER -> {} diff --git a/app/src/main/java/space/rcmd/android/sizzle/audio/SampleStore.kt b/app/src/main/java/space/rcmd/android/sizzle/audio/SampleStore.kt index 4e24b64..1a6f814 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/audio/SampleStore.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/audio/SampleStore.kt @@ -18,14 +18,25 @@ import java.util.concurrent.ConcurrentHashMap * The id stored in a toolbox slot's `samplePath` parameter is the key here. */ object SampleStore { - /** Decoded mono PCM plus the rate it was recorded at. */ - class Sample(val data: FloatArray, val sampleRate: Int) + /** Decoded mono PCM plus the rate it was recorded at. [loopStart]/[loopEnd] are + * sample-loop points in frames (from an SF2/XI file), or -1 when the source has + * none — the SoundFont voice uses them for seamless sustain looping. */ + class Sample( + val data: FloatArray, + val sampleRate: Int, + val loopStart: Int = -1, + val loopEnd: Int = -1, + ) private val samples = ConcurrentHashMap() fun put(id: String, sample: Sample) { samples[id] = sample } fun get(id: String): Sample? = if (id.isEmpty()) null else samples[id] + /** Drop a cached sample so its PCM can be GC'd. Any voice already playing it + * keeps its own reference, so removing a live sample never crashes playback. */ + fun remove(id: String) { if (id.isNotEmpty()) samples.remove(id) } + /** * Decode a WAV file (the common uncompressed formats) into a mono [Sample]. * Supports 8/16/24-bit PCM and 32-bit float, mono or stereo (stereo is diff --git a/app/src/main/java/space/rcmd/android/sizzle/audio/SampleVoice.kt b/app/src/main/java/space/rcmd/android/sizzle/audio/SampleVoice.kt index 53872a3..f91cefe 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/audio/SampleVoice.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/audio/SampleVoice.kt @@ -29,6 +29,20 @@ class SampleVoice(private val engineSampleRate: Int) { private var amp = 0f var pitch = -1; private set + // ---- sustain loop (SoundFont): wrap [loopStartIdx, loopEndIdx) while sounding, + // until a note-off's release envelope ends the voice. Uses the file's loop points + // when valid, else the whole played region. + private var loopActive = false + private var loopStartIdx = 0 + private var loopEndIdx = 0 + private var loopSpan = 0.0 + // Loop crossfade: [xfade] output samples of the loop tail are blended into its + // head so the seam is click-free. The repeating span is then [loopEff] long and + // wraps back to [loopBase] (= loopStartIdx + xfade). xfade == 0 → a plain wrap. + private var xfade = 0.0 + private var loopBase = 0.0 + private var loopEff = 0.0 + // ---- ADSR envelope (linear ramps; levels 0..1) ---- private enum class Phase { ATTACK, DECAY, SUSTAIN, RELEASE } private var phase = Phase.ATTACK @@ -62,6 +76,7 @@ class SampleVoice(private val engineSampleRate: Int) { s: SampleStore.Sample, midi: Int, velocity: Int, root: Int, sliceStart: Float, sliceEnd: Float, volume: Float, attack: Float = 0.002f, decay: Float = 0f, sustain: Float = 1f, release: Float = 0.02f, + loop: Boolean = false, crossfadeMs: Float = 0f, ) { // Carry the level we're interrupting so the new note starts continuously // (the new note fades in from ~0; without this the sum would jump from the @@ -75,6 +90,31 @@ class SampleVoice(private val engineSampleRate: Int) { startIndex = (minOf(a, b) * len).toInt().coerceIn(0, (len - 1).coerceAtLeast(0)) endIndex = (maxOf(a, b) * len).toInt().coerceIn(startIndex + 1, len) position = startIndex.toDouble() + + // Loop region: prefer the file's loop points (clamped into the played slice); + // otherwise loop the whole slice. Disabled if the resulting span is too short. + if (loop) { + val fileLs = s.loopStart + val fileLe = s.loopEnd + if (fileLe > fileLs && fileLs >= 0 && fileLe <= len) { + loopStartIdx = fileLs.coerceIn(startIndex, endIndex - 1) + loopEndIdx = fileLe.coerceIn(loopStartIdx + 1, endIndex) + } else { + loopStartIdx = startIndex + loopEndIdx = endIndex + } + loopSpan = (loopEndIdx - loopStartIdx).toDouble() + loopActive = loopSpan >= 2.0 + // Crossfade length in samples, kept under half the loop so the repeating + // span stays positive; needs a loop of at least a few samples to be useful. + val xf = if (loopActive) (crossfadeMs / 1000f * engineSampleRate).toDouble() else 0.0 + xfade = if (loopActive && loopSpan >= 4.0) xf.coerceIn(0.0, loopSpan / 2.0 - 1.0) else 0.0 + loopBase = loopStartIdx + xfade + loopEff = loopSpan - xfade + } else { + loopActive = false + xfade = 0.0 + } // Pitch ratio * sample-rate conversion. val semis = (midi - root) / 12.0 rate = Math.pow(2.0, semis) * (s.sampleRate.toDouble() / engineSampleRate) @@ -99,7 +139,7 @@ class SampleVoice(private val engineSampleRate: Int) { phase = Phase.RELEASE } - fun kill() { sample = null; pitch = -1; env = 0f; endGain = 1f; lastY = 0f; declick = 0f } + fun kill() { sample = null; pitch = -1; env = 0f; endGain = 1f; lastY = 0f; declick = 0f; loopActive = false } /** One sample of the decaying declick offset (used on the paths that stop the * voice, so any carried level rings out smoothly instead of snapping to 0). */ @@ -115,14 +155,24 @@ class SampleVoice(private val engineSampleRate: Int) { fun render(): Float { val s = sample ?: return declickTail() // idle → 0; ring out any declick tail val data = s.data - val i = position.toInt() - if (i >= endIndex - 1 || i >= data.size - 1) { sample = null; pitch = -1; return declickTail() } - // Anti-click fade as the slice end approaches (in OUTPUT samples, so it holds - // regardless of playback rate). - if ((endIndex - i) / rate <= FADE_SAMPLES) { - endGain = (endGain - FADE_STEP).coerceAtLeast(0f) - if (endGain <= 0f) { sample = null; pitch = -1; return declickTail() } + if (loopActive) { + // Wrap the (crossfaded) loop; the voice keeps looping — even through the + // release stage — until the envelope reaches 0 below. Modulo (not a single + // subtract) so a very high playback rate that steps past the region in one + // sample still lands back inside it. + if (position >= loopEndIdx) position = loopBase + (position - loopBase) % loopEff + // Belt-and-braces bounds guard so a malformed loop can never read OOB. + if (position >= data.size - 1 || position < loopStartIdx) position = loopBase + } else { + val i = position.toInt() + if (i >= endIndex - 1 || i >= data.size - 1) { sample = null; pitch = -1; return declickTail() } + // Anti-click fade as the slice end approaches (in OUTPUT samples, so it + // holds regardless of playback rate). + if ((endIndex - i) / rate <= FADE_SAMPLES) { + endGain = (endGain - FADE_STEP).coerceAtLeast(0f) + if (endGain <= 0f) { sample = null; pitch = -1; return declickTail() } + } } // Advance the ADSR envelope one output sample. @@ -133,9 +183,25 @@ class SampleVoice(private val engineSampleRate: Int) { Phase.RELEASE -> { env -= releaseInc; if (env <= 0f) { sample = null; pitch = -1; return declickTail() } } } - // Linear interpolation between neighbouring samples. + // Linear interpolation between neighbouring samples (neighbour clamped so the + // final frame of a buffer-length loop can't read past the end). + val i = position.toInt() + val i1 = if (i + 1 < data.size) i + 1 else i val frac = (position - i).toFloat() - val out = data[i] + (data[i + 1] - data[i]) * frac + var out = data[i] + (data[i1] - data[i]) * frac + + // In the crossfade zone, blend the loop tail into its head so the wrap seam is + // click-free. Reads only within [loopStartIdx, loopEndIdx) → never OOB. + if (loopActive && xfade >= 1.0 && position >= loopEndIdx - xfade) { + val into = position - (loopEndIdx - xfade) // 0 … xfade + val f = (into / xfade).toFloat() // 0 → 1 across the zone + val sp = loopStartIdx + into // matching head position + val si = sp.toInt() + val si1 = if (si + 1 < data.size) si + 1 else si + val sfrac = (sp - si).toFloat() + val head = data[si] + (data[si1] - data[si]) * sfrac + out = out * (1f - f) + head * f + } position += rate val y = out * amp * env * endGain + declick diff --git a/app/src/main/java/space/rcmd/android/sizzle/audio/SoundFontLoader.kt b/app/src/main/java/space/rcmd/android/sizzle/audio/SoundFontLoader.kt index da5ede0..4151404 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/audio/SoundFontLoader.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/audio/SoundFontLoader.kt @@ -67,12 +67,16 @@ object SoundFontLoader { val h = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).apply { position(shdrOff + 20) } val start = h.int.toLong() and 0xFFFFFFFFL val end = h.int.toLong() and 0xFFFFFFFFL - h.int; h.int // start/end loop (unused) + val startLoop = h.int.toLong() and 0xFFFFFFFFL + val endLoop = h.int.toLong() and 0xFFFFFFFFL val sampleRate = h.int val originalKey = h.get().toInt() and 0xFF if (end <= start) return null - val frames = (end - start).toInt().coerceAtMost(MAX_FRAMES) + // Coerce the span as a Long *before* narrowing to Int: a corrupt/huge header + // could otherwise overflow to a negative Int and crash FloatArray(). + val frames = (end - start).coerceIn(0L, MAX_FRAMES.toLong()).toInt() + if (frames <= 0) return null val out = FloatArray(frames) val db = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN) .apply { position((smplOff + start.toInt() * 2).coerceIn(0, bytes.size)) } @@ -81,7 +85,13 @@ object SoundFontLoader { val root = if (originalKey in 1..127) originalKey else 60 val rate = if (sampleRate in 8000..192000) sampleRate else 44100 - return Loaded(SampleStore.Sample(out, rate), root) + // Loop points are absolute (same base as start/end); make them relative to the + // extracted region and keep only if they fall inside it. + val lsRel = startLoop - start + val leRel = endLoop - start + val loopS = if (leRel > lsRel && lsRel >= 0 && leRel <= frames) lsRel.toInt() else -1 + val loopE = if (loopS >= 0) leRel.toInt() else -1 + return Loaded(SampleStore.Sample(out, rate, loopS, loopE), root) } // -------------------------------------------------------------------- XI @@ -105,10 +115,11 @@ object SoundFontLoader { if (length <= 0 || dataStart >= bytes.size) return null val byteLen = minOf(length, bytes.size - dataStart) - val out: FloatArray + val div = if (is16) 2 else 1 + val n = (byteLen / div).coerceAtMost(MAX_FRAMES) + if (n <= 0) return null + val out = FloatArray(n) if (is16) { - val n = (byteLen / 2).coerceAtMost(MAX_FRAMES) - out = FloatArray(n) val db = ByteBuffer.wrap(bytes, dataStart, byteLen).order(ByteOrder.LITTLE_ENDIAN) var old = 0 for (k in 0 until n) { @@ -116,19 +127,29 @@ object SoundFontLoader { out[k] = old.toShort() / 32768f } } else { - val n = byteLen.coerceAtMost(MAX_FRAMES) - out = FloatArray(n) var old = 0 for (k in 0 until n) { old = (old + bytes[dataStart + k]) and 0xFF out[k] = old.toByte() / 128f } } + // Loop info: type bits 0-1 (0=none, 1=forward, 2=ping-pong); loopStart/loopLen + // are byte offsets (halve for 16-bit). Ping-pong is treated as forward. + val loopType = type and 0x03 + val loopStartB = u32(bytes, hp + 4).toInt() + val loopLenB = u32(bytes, hp + 8).toInt() + var loopS = -1 + var loopE = -1 + if (loopType != 0 && loopLenB > 0 && loopStartB >= 0) { + val ls = loopStartB / div + val le = (loopStartB + loopLenB) / div + if (ls in 0 until n && le in (ls + 1)..n) { loopS = ls; loopE = le } + } // XI/XM samples have no stored rate; C-4 plays at 8363 Hz by convention. // relativeNote offsets the pitch: root = 60 - relativeNote so playing that // note yields the sample's natural pitch and it transposes from there. val root = (60 - relativeNote).coerceIn(0, 127) - return Loaded(SampleStore.Sample(out, 8363), root) + return Loaded(SampleStore.Sample(out, 8363, loopS, loopE), root) } // ----------------------------------------------------------------- helpers diff --git a/app/src/main/java/space/rcmd/android/sizzle/model/Toolbox.kt b/app/src/main/java/space/rcmd/android/sizzle/model/Toolbox.kt index 9332675..8f1db75 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/model/Toolbox.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/model/Toolbox.kt @@ -72,6 +72,11 @@ enum class ToolboxType( ParamSpec("decay", "Decay", 0.10f, 0f, 2f), ParamSpec("sustain", "Sustain", 1f, 0f, 1f), ParamSpec("release", "Release", 0.20f, 0f, 3f), + // Sustain-loop the sample while a note is held (uses the file's loop + // points when present, else loops the whole sample). Ends on note-off. + ParamSpec("loop", "Loop", choices = listOf("Off", "On")), + // Loop crossfade time in milliseconds — smooths the loop seam. + ParamSpec("loopXfade", "Loop XFade", 0f, 0f, 500f), ), ), diff --git a/app/src/main/java/space/rcmd/android/sizzle/ui/AppViewModel.kt b/app/src/main/java/space/rcmd/android/sizzle/ui/AppViewModel.kt index 394ed32..0f053e2 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/ui/AppViewModel.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/ui/AppViewModel.kt @@ -572,13 +572,27 @@ class AppViewModel( bytes: ByteArray, ): Boolean { val loaded = space.rcmd.android.sizzle.audio.SoundFontLoader.load(bytes) ?: return false + val previous = slot.string("sfSample") space.rcmd.android.sizzle.audio.SampleStore.put(id, loaded.sample) slot.set("sfSample", id) slot.set("sfRoot", loaded.rootNote.toFloat()) + // Free the sample this slot previously held (unless another slot still uses + // the same file) so repeatedly re-loading instruments doesn't leak PCM. + if (previous.isNotBlank() && previous != id && !isSampleReferenced(previous, except = slot)) { + space.rcmd.android.sizzle.audio.SampleStore.remove(previous) + } touched() return true } + /** True if any toolbox slot other than [except] references the sample id [id] + * (as a SoundFont's `sfSample` or a Sampler pad). Used before evicting a sample + * from the [space.rcmd.android.sizzle.audio.SampleStore] so shared samples stay. */ + private fun isSampleReferenced( + id: String, + except: space.rcmd.android.sizzle.model.ToolboxSlot, + ): Boolean = project.toolbox.any { s -> s !== except && s.values.values.any { it == id } } + /** * Destructively trim a Sampler [slot]'s [pad] to its current slice region: the * pad's sample is replaced by just the portion between the two slice markers, diff --git a/app/src/main/java/space/rcmd/android/sizzle/ui/StackedKeyboard.kt b/app/src/main/java/space/rcmd/android/sizzle/ui/StackedKeyboard.kt index 78b4f1a..a9aaad5 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/ui/StackedKeyboard.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/ui/StackedKeyboard.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.unit.sp import space.rcmd.android.sizzle.model.Pitch import space.rcmd.android.sizzle.ui.components.PianoKey import space.rcmd.android.sizzle.ui.components.PianoOctave +import space.rcmd.android.sizzle.ui.components.RepeatButton import space.rcmd.android.sizzle.ui.components.RetroButton import space.rcmd.android.sizzle.ui.theme.LocalRetro @@ -80,12 +81,12 @@ fun StackedKeyboard(vm: AppViewModel, punchIn: Boolean, modifier: Modifier) { private fun Selector(label: String, value: String, onDec: () -> Unit, onInc: () -> Unit) { val c = LocalRetro.current Text(label, color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 11.sp) - RetroButton("-", onClick = onDec) + RepeatButton("-", onStep = onDec) Text( value, color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 13.sp, textAlign = TextAlign.Center, modifier = Modifier.width(40.dp), ) - RetroButton("+", onClick = onInc) + RepeatButton("+", onStep = onInc) } /** One octave; hold a key to audition (and, in punch-in mode, record) the note. diff --git a/app/src/main/java/space/rcmd/android/sizzle/ui/components/Widgets.kt b/app/src/main/java/space/rcmd/android/sizzle/ui/components/Widgets.kt index 282c98b..f20479e 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/ui/components/Widgets.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/ui/components/Widgets.kt @@ -6,6 +6,9 @@ package space.rcmd.android.sizzle.ui.components import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width @@ -17,15 +20,18 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.font.FontFamily 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 kotlinx.coroutines.withTimeoutOrNull import space.rcmd.android.sizzle.ui.theme.LocalRetro /** @@ -49,6 +55,59 @@ fun RetroButton( width: Dp? = null, danger: Boolean = false, onClick: () -> Unit, +) { + RetroButtonSurface(label, modifier, active, width, danger, Modifier.clickable(onClick = onClick)) +} + +/** + * A stepper button (e.g. "+" / "-") that fires once on press and then **auto-repeats + * while held**, with the repeat interval shrinking exponentially so a long hold spins + * the value ever faster. Lifting the finger — or dragging off, e.g. to scroll a + * toolbar — stops the repeat. + */ +@Composable +fun RepeatButton( + label: String, + modifier: Modifier = Modifier, + active: Boolean = false, + width: Dp? = null, + danger: Boolean = false, + onStep: () -> Unit, +) { + val step by rememberUpdatedState(onStep) + RetroButtonSurface( + label, modifier, active, width, danger, + Modifier.pointerInput(Unit) { + awaitEachGesture { + awaitFirstDown(requireUnconsumed = false) + step() // immediate first step on press + var delayMs = REPEAT_START_MS + while (true) { + // Wait for finger-up/cancel, but no longer than the current delay. + var ended = false + withTimeoutOrNull(delayMs) { + waitForUpOrCancellation() // completes on up OR cancel (scroll) + ended = true + } + if (ended) break // released or cancelled → stop + step() // still held → repeat … + delayMs = (delayMs * REPEAT_DECAY).toLong().coerceAtLeast(REPEAT_MIN_MS) + } + } + }, + ) +} + +/** Shared visual for [RetroButton]/[RepeatButton]; [interaction] carries the tap or + * hold-repeat gesture so the look stays identical across both. */ +@Composable +private fun RetroButtonSurface( + label: String, + modifier: Modifier, + active: Boolean, + width: Dp?, + danger: Boolean, + interaction: Modifier, ) { val c = LocalRetro.current // danger (red) wins over active (accent); otherwise the neutral surface look. @@ -58,7 +117,7 @@ fun RetroButton( .then(if (width != null) Modifier.width(width) else Modifier) .border(1.dp, hue ?: c.grid, RectangleShape) .background(hue?.copy(alpha = 0.18f) ?: c.surface) - .clickable(onClick = onClick) + .then(interaction) .padding(horizontal = 10.dp, vertical = 6.dp), contentAlignment = Alignment.Center, ) { @@ -74,6 +133,12 @@ fun RetroButton( } } +// Hold-to-repeat timing: first repeat after REPEAT_START_MS, then each interval +// multiplied by REPEAT_DECAY (exponential speed-up) down to REPEAT_MIN_MS. +private const val REPEAT_START_MS = 320L +private const val REPEAT_MIN_MS = 24L +private const val REPEAT_DECAY = 0.80 + /** A labelled dropdown selector rendered as `LABEL:value ▾`. */ @Composable fun RetroDropdown( diff --git a/app/src/main/java/space/rcmd/android/sizzle/ui/mixer/MixerScreen.kt b/app/src/main/java/space/rcmd/android/sizzle/ui/mixer/MixerScreen.kt index 3b25f85..e5cfd52 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/ui/mixer/MixerScreen.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/ui/mixer/MixerScreen.kt @@ -39,6 +39,7 @@ import space.rcmd.android.sizzle.model.MixerChannel import space.rcmd.android.sizzle.model.ToolboxKind import space.rcmd.android.sizzle.ui.AppViewModel import space.rcmd.android.sizzle.ui.isLandscape +import space.rcmd.android.sizzle.ui.components.RepeatButton import space.rcmd.android.sizzle.ui.components.RetroButton import space.rcmd.android.sizzle.ui.components.RetroDropdown import space.rcmd.android.sizzle.ui.components.SectionLabel @@ -190,14 +191,14 @@ private fun ColumnScope.ChannelRouting(vm: AppViewModel, channel: MixerChannel) SectionLabel("MIDI Ch") Row(verticalAlignment = Alignment.CenterVertically) { - RetroButton("-") { channel.midiChannel = (channel.midiChannel - 1).coerceAtLeast(0); vm.bumpForToolbar() } + RepeatButton("-") { 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() } + RepeatButton("+") { channel.midiChannel = (channel.midiChannel + 1).coerceAtMost(16); vm.bumpForToolbar() } } } diff --git a/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/Import.kt b/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/Import.kt new file mode 100644 index 0000000..ed3cc87 --- /dev/null +++ b/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/Import.kt @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown +// SPDX-License-Identifier: GPL-3.0-or-later + +package space.rcmd.android.sizzle.ui.toolbox + +import java.io.ByteArrayOutputStream +import java.io.InputStream + +/** Upper bound on an imported instrument/sample file we read fully into memory. + * SF2 SoundFonts can be hundreds of MB; reading one wholesale would OOM the app, + * so anything larger is rejected with a friendly error instead of crashing. */ +const val MAX_IMPORT_BYTES: Int = 64 * 1024 * 1024 + +/** + * Read the whole stream, but give up (returning null) once more than [cap] bytes + * have been read — so a pathologically large file can't exhaust memory. Returns + * the bytes on success, or null if the stream is longer than [cap]. + */ +fun readCapped(input: InputStream, cap: Int = MAX_IMPORT_BYTES): ByteArray? { + val out = ByteArrayOutputStream(minOf(cap, 1 shl 20)) + val chunk = ByteArray(64 * 1024) + var total = 0L + while (true) { + val n = input.read(chunk) + if (n < 0) break + total += n + if (total > cap) return null + out.write(chunk, 0, n) + } + return out.toByteArray() +} diff --git a/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/SamplerEditor.kt b/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/SamplerEditor.kt index f9d9b38..d810d9a 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/SamplerEditor.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/SamplerEditor.kt @@ -75,10 +75,11 @@ fun SamplerEditor(vm: AppViewModel, slot: ToolboxSlot) { val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> if (uri == null) return@rememberLauncherForActivityResult val bytes = runCatching { - context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + context.contentResolver.openInputStream(uri)?.use { readCapped(it) } }.getOrNull() when { - bytes == null -> importError = "Couldn't read the selected file." + bytes == null -> importError = + "Couldn't read the file, or it's larger than ${MAX_IMPORT_BYTES / (1024 * 1024)} MB." !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." diff --git a/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/SoundFontEditor.kt b/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/SoundFontEditor.kt index 0f7baa4..805fffb 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/SoundFontEditor.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/SoundFontEditor.kt @@ -44,9 +44,13 @@ import space.rcmd.android.sizzle.model.ToolboxSlot import space.rcmd.android.sizzle.ui.AppViewModel import space.rcmd.android.sizzle.ui.components.PianoKey import space.rcmd.android.sizzle.ui.components.PianoOctave +import space.rcmd.android.sizzle.ui.components.RepeatButton import space.rcmd.android.sizzle.ui.components.RetroButton import space.rcmd.android.sizzle.ui.theme.LocalRetro +/** Upper bound (ms) for the SoundFont loop crossfade stepper. */ +private const val MAX_XFADE_MS = 500 + /** * The SoundFont device editor: load an `.sf2` / `.xi` (or `.wav`) file from local * storage, preview its waveform, set output volume, and audition it pitched across @@ -67,21 +71,45 @@ fun SoundFontEditor(vm: AppViewModel, slot: ToolboxSlot) { val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> if (uri == null) return@rememberLauncherForActivityResult val bytes = runCatching { - context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + context.contentResolver.openInputStream(uri)?.use { readCapped(it) } }.getOrNull() when { - bytes == null -> importError = "Couldn't read the selected file." + bytes == null -> importError = + "Couldn't read the file, or it's larger than ${MAX_IMPORT_BYTES / (1024 * 1024)} MB." !vm.loadSoundFont(slot, uri.toString(), bytes) -> importError = "Couldn't load this instrument. Supported formats are .sf2, .xi and " + "uncompressed .wav." } } + val loopOn = slot.string("loop", "Off") == "On" + val xfadeMs = slot.float("loopXfade", 0f).toInt() + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { RetroButton("LOAD SF2 / XI") { // .sf2/.xi have no reliable MIME; accept any file and detect by content. picker.launch(arrayOf("*/*")) } + // Sustain loop (seamless while a note is held; releases on note-off) plus a + // crossfade-time stepper in ms that smooths the loop seam (hold +/- to spin). + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RetroButton("LOOP", active = loopOn) { + slot.set("loop", if (loopOn) "Off" else "On") + vm.bumpForToolbar() + } + Text("XFADE", color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 11.sp) + RepeatButton("-") { + slot.set("loopXfade", (xfadeMs - 1).coerceAtLeast(0).toFloat()); vm.bumpForToolbar() + } + Text("${xfadeMs}ms", color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 12.sp) + RepeatButton("+") { + slot.set("loopXfade", (xfadeMs + 1).coerceAtMost(MAX_XFADE_MS).toFloat()); vm.bumpForToolbar() + } + } Text( if (sample == null) { "No instrument loaded — load a .sf2 or .xi file." @@ -125,9 +153,9 @@ fun SoundFontEditor(vm: AppViewModel, slot: ToolboxSlot) { horizontalArrangement = Arrangement.spacedBy(6.dp), ) { Text("OCT", color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 11.sp) - RetroButton("-") { octave = (octave - 1).coerceAtLeast(0) } + RepeatButton("-") { octave = (octave - 1).coerceAtLeast(0) } Text("$octave–${octave + 1}", color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 13.sp) - RetroButton("+") { octave = (octave + 1).coerceAtMost(8) } + RepeatButton("+") { octave = (octave + 1).coerceAtMost(8) } } AuditionOctave(vm, slot.index, (octave + 2) * 12) // upper octave on top AuditionOctave(vm, slot.index, (octave + 1) * 12) // lower octave below diff --git a/app/src/main/java/space/rcmd/android/sizzle/ui/tracker/ArrangementRoll.kt b/app/src/main/java/space/rcmd/android/sizzle/ui/tracker/ArrangementRoll.kt index 43c0371..a1df103 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/ui/tracker/ArrangementRoll.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/ui/tracker/ArrangementRoll.kt @@ -46,6 +46,7 @@ import space.rcmd.android.sizzle.model.Arrangement as ArrModel import space.rcmd.android.sizzle.model.LoopRegion import space.rcmd.android.sizzle.ui.AppViewModel import space.rcmd.android.sizzle.ui.components.GlyphCache +import space.rcmd.android.sizzle.ui.components.RepeatButton import space.rcmd.android.sizzle.ui.components.RetroButton import space.rcmd.android.sizzle.ui.theme.LocalRetro @@ -403,8 +404,8 @@ private fun RegionControl(name: String, region: LoopRegion, onTap: () -> Unit, v // the region, otherwise it assigns the current selection to it. RetroButton(name, active = region.isValid, onClick = onTap) // Repeat count updates live during playback (no transport reset). - RetroButton("-") { vm.changeRegionRepeats(region, -1) } + RepeatButton("-") { vm.changeRegionRepeats(region, -1) } Text(" x${region.repeats} ", color = c.text, fontFamily = FontFamily.Monospace, fontSize = 11.sp) - RetroButton("+") { vm.changeRegionRepeats(region, +1) } + RepeatButton("+") { vm.changeRegionRepeats(region, +1) } } } diff --git a/app/src/main/java/space/rcmd/android/sizzle/ui/tracker/TrackerScreen.kt b/app/src/main/java/space/rcmd/android/sizzle/ui/tracker/TrackerScreen.kt index 41180b2..b84474a 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/ui/tracker/TrackerScreen.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/ui/tracker/TrackerScreen.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.unit.sp import space.rcmd.android.sizzle.model.TimeSignature import space.rcmd.android.sizzle.ui.AppViewModel import space.rcmd.android.sizzle.ui.isLandscape +import space.rcmd.android.sizzle.ui.components.RepeatButton import space.rcmd.android.sizzle.ui.components.RetroButton import space.rcmd.android.sizzle.ui.components.RetroDropdown import space.rcmd.android.sizzle.ui.theme.LocalRetro @@ -149,14 +150,14 @@ private fun TransportButtons(vm: AppViewModel) { 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) }) + RepeatButton("-", onStep = { 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) }) + RepeatButton("+", onStep = { vm.setTempo(project.tempoBpm + 1) }) } RetroDropdown( label = "SIG",