SF2/XI loop crossfade + hold-to-repeat steppers (v0.3.0)
- SF2/XI loader: add a Loop XFade control (ms, 0-500) next to the loop toggle. SampleVoice crossfades the loop tail into its head for a click-free seam even when the file's loop points don't match; 0 ms keeps the previous hard wrap. Threaded through VoiceSample and all noteOn call sites. - Add a reusable RepeatButton: fires on press then auto-repeats while held with an exponentially shrinking interval (stops on release/scroll-off). Applied to every +/- stepper (tempo, MIDI channel, arrangement repeats, audition/keyboard octave, and the new crossfade). - Bump version to 0.3.0 (versionCode 3) per the minor-per-change policy. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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 -> {}
|
||||
|
||||
@@ -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<String, Sample>()
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 <T> RetroDropdown(
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user