Compare commits

...

7 Commits

Author SHA1 Message Date
Reactorcoremeltdown
8c44ed5151 Add full backup & restore to Settings (v0.14.0)
A new Backup & Restore card saves everything the app persists — songs, presets
(with their sample WAVs), themes, the autosaved project, and the settings +
input-binding DataStore blobs — into one .zip via the system file picker, and
restores from one.

Restore is defensive: it extracts to a staging folder and only swaps it into
place once the archive validates, so a corrupt/wrong file can't destroy current
data; it's zip-slip guarded and runs off the main thread. Since DataStore caches
in memory, restore prompts to reopen the app to finish applying. Covered by
BackupIoTest (round-trip replace, bad-file safety, empty-zip rejection).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 22:28:24 +02:00
Reactorcoremeltdown
3488bac1ca Group and enlarge the toolbox device picker (v0.13.4)
Split the SELECT DEVICE list into Instruments / MIDI effects / Audio effects,
each with a gap and a small header, and bump the device labels 13→18 sp with more
tap padding. The old [INS]/[EFF] prefix is dropped since the grouping conveys it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 18:11:30 +02:00
Reactorcoremeltdown
5dfbda7c15 Tape bobbin holes: rounded trapezoids, narrow side outward (v0.13.3)
Replace the inward-pointing triangular reel cutouts with rounded trapezoids —
wide toward the hub, narrow toward the rim. Generalize the corner-rounding helper
(roundedTri → roundedPoly) to handle the four-corner shape.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 15:54:14 +02:00
Reactorcoremeltdown
d43112f296 Redraw Tape Engine as a real tape transport (v0.13.2)
Reels are now NAB-style: a metal flange with three rotating rounded-triangular
bobbin holes and a hub, instead of chariot-wheel spokes. Tape runs under tension
as straight lines from each reel down to a rounded-rectangle playhead between and
below them (the top tape line is gone). The four controls are vintage round knobs
in a row — wow, flutter, hiss, wow-sync — replacing the sliders/stepper.

Each knob is weighted so all four are visible (the shared Knob's internal
fillMaxWidth text had let the first one swallow the row), and Knob gains an
optional knobSize (default 44 dp, so other editors are unchanged) which the tape
knobs set to 58 dp.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 15:48:31 +02:00
Reactorcoremeltdown
71bb50c775 Add Tape Engine and Granular Glitch visual effects (v0.13.0)
Tape Engine: a varispeed reel-to-reel. Audio runs through a ring buffer read at a
glided 'speed'; the editor draws two spinning reels you press-and-hold to brake,
diving the pitch like a finger on a slowing tape. Wow (slow, tempo-synced via
division) and flutter (fast) wobble the rate; hiss adds tape noise.

Granular Glitch: records the signal and, driven by Intensity, jumps the read head
back to replay short grains (stutter/repeat/octave glitch). A new VizEffect
interface lets the processor publish a decimated scope + playhead; the engine
exposes it per slot (effectScope/effectPlayhead), and the editor draws a dotted
oscilloscope with the jumping playhead over it plus a horizontal Intensity slider.
The scope reads in the Canvas draw phase (redraw only, no recomposition), mirroring
the VU-meter path.

Also make the toolbox device picker scrollable so all types stay reachable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 15:20:36 +02:00
Reactorcoremeltdown
ef13a46026 Widen arrangement mute toggles and block headers ~1.5× (v0.12.1)
Bigger, easier thumb targets on the arrangement roll's left column: the mute
toggle 34→51 dp and the block-number header 34→51 dp (laneColWidth 68→102). The
scrollable ruler/roll to the right is unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 15:20:11 +02:00
Reactorcoremeltdown
abdcf77ce5 Stacked keyboards go chromatic when routed to a Sampler (v0.12.0)
A Sampler maps a pad to each MIDI note, so scale filtering left out-of-scale
pads greyed-out and unreachable. When the punch-in / audition keyboard's channel
routes to a bus with a Sampler instrument, bypass the scale filter and enable all
twelve keys; otherwise scale filtering is unchanged. Non-destructive - the
project's scale/root setting is untouched, and switching the channel back to a
synth/SF2 restores filtering.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 23:59:36 +02:00
14 changed files with 990 additions and 18 deletions

View File

@@ -22,8 +22,8 @@ android {
// (android.media.midi) and AAudio low-latency audio we rely on.
minSdk = 26
targetSdk = 34
versionCode = 30
versionName = "0.11.2"
versionCode = 38
versionName = "0.14.0"
// We provide our own instrumentation runner if/when tests are added.
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

View File

@@ -659,6 +659,24 @@ class AudioEngine(
/** True while bus [ch] is (or recently was) clipping past 0 dBFS — drives the red VU. */
fun channelClipped(ch: Int): Boolean = ch in chClipHold.indices && chClipHold[ch] > 0
/** The live visualization source for toolbox [slotIndex], if that slot is an effect
* in some channel's insert chain and produces one (first match wins). Read on the
* UI thread; the returned processor's viz fields are written by the audio thread. */
private fun vizFor(slotIndex: Int): VizEffect? {
val chains = channelChains
for (ch in chains) for (u in ch) {
if (u.slot.index == slotIndex) { val e = u.effect; if (e is VizEffect) return e }
}
return null
}
/** Copy effect [slotIndex]'s scope samples into [out]; -1 if it isn't a live,
* routed viz effect (nothing to draw). */
fun effectScope(slotIndex: Int, out: FloatArray): Int = vizFor(slotIndex)?.copyScope(out) ?: -1
/** Effect [slotIndex]'s playhead (0..1), or -1 if no live viz source. */
fun effectPlayhead(slotIndex: Int): Float = vizFor(slotIndex)?.playhead() ?: -1f
/**
* Feed-forward, look-ahead brickwall limiter for the master bus. Results land in
* [limOutL]/[limOutR] (no per-sample allocation). Runs on the audio thread.

View File

@@ -4,6 +4,7 @@
package space.rcmd.android.sizzle.audio
import space.rcmd.android.sizzle.model.DelayDivisions
import space.rcmd.android.sizzle.model.TimeDivision
import space.rcmd.android.sizzle.model.ToolboxSlot
import space.rcmd.android.sizzle.model.ToolboxType
import kotlin.math.PI
@@ -42,11 +43,27 @@ interface AudioEffect {
ToolboxType.BITCRUSHER -> Bitcrusher()
ToolboxType.EQ10 -> GraphicEq(sampleRate)
ToolboxType.MIX_TIGHTENER -> MixTightener(sampleRate)
ToolboxType.TAPE_ENGINE -> TapeEngine(sampleRate)
ToolboxType.GRANULAR -> GranularGlitch(sampleRate)
else -> null // arpeggiator / transposer / LFO are handled by the sequencer
}
}
}
/**
* An effect that publishes a small visualization snapshot for its editor to draw.
* The audio thread writes into plain fields/arrays; the UI reads them at frame rate
* (a stale value for one frame is harmless, so no locking is used). Read only through
* the engine, which hands back the live processor for the edited slot.
*/
interface VizEffect {
/** Copy the most recent scope samples (oldest → newest) into [out]; return the
* count written (0 if nothing yet). */
fun copyScope(out: FloatArray): Int
/** Current read/playhead position within the scope window, 0 (oldest) … 1 (newest). */
fun playhead(): Float
}
// ---------------------------------------------------------------------------
// Multi-tap tape delay: one shared delay line read by up to four independent
// "tape heads", each with its own tempo-synced division and on/off switch. A tape
@@ -519,3 +536,195 @@ private class MixTightener(private val sampleRate: Int) : AudioEffect {
const val GAIN_SMOOTH = 0.05f // per-sample gain ramp toward the control-rate target
}
}
// ---------------------------------------------------------------------------
// Tape Engine: a varispeed reel-to-reel. Audio is written into a ring buffer at
// rate 1 and read back at `speed` (< 1 = slower = lower pitch), interpolated — a
// classic tape brake / tape-stop. `speed` is driven by the editor's reel gesture
// (press to brake); the engine GLIDES toward it so pitch dives and recovers with
// tape-like inertia rather than jumping. wow (slow, tempo-synced) and flutter (fast)
// wobble the read rate; hiss adds constant tape noise. Fully wet — the whole signal
// runs through the tape — so at speed 1 / no wobble it's just a short constant delay.
// ---------------------------------------------------------------------------
private class TapeEngine(private val sampleRate: Int) : AudioEffect {
private val buf = FloatArray(sampleRate * 2) // 2 s ring (headroom for long brakes)
private var writePos = NOMINAL_DELAY
private var readPos = 0.0
private var targetSpeed = 1.0
private var curSpeed = 1.0
private var wowInc = 0.0; private var wowPhase = 0.0; private var wowDepth = 0f
private var flutInc = FLUTTER_HZ / sampleRate; private var flutPhase = 0.0; private var flutDepth = 0f
private var hiss = 0f
private var rng = 0x51ED2A19
override fun update(slot: ToolboxSlot, tempoBpm: Float) {
targetSpeed = slot.float("speed", 1f).coerceIn(MIN_SPEED, 1f).toDouble()
wowDepth = slot.float("wow", 0.15f).coerceIn(0f, 1f) * MAX_WOW
flutDepth = slot.float("flutter", 0.15f).coerceIn(0f, 1f) * MAX_FLUT
hiss = slot.float("hiss", 0.10f).coerceIn(0f, 1f) * MAX_HISS
val secPerBeat = 60.0 / tempoBpm
val div = TimeDivision.fromIndex(slot.float("division", 2f).toInt())
val wowHz = 1.0 / (div.beatFraction * secPerBeat).coerceAtLeast(1e-4)
wowInc = wowHz / sampleRate
flutInc = FLUTTER_HZ / sampleRate
}
override fun process(x: Float): Float {
buf[writePos] = x
writePos++; if (writePos >= buf.size) writePos = 0
curSpeed += (targetSpeed - curSpeed) * SPEED_GLIDE // tape inertia
wowPhase += wowInc; if (wowPhase >= 1.0) wowPhase -= 1.0
flutPhase += flutInc; if (flutPhase >= 1.0) flutPhase -= 1.0
val wow = sin(2.0 * PI * wowPhase) * wowDepth
val flut = sin(2.0 * PI * flutPhase) * flutDepth
var rate = curSpeed * (1.0 + wow + flut)
if (rate < 0.01) rate = 0.01
val i0 = readPos.toInt() % buf.size
val i1 = (i0 + 1) % buf.size
val frac = (readPos - readPos.toInt()).toFloat()
var out = buf[i0] + (buf[i1] - buf[i0]) * frac
var rp = readPos + rate
if (rp >= buf.size) rp -= buf.size
// Keep the read head a valid distance behind the write head: never read the
// just-written/future region (min gap), and never lag past the buffer (max gap
// — a hard brake that would fall off snaps forward with a small glitch).
var gap = writePos - rp; if (gap < 0) gap += buf.size
val maxGap = buf.size - 2.0
if (gap < MIN_GAP) { rp = writePos - MIN_GAP; if (rp < 0) rp += buf.size }
else if (gap > maxGap) { rp = writePos - maxGap; if (rp < 0) rp += buf.size }
readPos = rp
if (hiss > 0f) out += nextRand() * hiss
return out
}
private fun nextRand(): Float {
var s = rng; s = s xor (s shl 13); s = s xor (s ushr 17); s = s xor (s shl 5); rng = s
return s * 4.656613e-10f // -1 … 1
}
private companion object {
const val NOMINAL_DELAY = 128 // baseline read latency (samples) for interp headroom
const val MIN_SPEED = 0.25f
const val MAX_WOW = 0.03f // ±3 % rate at full wow
const val MAX_FLUT = 0.008f // ±0.8 % rate at full flutter
const val MAX_HISS = 0.02f
const val FLUTTER_HZ = 11.0
const val SPEED_GLIDE = 0.00012f // per-sample glide (~0.15 s) toward target speed
const val MIN_GAP = 2.0
}
}
// ---------------------------------------------------------------------------
// Granular Glitch: records the incoming signal into a rolling buffer and, driven by
// Intensity, periodically "jumps" the read head back to a random earlier point and
// replays a short grain (stutter / repeat / octave glitch). Between grains it passes
// the signal through dry, so Intensity 0 is transparent and higher = more frequent,
// more mangled glitches. Publishes a decimated scope of the incoming sound plus the
// jumping read-head position ([VizEffect]) for the editor to draw.
// ---------------------------------------------------------------------------
private class GranularGlitch(private val sampleRate: Int) : AudioEffect, VizEffect {
private val buf = FloatArray(sampleRate) // 1 s history
private var writePos = 0
private var readPos = 0.0
private var grainLeft = 0
private var grainRate = 1.0
private var intensity = 0f
private var rng = 0x2F6E1DB7
// Visualization: a ring of decimated INPUT samples (the sound passing through) plus
// the normalized read-head position within that window.
private val scope = FloatArray(SCOPE_N)
private var scopeWrite = 0
private var decCount = 0
@Volatile private var playheadNorm = 1f
override fun update(slot: ToolboxSlot, tempoBpm: Float) {
intensity = slot.float("intensity", 0.3f).coerceIn(0f, 1f)
}
override fun process(x: Float): Float {
val wp = writePos
buf[wp] = x
writePos++; if (writePos >= buf.size) writePos = 0
val out: Float
if (grainLeft <= 0 && (intensity < 0.001f || !maybeStartGrain(wp))) {
// Dry passthrough — keep the read head trailing the write head so the next
// grain can start from "now" and jump back from there.
readPos = wp.toDouble()
out = x
} else {
val i0 = readPos.toInt() % buf.size
val i1 = (i0 + 1) % buf.size
val frac = (readPos - readPos.toInt()).toFloat()
out = buf[i0] + (buf[i1] - buf[i0]) * frac
var rp = readPos + grainRate
if (rp >= buf.size) rp -= buf.size
if (rp < 0) rp += buf.size
var gap = wp - rp; if (gap < 0) gap += buf.size
if (gap < 2.0) { rp = wp - 2.0; if (rp < 0) rp += buf.size } // don't read the future
readPos = rp
grainLeft--
}
// Scope: decimate the incoming signal into the ring.
if (++decCount >= SCOPE_DECIM) {
decCount = 0
scope[scopeWrite] = x
scopeWrite++; if (scopeWrite >= SCOPE_N) scopeWrite = 0
}
// Playhead: where the read head sits within the displayed window (0 oldest … 1 now).
var behind = wp - readPos; if (behind < 0) behind += buf.size
playheadNorm = (1f - behind.toFloat() / (SCOPE_N * SCOPE_DECIM)).coerceIn(0f, 1f)
return out
}
/** Maybe (probability scales with Intensity) start a new grain, jumping the read
* head back to a random earlier point. Returns true if a grain was started. */
private fun maybeStartGrain(wp: Int): Boolean {
if (nextUnit() > intensity * intensity * MAX_TRIGGER) return false
val minJ = MIN_JUMP_S * sampleRate
val maxJ = (MIN_JUMP_S + intensity * (MAX_JUMP_S - MIN_JUMP_S)) * sampleRate
val jump = minJ + nextUnit() * (maxJ - minJ)
var rp = wp.toDouble() - jump
while (rp < 0) rp += buf.size
readPos = rp
val lenSec = GRAIN_MAX_S - intensity * (GRAIN_MAX_S - GRAIN_MIN_S)
grainLeft = (lenSec * sampleRate * (0.5f + nextUnit())).toInt().coerceAtLeast(64)
grainRate = when { // occasional octave glitch, likelier when hot
nextUnit() < intensity * 0.3f -> 2.0
nextUnit() < intensity * 0.2f -> 0.5
else -> 1.0
}
return true
}
override fun copyScope(out: FloatArray): Int {
val n = if (out.size < SCOPE_N) out.size else SCOPE_N
val start = scopeWrite // oldest sample in the ring
for (k in 0 until n) out[k] = scope[(start + k) % SCOPE_N]
return n
}
override fun playhead(): Float = playheadNorm
private fun nextUnit(): Float {
var s = rng; s = s xor (s shl 13); s = s xor (s ushr 17); s = s xor (s shl 5); rng = s
return (s.toLong() and 0xFFFFFFFFL).toFloat() * 2.3283064e-10f // 0 … 1
}
private companion object {
const val SCOPE_N = 96 // scope points drawn
const val SCOPE_DECIM = 96 // input samples per point → ~192 ms window @ 48 kHz
const val MAX_TRIGGER = 0.0009f // per-sample grain-start probability at full intensity
const val MIN_JUMP_S = 0.03f // shortest jump-back
const val MAX_JUMP_S = 0.5f // longest jump-back at full intensity
const val GRAIN_MIN_S = 0.02f // shortest grain (hot = stutter)
const val GRAIN_MAX_S = 0.15f // longest grain (gentle)
}
}

View File

@@ -0,0 +1,107 @@
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
// SPDX-License-Identifier: GPL-3.0-or-later
package space.rcmd.android.sizzle.io
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.File
import java.io.InputStream
import java.io.OutputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
/**
* Whole-app backup: a single ZIP of everything the app persists under its private
* files dir — saved songs, themes, instrument/effect presets (with their sample
* WAVs), the autosaved project, and the DataStore blobs holding settings + input
* bindings. Restore extracts into a staging folder first and only swaps it into
* place once the archive looks valid, so a corrupt or wrong file can never destroy
* the current data. Because the DataStore prefs are cached in memory while the app
* runs, a restore only takes full effect after the app is restarted.
*/
object BackupIo {
// Top-level content areas under filesDir. "datastore" holds the settings +
// bindings Preferences files (see SettingsStore / BindingStore).
private val CONTENT_DIRS = listOf("songs", "themes", "presets", "datastore")
private const val AUTOSAVE = "autosave.sng"
private const val STAGING = ".restore_tmp"
/** Write a full backup of [filesDir] to [out] as a ZIP. */
fun write(filesDir: File, out: OutputStream) {
ZipOutputStream(BufferedOutputStream(out)).use { zip ->
for (dir in CONTENT_DIRS) addTree(zip, File(filesDir, dir), dir)
File(filesDir, AUTOSAVE).takeIf { it.isFile }?.let { addFile(zip, it, AUTOSAVE) }
}
}
/**
* Restore a backup ZIP from [input] into [filesDir], replacing the current data.
* Returns false (leaving current data untouched) if the archive is empty or holds
* none of the expected areas. Settings/bindings apply after an app restart.
*/
fun read(filesDir: File, input: InputStream): Boolean {
val staging = File(filesDir, STAGING)
staging.deleteRecursively(); staging.mkdirs()
val stagingRoot = staging.canonicalPath + File.separator
var any = false
try {
ZipInputStream(BufferedInputStream(input)).use { zin ->
var entry: ZipEntry? = zin.nextEntry
while (entry != null) {
val name = entry.name
if (!entry.isDirectory && !name.contains("..")) {
val target = File(staging, name)
// Zip-slip guard: the resolved path must stay inside staging.
if (target.canonicalPath.startsWith(stagingRoot)) {
target.parentFile?.mkdirs()
target.outputStream().use { zin.copyTo(it) }
any = true
}
}
zin.closeEntry()
entry = zin.nextEntry
}
}
} catch (e: Exception) {
// A truncated / non-ZIP file: bail out without touching the live data.
staging.deleteRecursively(); return false
}
val looksValid = any &&
(CONTENT_DIRS.any { File(staging, it).exists() } || File(staging, AUTOSAVE).isFile)
if (!looksValid) { staging.deleteRecursively(); return false }
// Swap each restored area into place (delete the live one first so a rename
// over it succeeds; fall back to a copy if rename can't cross the boundary).
for (dir in CONTENT_DIRS) {
val src = File(staging, dir)
if (!src.exists()) continue
val dst = File(filesDir, dir)
dst.deleteRecursively()
if (!src.renameTo(dst)) src.copyRecursively(dst, overwrite = true)
}
File(staging, AUTOSAVE).takeIf { it.isFile }?.let { src ->
val dst = File(filesDir, AUTOSAVE)
dst.delete()
if (!src.renameTo(dst)) src.copyTo(dst, overwrite = true)
}
staging.deleteRecursively()
return true
}
private fun addTree(zip: ZipOutputStream, dir: File, prefix: String) {
if (!dir.isDirectory) return
dir.walkTopDown().filter { it.isFile }.forEach { f ->
addFile(zip, f, prefix + "/" + f.relativeTo(dir).path.replace(File.separatorChar, '/'))
}
}
private fun addFile(zip: ZipOutputStream, f: File, entryName: String) {
zip.putNextEntry(ZipEntry(entryName))
f.inputStream().use { it.copyTo(zip) }
zip.closeEntry()
}
}

View File

@@ -226,6 +226,30 @@ enum class ToolboxType(
// (see MixTightenerPresets); only Strength is user-facing. Rendered by
// MixTightener in Effects.kt, edited by ui/toolbox/MixTightenerEditor.
listOf(ParamSpec("strength", "Strength", 1f, 0f, 1f)),
),
TAPE_ENGINE(
ToolboxKind.EFFECT, "Tape Engine",
// A reel-to-reel varispeed tape: press a reel in the editor to "brake" it and
// the pitch dives like a slowing tape (the 'speed' param is driven by that reel
// gesture, not a slider). wow/flutter add slow + fast pitch wobble, hiss adds
// tape noise, and the wow wobble rate is tempo-synced via 'division'. Rendered
// by TapeEngine in Effects.kt, edited by ui/toolbox/TapeEngineEditor.
listOf(
ParamSpec("speed", "Speed", 1f, 0.25f, 1f),
ParamSpec("wow", "Wow", 0.15f, 0f, 1f),
ParamSpec("flutter", "Flutter", 0.15f, 0f, 1f),
ParamSpec("hiss", "Hiss", 0.10f, 0f, 1f),
ParamSpec("division", "Wow Sync", 2f, 0f, 6f, isInt = true), // index into TimeDivision
),
),
GRANULAR(
ToolboxKind.EFFECT, "Granular Glitch",
// Stutters/repeats short grains read from a rolling buffer of the incoming
// sound; the read head jumps around, and the editor draws it as a moving
// playhead over a dotted scope of the signal. One control: Intensity (how often
// it glitches + how mangled). Rendered by GranularGlitch in Effects.kt, edited
// by ui/toolbox/GranularEditor.
listOf(ParamSpec("intensity", "Intensity", 0.3f, 0f, 1f)),
);
val isInstrument: Boolean get() = kind == ToolboxKind.INSTRUMENT

View File

@@ -19,6 +19,7 @@ import space.rcmd.android.sizzle.model.Pattern
import space.rcmd.android.sizzle.model.Pitch
import space.rcmd.android.sizzle.model.Project
import space.rcmd.android.sizzle.model.TimeSignature
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -221,12 +222,31 @@ class AppViewModel(
fun kbdNoteOn(midi: Int) { markNoteOn(midi); engine.busNoteOn(kbdChannel, midi, kbdVelocity) }
fun kbdNoteOff(midi: Int) { markNoteOff(midi); engine.busNoteOff(kbdChannel, midi) }
/** True if the on-screen keyboard's MIDI channel routes to any bus whose instrument
* is a Sampler. A Sampler maps a pad to each MIDI note, so scale filtering would
* hide pads — the stacked keyboards go fully chromatic in that case. */
fun kbdRoutesToSampler(): Boolean {
for (ch in project.mixer.channels) {
if (ch.midiChannel != kbdChannel) continue
val slot = project.toolbox.getOrNull(ch.instrumentSlot) ?: continue
if (slot.type == space.rcmd.android.sizzle.model.ToolboxType.SAMPLER) return true
}
return false
}
/** Live VU level (peak, ~0..1+) for mixer bus [ch] — polled at frame rate by the
* mixer's fader VU meters. */
fun channelMeter(ch: Int): Float = engine.channelMeter(ch)
/** True while mixer bus [ch] is (or just was) clipping past 0 dBFS. */
fun channelClipped(ch: Int): Boolean = engine.channelClipped(ch)
/** Copy the live scope samples for the effect in toolbox [slotIndex] into [out];
* returns the count, or -1 if it isn't a routed, running visual effect. Polled at
* frame rate by the Granular editor's oscilloscope. */
fun effectScope(slotIndex: Int, out: FloatArray): Int = engine.effectScope(slotIndex, out)
/** The effect [slotIndex]'s jumping playhead position (0..1), or -1 if none live. */
fun effectPlayhead(slotIndex: Int): Float = engine.effectPlayhead(slotIndex)
/**
* The core value-editing operation, shared by drag gestures and +/- input.
* NOTE column cycles within the active scale; VELOCITY/CHANNEL step numerically.
@@ -929,6 +949,41 @@ class AppViewModel(
fun writeSngExport(): java.io.File =
songLibrary.writeExport(project.name.ifBlank { "song" }, exportSng())
// ------------------------------------------------------------- full backup/restore
/** Result of the last backup/restore, shown in a dialog; null when dismissed. */
var backupMessage by mutableStateOf<String?>(null); private set
fun dismissBackupMessage() { backupMessage = null }
/** Write a full backup (songs, presets, themes, autosave, settings + bindings) to
* the SAF [uri] the user picked. Runs off the main thread. */
fun backupTo(uri: android.net.Uri) {
viewModelScope.launch(Dispatchers.IO) {
val ok = runCatching {
appContext.contentResolver.openOutputStream(uri)?.use {
space.rcmd.android.sizzle.io.BackupIo.write(appContext.filesDir, it)
} != null
}.getOrDefault(false)
backupMessage = if (ok) "Backup saved." else "Couldn't write the backup."
}
}
/** Restore a full backup from the SAF [uri], replacing all current data. Settings
* and bindings apply after the app is restarted. Runs off the main thread. */
fun restoreFrom(uri: android.net.Uri) {
viewModelScope.launch(Dispatchers.IO) {
val ok = runCatching {
appContext.contentResolver.openInputStream(uri)?.use {
space.rcmd.android.sizzle.io.BackupIo.read(appContext.filesDir, it)
} ?: false
}.getOrDefault(false)
backupMessage = if (ok) {
"Backup restored. Close and reopen the app to finish applying it."
} else {
"Restore failed — that doesn't look like a Sizzletracker backup."
}
}
}
// ------------------------------------------------------ instrument/effect presets
/** Preset names available for a device type (for the editor's browser dropdown). */
fun presetNames(type: space.rcmd.android.sizzle.model.ToolboxType): List<String> =

View File

@@ -28,6 +28,9 @@ import space.rcmd.android.sizzle.ui.theme.LocalRetro
private const val VEL_STEP = 8
/** All twelve pitch classes — used when scale filtering is bypassed (Sampler routing). */
private val CHROMATIC: Set<Int> = (0..11).toSet()
/**
* A stacked two-octave keyboard shared by the Toolbox (audition) and Tracker
* (punch-in) tabs. Its channel, octave range and velocity live on the
@@ -41,9 +44,14 @@ fun StackedKeyboard(vm: AppViewModel, punchIn: Boolean, modifier: Modifier) {
val c = LocalRetro.current
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // refresh key filtering on scale/root change
// Only in-scale keys are playable on BOTH the tracker punch-in and the toolbox
// audition keyboard (Chromatic yields all twelve = every key stays enabled).
// audition keyboard (Chromatic yields all twelve = every key stays enabled). But a
// Sampler maps a pad to each MIDI note, so scale filtering would hide pads — when
// the keyboard's channel routes to a Sampler, go fully chromatic so every pad is
// reachable.
val root = vm.project.rootNote
val inKey: Set<Int> = vm.project.scale.intervals.map { (it + root) % 12 }.toSet()
val inKey: Set<Int> =
if (vm.kbdRoutesToSampler()) CHROMATIC
else vm.project.scale.intervals.map { (it + root) % 12 }.toSet()
Column(
modifier.fillMaxWidth().background(c.background).padding(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),

View File

@@ -21,6 +21,7 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import space.rcmd.android.sizzle.ui.theme.LocalRetro
@@ -51,6 +52,7 @@ fun Knob(
valueText: String,
modifier: Modifier = Modifier,
default: Float = min,
knobSize: Dp = 44.dp,
onChange: (Float) -> Unit,
) {
val c = LocalRetro.current
@@ -71,7 +73,7 @@ fun Knob(
)
Canvas(
Modifier
.size(44.dp)
.size(knobSize)
.pointerInput(min, max) {
// Relative: slide up to raise, down to lower. A full min..max sweep
// takes KNOB_TRAVEL_DP of vertical travel (not the 44dp knob height),

View File

@@ -94,6 +94,7 @@ fun SettingsScreen(vm: AppViewModel) {
) {
item { PanicButton(vm) }
item { ProjectCard(vm) }
item { BackupCard(vm) }
item { HelpCard() }
item { AudioDevicesCard(vm) }
item { InterfaceCard(vm) }
@@ -372,6 +373,57 @@ private fun AudioDevicesCard(vm: AppViewModel) {
}
}
/** Whole-app backup to a single ZIP (songs, presets, themes, settings + bindings)
* and restore from one. Restore is destructive and needs an app restart. */
@Composable
private fun BackupCard(vm: AppViewModel) {
var confirmRestore by remember { mutableStateOf<Uri?>(null) }
val backupPicker = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("application/zip"),
) { uri -> uri?.let { vm.backupTo(it) } }
val restorePicker = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument(),
) { uri -> if (uri != null) confirmRestore = uri }
SettingsCard("Backup & Restore", subtitle = "Save or restore all songs, presets, themes & settings") {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = { backupPicker.launch("sizzletracker-backup.zip") }) { Text("Back up all") }
OutlinedButton(onClick = { restorePicker.launch(arrayOf("application/zip", "application/octet-stream", "*/*")) }) {
Text("Restore")
}
}
Text(
"Backup writes one .zip. Restore replaces ALL current data and takes effect " +
"after you reopen the app.",
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
confirmRestore?.let { uri ->
AlertDialog(
onDismissRequest = { confirmRestore = null },
title = { Text("Restore backup?") },
text = {
Text(
"This replaces all current songs, presets, themes and settings with the " +
"backup's contents. This can't be undone.",
)
},
confirmButton = { TextButton(onClick = { vm.restoreFrom(uri); confirmRestore = null }) { Text("Restore") } },
dismissButton = { TextButton(onClick = { confirmRestore = null }) { Text("Cancel") } },
)
}
vm.backupMessage?.let { msg ->
AlertDialog(
onDismissRequest = { vm.dismissBackupMessage() },
title = { Text("Backup & Restore") },
text = { Text(msg) },
confirmButton = { TextButton(onClick = { vm.dismissBackupMessage() }) { Text("OK") } },
)
}
}
/** About + external links: app version, source, user guide, and support. */
@Composable
private fun HelpCard() {

View File

@@ -0,0 +1,155 @@
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
// SPDX-License-Identifier: GPL-3.0-or-later
package space.rcmd.android.sizzle.ui.toolbox
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import space.rcmd.android.sizzle.model.ToolboxSlot
import space.rcmd.android.sizzle.ui.AppViewModel
import space.rcmd.android.sizzle.ui.theme.LocalRetro
import kotlin.math.roundToInt
/**
* Editor for the [space.rcmd.android.sizzle.model.ToolboxType.GRANULAR] effect: a
* dotted oscilloscope of the sound passing through with the granular read head drawn
* as a jumping playhead, and one horizontal Intensity slider below. The scope only
* shows data while the effect is placed on a mixer channel that is receiving signal.
*/
@Composable
fun GranularEditor(vm: AppViewModel, slot: ToolboxSlot) {
val c = LocalRetro.current
@Suppress("UNUSED_VARIABLE") val rev = vm.revision
val mono = FontFamily.Monospace
// Polled once per frame from the audio engine: the scope samples, the playhead
// position, and how many samples are live (-1 = not routed / no signal).
val scope = remember { FloatArray(SCOPE_CAP) }
var count by remember { mutableIntStateOf(-1) }
var playhead by remember { mutableFloatStateOf(-1f) }
var tick by remember { mutableIntStateOf(0) }
LaunchedEffect(slot.index) {
while (true) {
withFrameNanos { }
count = vm.effectScope(slot.index, scope)
playhead = vm.effectPlayhead(slot.index)
tick++
}
}
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
Modifier.fillMaxWidth().height(160.dp).background(c.background).border(1.dp, c.grid, RectangleShape),
) {
Canvas(Modifier.fillMaxWidth().height(160.dp)) {
@Suppress("UNUSED_EXPRESSION") tick // subscribe the draw to the per-frame updates
val w = size.width
val h = size.height
val cy = h * 0.5f
val amp = h * 0.42f
drawLine(c.grid, Offset(0f, cy), Offset(w, cy), strokeWidth = 1f) // baseline
val n = count
if (n > 1) {
// Dotted waveform of the signal passing through.
val step = w / (n - 1)
for (k in 0 until n) {
val y = cy - scope[k].coerceIn(-1f, 1f) * amp
drawCircle(c.textDim, radius = 2f, center = Offset(k * step, y))
}
// Jumping playhead over the scope.
val ph = playhead
if (ph in 0f..1f) {
val px = ph * w
drawLine(c.accent, Offset(px, 0f), Offset(px, h), strokeWidth = 2f)
drawCircle(c.accent, radius = 4f, center = Offset(px, cy))
}
}
}
if (count < 0) {
Text(
"Add to a mixer channel's FX and play to see the scope.",
color = c.textDim, fontFamily = mono, fontSize = 11.sp,
textAlign = TextAlign.Center,
modifier = Modifier.align(Alignment.Center).fillMaxWidth(),
)
}
}
val intensity = slot.float("intensity", 0.3f).coerceIn(0f, 1f)
Text("Intensity ${(intensity * 100).roundToInt()}%",
color = c.accent, fontFamily = mono, fontSize = 12.sp)
HorizontalSlider(
value = intensity,
onValueChange = { slot.set("intensity", it.coerceIn(0f, 1f)); vm.bumpForToolbar() },
modifier = Modifier.fillMaxWidth().height(40.dp),
)
Text(
"Low = occasional stutters · High = constant glitch & octave jumps.",
color = c.textDim, fontFamily = mono, fontSize = 10.sp,
textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(),
)
}
}
/** A horizontal fill-from-left slider matching the app's retro faders. */
@Composable
private fun HorizontalSlider(value: Float, onValueChange: (Float) -> Unit, modifier: Modifier) {
val c = LocalRetro.current
Box(
modifier
.border(1.dp, c.grid, RectangleShape)
.background(c.background)
.pointerInput(Unit) {
detectTapGestures { off -> onValueChange(off.x / size.width) }
}
.pointerInput(Unit) {
detectDragGestures { change, _ -> change.consume(); onValueChange(change.position.x / size.width) }
},
contentAlignment = Alignment.CenterStart,
) {
Box(
Modifier
.fillMaxHeight()
.fillMaxWidth(value.coerceIn(0.001f, 1f))
.background(c.accent.copy(alpha = 0.30f)),
contentAlignment = Alignment.CenterEnd,
) {
Box(Modifier.fillMaxHeight().width(3.dp).background(c.accent)) // thumb
}
}
}
private const val SCOPE_CAP = 128 // UI buffer; the engine fills up to its own scope size

View File

@@ -0,0 +1,254 @@
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
// SPDX-License-Identifier: GPL-3.0-or-later
package space.rcmd.android.sizzle.ui.toolbox
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import space.rcmd.android.sizzle.model.TimeDivision
import space.rcmd.android.sizzle.model.ToolboxSlot
import space.rcmd.android.sizzle.ui.AppViewModel
import space.rcmd.android.sizzle.ui.components.Knob
import space.rcmd.android.sizzle.ui.theme.LocalRetro
import kotlin.math.cos
import kotlin.math.hypot
import kotlin.math.min
import kotlin.math.roundToInt
import kotlin.math.sin
/**
* Editor for the [space.rcmd.android.sizzle.model.ToolboxType.TAPE_ENGINE] effect,
* drawn as a reel-to-reel tape transport: two NAB reels (metal flange with three
* rounded-triangular bobbin holes) feeding tape down to a playhead block between them
* under tension. **Press and hold a reel to brake it** — the tape (and its pitch)
* dives, then springs back when released. Below is a row of vintage round knobs: wow,
* flutter, hiss, and the tempo-sync division for the wow wobble.
*/
@Composable
fun TapeEngineEditor(vm: AppViewModel, slot: ToolboxSlot) {
val c = LocalRetro.current
@Suppress("UNUSED_VARIABLE") val rev = vm.revision
val mono = FontFamily.Monospace
// Rotation + a display speed eased toward the slot's target, so the reels dive and
// recover in step with the audio's own glide. Advanced once per frame.
var angle by remember { mutableFloatStateOf(0f) }
var displaySpeed by remember { mutableFloatStateOf(1f) }
LaunchedEffect(slot.index) {
while (true) {
withFrameNanos { }
val target = slot.float("speed", 1f).coerceIn(0.25f, 1f)
displaySpeed += (target - displaySpeed) * 0.10f
angle = (angle + displaySpeed * DEG_PER_FRAME) % 360f
}
}
fun setSpeed(v: Float) { slot.set("speed", v.coerceIn(0.25f, 1f)); vm.bumpForToolbar() }
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
Modifier
.fillMaxWidth()
.height(180.dp)
.background(c.surface)
// Press a reel to brake the tape; release to let it spin back up.
.pointerInput(slot.index) {
detectTapGestures(
onPress = { off ->
if (overReel(off, size.width.toFloat(), size.height.toFloat())) {
setSpeed(BRAKE_SPEED)
tryAwaitRelease()
setSpeed(1f)
}
},
)
},
) {
Canvas(Modifier.fillMaxWidth().height(180.dp)) {
val cx0 = size.width * REEL_L_X
val cx1 = size.width * REEL_R_X
val cy = size.height * REEL_CY
val r = min(size.height * 0.30f, size.width * 0.20f)
// Playhead block between + below the reels.
val hw = (cx1 - cx0) * 0.46f
val hh = size.height * 0.13f
val hx = size.width * 0.5f
val hy = cy + r + size.height * 0.14f
val tl = Offset(hx - hw / 2f, hy - hh / 2f)
val tr = Offset(hx + hw / 2f, hy - hh / 2f)
// Tensioned tape: from each reel's bottom straight down to the playhead
// and across its top. No line over the top of the reels.
val p0 = Offset(cx0, cy + r)
val p1 = Offset(cx1, cy + r)
val tape = c.textDim
drawLine(tape, p0, tl, strokeWidth = 2.5f)
drawLine(tape, tl, tr, strokeWidth = 2.5f)
drawLine(tape, tr, p1, strokeWidth = 2.5f)
// Playhead (rounded rectangle) drawn over the tape it tensions.
drawRoundRect(
color = c.grid, topLeft = Offset(hx - hw / 2f, hy - hh / 2f),
size = Size(hw, hh), cornerRadius = CornerRadius(hh * 0.45f, hh * 0.45f),
)
drawRoundRect(
color = c.accent, topLeft = Offset(hx - hw / 2f, hy - hh / 2f),
size = Size(hw, hh), cornerRadius = CornerRadius(hh * 0.45f, hh * 0.45f),
style = Stroke(width = 2f),
)
// Head gap slit in the middle of the playhead.
drawLine(c.accent, Offset(hx, hy - hh / 2f + 3f), Offset(hx, hy + hh / 2f - 3f), strokeWidth = 2f)
drawReel(cx0, cy, r, angle, c.accent, c.grid, c.surface, c.textDim)
drawReel(cx1, cy, r, angle, c.accent, c.grid, c.surface, c.textDim)
}
}
Text(
"Hold a reel to brake the tape — pitch dives, then springs back.",
color = c.textDim, fontFamily = mono, fontSize = 11.sp,
textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(),
)
// Vintage round knobs, all in a row (each weighted so all four fit).
val divIdx = slot.float("division", 2f).toInt().coerceIn(0, TimeDivision.entries.lastIndex)
Row(
Modifier.fillMaxWidth().padding(top = 2.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.Top,
) {
PercentKnob(vm, slot, "wow", "WOW", 0.15f, Modifier.weight(1f))
PercentKnob(vm, slot, "flutter", "FLUTTER", 0.15f, Modifier.weight(1f))
PercentKnob(vm, slot, "hiss", "HISS", 0.10f, Modifier.weight(1f))
Knob(
label = "WOW SYNC",
value = divIdx.toFloat(), min = 0f, max = TimeDivision.entries.lastIndex.toFloat(),
valueText = TimeDivision.fromIndex(divIdx).label, default = 2f,
modifier = Modifier.weight(1f), knobSize = KNOB_SIZE,
onChange = { v ->
slot.set("division", v.roundToInt().coerceIn(0, TimeDivision.entries.lastIndex).toFloat())
vm.bumpForToolbar()
},
)
}
}
}
/** A 0..1 knob showing its value as a percentage, writing [key] on the slot. */
@Composable
private fun PercentKnob(vm: AppViewModel, slot: ToolboxSlot, key: String, label: String, default: Float, modifier: Modifier) {
@Suppress("UNUSED_VARIABLE") val rev = vm.revision
val value = slot.float(key, default).coerceIn(0f, 1f)
Knob(
label = label, value = value, min = 0f, max = 1f,
valueText = "${(value * 100).roundToInt()}%", default = default,
modifier = modifier, knobSize = KNOB_SIZE,
onChange = { slot.set(key, it.coerceIn(0f, 1f)); vm.bumpForToolbar() },
)
}
/** True if [off] is within either reel's circle (used to gate the brake gesture). */
private fun overReel(off: Offset, w: Float, h: Float): Boolean {
val cy = h * REEL_CY
val r = min(h * 0.30f, w * 0.20f)
return hypot(off.x - w * REEL_L_X, off.y - cy) <= r ||
hypot(off.x - w * REEL_R_X, off.y - cy) <= r
}
/** Draw one NAB reel: metal flange, three rotating rounded-triangular bobbin holes,
* and the hub in the centre. */
private fun DrawScope.drawReel(
cx: Float, cy: Float, r: Float, angle: Float,
accent: Color, flange: Color, hole: Color, rim: Color,
) {
val center = Offset(cx, cy)
drawCircle(flange, radius = r, center = center) // metal flange
for (k in 0 until 3) drawPath(bobbinHole(cx, cy, r, angle + k * 120f), hole) // trapezoidal cutouts
drawCircle(hole, radius = r * 0.26f, center = center) // bobbin centre (open)
drawCircle(accent, radius = r * 0.26f, center = center, style = Stroke(width = 3f))
drawCircle(accent, radius = r * 0.14f, center = center, style = Stroke(width = 2f))
drawCircle(rim, radius = r, center = center, style = Stroke(width = 2.5f)) // flange edge
}
/** One rounded-trapezoidal bobbin hole at [thetaDeg] around the reel: wide toward the
* hub, narrow toward the rim (the narrow side faces outward). */
private fun bobbinHole(cx: Float, cy: Float, r: Float, thetaDeg: Float): Path {
val rIn = r * 0.40f; val rOut = r * 0.86f
val inner = 44f // angular half-spread at the hub (wide side)
val outer = 13f // angular half-spread at the rim (narrow side)
val innerL = polar(cx, cy, rIn, thetaDeg - inner)
val outerL = polar(cx, cy, rOut, thetaDeg - outer)
val outerR = polar(cx, cy, rOut, thetaDeg + outer)
val innerR = polar(cx, cy, rIn, thetaDeg + inner)
return roundedPoly(arrayOf(innerL, outerL, outerR, innerR), r * 0.09f)
}
/** A closed polygon through [v] with every corner rounded to radius [corner]. */
private fun roundedPoly(v: Array<Offset>, corner: Float): Path {
val n = v.size
val path = Path()
for (i in 0 until n) {
val curr = v[i]
val prev = v[(i + n - 1) % n]
val next = v[(i + 1) % n]
val a = curr + toward(curr, prev, corner) // approach point on the incoming edge
val b = curr + toward(curr, next, corner) // leave point on the outgoing edge
if (i == 0) path.moveTo(a.x, a.y) else path.lineTo(a.x, a.y)
path.quadraticBezierTo(curr.x, curr.y, b.x, b.y)
}
path.close()
return path
}
/** Vector of length [len] from [from] toward [to]. */
private fun toward(from: Offset, to: Offset, len: Float): Offset {
val dx = to.x - from.x; val dy = to.y - from.y
val d = hypot(dx, dy).coerceAtLeast(1e-3f)
return Offset(dx / d * len, dy / d * len)
}
private fun polar(cx: Float, cy: Float, rad: Float, deg: Float): Offset {
val a = Math.toRadians(deg.toDouble())
return Offset(cx + rad * cos(a).toFloat(), cy + rad * sin(a).toFloat())
}
private val KNOB_SIZE = 58.dp // a touch bigger than the default 44 dp knob
private const val DEG_PER_FRAME = 6f // ~1 rev/sec at 60 fps and full speed
private const val BRAKE_SPEED = 0.5f // target while a reel is held (≈ one octave down)
private const val REEL_L_X = 0.30f
private const val REEL_R_X = 0.70f
private const val REEL_CY = 0.38f // reel centre as a fraction of the canvas height

View File

@@ -242,29 +242,45 @@ private fun SlotTile(
}
}
/** A simple overlay list of all available instruments and effects to load. */
/** A simple overlay list of all available devices, grouped into instruments, MIDI
* effects and audio effects (separated by a gap + header). */
@Composable
private fun DevicePicker(onDismiss: () -> Unit, onPick: (ToolboxType) -> Unit) {
val c = LocalRetro.current
// MIDI effects alter note generation (handled by the sequencer); every other
// effect processes audio. Split the two so the list reads in three clear groups.
val midiEffects = setOf(ToolboxType.ARPEGGIATOR, ToolboxType.TRANSPOSER, ToolboxType.LFO)
val groups = listOf(
"INSTRUMENTS" to ToolboxType.entries.filter { it.isInstrument },
"MIDI EFFECTS" to ToolboxType.entries.filter { !it.isInstrument && it in midiEffects },
"AUDIO EFFECTS" to ToolboxType.entries.filter { !it.isInstrument && it !in midiEffects },
)
Box(
Modifier.fillMaxSize().background(c.background.copy(alpha = 0.92f))
.pointerInput(Unit) { detectTapGestures { onDismiss() } },
contentAlignment = Alignment.Center,
) {
Column(Modifier.padding(16.dp)) {
Text("SELECT DEVICE", color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 14.sp)
ToolboxType.entries.forEach { type ->
Column(Modifier.fillMaxHeight().padding(16.dp).verticalScroll(rememberScrollState())) {
Text("SELECT DEVICE", color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 16.sp)
groups.forEach { (header, types) ->
Spacer(Modifier.height(16.dp)) // visual gap between groups
Text(
"[${type.kind.name.take(3)}] ${type.displayName}",
color = c.text, fontFamily = FontFamily.Monospace, fontSize = 13.sp,
header, color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 11.sp,
modifier = Modifier.padding(bottom = 2.dp),
)
types.forEach { type ->
Text(
type.displayName,
color = c.text, fontFamily = FontFamily.Monospace, fontSize = 18.sp,
modifier = Modifier
.fillMaxWidth()
.pointerInput(type) { detectTapGestures { onPick(type) } }
.padding(vertical = 6.dp),
.padding(vertical = 8.dp),
)
}
}
}
}
}
/** Auto-generated parameter editor. Presets are managed by the [PresetBar]. */
@@ -310,6 +326,8 @@ private fun ParamEditor(vm: AppViewModel, slot: ToolboxSlot, onClose: () -> Unit
when (type) {
ToolboxType.NES_SYNTH -> NesSynthEditor(vm, slot)
ToolboxType.MIX_TIGHTENER -> MixTightenerEditor(vm, slot)
ToolboxType.TAPE_ENGINE -> TapeEngineEditor(vm, slot)
ToolboxType.GRANULAR -> GranularEditor(vm, slot)
ToolboxType.SAMPLER -> SamplerEditor(vm, slot, samplerPad, onSelectPad = { samplerPad = it })
ToolboxType.SOUNDFONT -> SoundFontEditor(vm, slot)
ToolboxType.DELAY -> DelayEditor(vm, slot)
@@ -338,7 +356,7 @@ private fun ParamEditor(vm: AppViewModel, slot: ToolboxSlot, onClose: () -> Unit
* single stacked column. Each [ParamControl] fills its half-width cell (an odd last
* param gets an empty cell beside it). */
@Composable
private fun ParamGrid(vm: AppViewModel, slot: ToolboxSlot, params: List<ParamSpec>) {
internal fun ParamGrid(vm: AppViewModel, slot: ToolboxSlot, params: List<ParamSpec>) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
var i = 0
while (i < params.size) {

View File

@@ -94,8 +94,8 @@ fun ArrangementRoll(vm: AppViewModel) {
val glyphs = remember(measurer) { GlyphCache(measurer) }
val beatWidth = 20.dp
val muteColWidth = 34.dp // wide enough to hit comfortably from the screen edge
val laneColWidth = 68.dp // mute toggle + block number
val muteColWidth = 51.dp // ~1.5× — wider, easy to hit from the screen edge
val laneColWidth = 102.dp // mute toggle (51) + block number header (51), both ~1.5×
val rulerHeight = 18.dp
val beatWidthPx = with(density) { beatWidth.toPx() }
val rulerHeightPx = with(density) { rulerHeight.toPx() }

View File

@@ -0,0 +1,70 @@
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
// SPDX-License-Identifier: GPL-3.0-or-later
package space.rcmd.android.sizzle.io
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.nio.file.Files
/** Round-trip + safety coverage for the whole-app [BackupIo]. */
class BackupIoTest {
private fun tempDir(): File = Files.createTempDirectory("backup-test").toFile()
private fun seed(dir: File) {
File(dir, "songs").mkdirs()
File(dir, "songs/tune.szg").writeText("SONG-DATA")
File(dir, "presets/DELAY").mkdirs()
File(dir, "presets/DELAY/slap.szp").writeText("PRESET-DATA")
File(dir, "datastore").mkdirs()
File(dir, "datastore/app_settings.preferences_pb").writeBytes(byteArrayOf(1, 2, 3, 4))
File(dir, "autosave.sng").writeText("AUTO-DATA")
}
@Test fun backupRestoreRoundTripReplacesContent() {
val source = tempDir().also { seed(it) }
val zip = ByteArrayOutputStream().also { BackupIo.write(source, it) }.toByteArray()
// Restore into a dir that already holds different + stale data.
val target = tempDir()
File(target, "songs").mkdirs()
File(target, "songs/stale.szg").writeText("STALE") // not in the backup → must go
assertTrue(BackupIo.read(target, ByteArrayInputStream(zip)))
assertEquals("SONG-DATA", File(target, "songs/tune.szg").readText())
assertFalse("a song absent from the backup is removed", File(target, "songs/stale.szg").exists())
assertEquals("PRESET-DATA", File(target, "presets/DELAY/slap.szp").readText())
assertEquals("AUTO-DATA", File(target, "autosave.sng").readText())
assertArrayEquals(byteArrayOf(1, 2, 3, 4), File(target, "datastore/app_settings.preferences_pb").readBytes())
assertFalse("staging is cleaned up", File(target, ".restore_tmp").exists())
}
@Test fun invalidArchiveLeavesDataIntact() {
val target = tempDir()
File(target, "songs").mkdirs()
File(target, "songs/keep.szg").writeText("KEEP")
assertFalse(BackupIo.read(target, ByteArrayInputStream("this is not a zip".toByteArray())))
assertEquals("existing data survives a bad restore file", "KEEP", File(target, "songs/keep.szg").readText())
}
@Test fun emptyZipIsRejected() {
val emptyZip = ByteArrayOutputStream().also { java.util.zip.ZipOutputStream(it).close() }.toByteArray()
val target = tempDir()
File(target, "songs").mkdirs()
File(target, "songs/keep.szg").writeText("KEEP")
assertFalse(BackupIo.read(target, ByteArrayInputStream(emptyZip)))
assertEquals("KEEP", File(target, "songs/keep.szg").readText())
}
private fun assertArrayEquals(a: ByteArray, b: ByteArray) =
assertTrue("byte arrays differ", a.contentEquals(b))
}