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:
Reactorcoremeltdown
2026-07-17 20:38:10 +02:00
parent 4bd8aac4d5
commit ba56b5ab9e
15 changed files with 290 additions and 41 deletions

View File

@@ -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"

View File

@@ -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 -> {}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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),
),
),

View File

@@ -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,

View File

@@ -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.

View File

@@ -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(

View File

@@ -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() }
}
}

View File

@@ -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()
}

View File

@@ -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."

View File

@@ -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

View File

@@ -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) }
}
}

View File

@@ -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",