Compare commits
7 Commits
8eee588780
...
8c44ed5151
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c44ed5151 | ||
|
|
3488bac1ca | ||
|
|
5dfbda7c15 | ||
|
|
d43112f296 | ||
|
|
71bb50c775 | ||
|
|
ef13a46026 | ||
|
|
abdcf77ce5 |
@@ -22,8 +22,8 @@ android {
|
|||||||
// (android.media.midi) and AAudio low-latency audio we rely on.
|
// (android.media.midi) and AAudio low-latency audio we rely on.
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 30
|
versionCode = 38
|
||||||
versionName = "0.11.2"
|
versionName = "0.14.0"
|
||||||
|
|
||||||
// We provide our own instrumentation runner if/when tests are added.
|
// We provide our own instrumentation runner if/when tests are added.
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|||||||
@@ -659,6 +659,24 @@ class AudioEngine(
|
|||||||
/** True while bus [ch] is (or recently was) clipping past 0 dBFS — drives the red VU. */
|
/** 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
|
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
|
* Feed-forward, look-ahead brickwall limiter for the master bus. Results land in
|
||||||
* [limOutL]/[limOutR] (no per-sample allocation). Runs on the audio thread.
|
* [limOutL]/[limOutR] (no per-sample allocation). Runs on the audio thread.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package space.rcmd.android.sizzle.audio
|
package space.rcmd.android.sizzle.audio
|
||||||
|
|
||||||
import space.rcmd.android.sizzle.model.DelayDivisions
|
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.ToolboxSlot
|
||||||
import space.rcmd.android.sizzle.model.ToolboxType
|
import space.rcmd.android.sizzle.model.ToolboxType
|
||||||
import kotlin.math.PI
|
import kotlin.math.PI
|
||||||
@@ -42,11 +43,27 @@ interface AudioEffect {
|
|||||||
ToolboxType.BITCRUSHER -> Bitcrusher()
|
ToolboxType.BITCRUSHER -> Bitcrusher()
|
||||||
ToolboxType.EQ10 -> GraphicEq(sampleRate)
|
ToolboxType.EQ10 -> GraphicEq(sampleRate)
|
||||||
ToolboxType.MIX_TIGHTENER -> MixTightener(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
|
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
|
// 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
|
// "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
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
107
app/src/main/java/space/rcmd/android/sizzle/io/BackupIo.kt
Normal file
107
app/src/main/java/space/rcmd/android/sizzle/io/BackupIo.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -226,6 +226,30 @@ enum class ToolboxType(
|
|||||||
// (see MixTightenerPresets); only Strength is user-facing. Rendered by
|
// (see MixTightenerPresets); only Strength is user-facing. Rendered by
|
||||||
// MixTightener in Effects.kt, edited by ui/toolbox/MixTightenerEditor.
|
// MixTightener in Effects.kt, edited by ui/toolbox/MixTightenerEditor.
|
||||||
listOf(ParamSpec("strength", "Strength", 1f, 0f, 1f)),
|
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
|
val isInstrument: Boolean get() = kind == ToolboxKind.INSTRUMENT
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import space.rcmd.android.sizzle.model.Pattern
|
|||||||
import space.rcmd.android.sizzle.model.Pitch
|
import space.rcmd.android.sizzle.model.Pitch
|
||||||
import space.rcmd.android.sizzle.model.Project
|
import space.rcmd.android.sizzle.model.Project
|
||||||
import space.rcmd.android.sizzle.model.TimeSignature
|
import space.rcmd.android.sizzle.model.TimeSignature
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
@@ -221,12 +222,31 @@ class AppViewModel(
|
|||||||
fun kbdNoteOn(midi: Int) { markNoteOn(midi); engine.busNoteOn(kbdChannel, midi, kbdVelocity) }
|
fun kbdNoteOn(midi: Int) { markNoteOn(midi); engine.busNoteOn(kbdChannel, midi, kbdVelocity) }
|
||||||
fun kbdNoteOff(midi: Int) { markNoteOff(midi); engine.busNoteOff(kbdChannel, midi) }
|
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
|
/** Live VU level (peak, ~0..1+) for mixer bus [ch] — polled at frame rate by the
|
||||||
* mixer's fader VU meters. */
|
* mixer's fader VU meters. */
|
||||||
fun channelMeter(ch: Int): Float = engine.channelMeter(ch)
|
fun channelMeter(ch: Int): Float = engine.channelMeter(ch)
|
||||||
/** True while mixer bus [ch] is (or just was) clipping past 0 dBFS. */
|
/** True while mixer bus [ch] is (or just was) clipping past 0 dBFS. */
|
||||||
fun channelClipped(ch: Int): Boolean = engine.channelClipped(ch)
|
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.
|
* The core value-editing operation, shared by drag gestures and +/- input.
|
||||||
* NOTE column cycles within the active scale; VELOCITY/CHANNEL step numerically.
|
* NOTE column cycles within the active scale; VELOCITY/CHANNEL step numerically.
|
||||||
@@ -929,6 +949,41 @@ class AppViewModel(
|
|||||||
fun writeSngExport(): java.io.File =
|
fun writeSngExport(): java.io.File =
|
||||||
songLibrary.writeExport(project.name.ifBlank { "song" }, exportSng())
|
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
|
// ------------------------------------------------------ instrument/effect presets
|
||||||
/** Preset names available for a device type (for the editor's browser dropdown). */
|
/** Preset names available for a device type (for the editor's browser dropdown). */
|
||||||
fun presetNames(type: space.rcmd.android.sizzle.model.ToolboxType): List<String> =
|
fun presetNames(type: space.rcmd.android.sizzle.model.ToolboxType): List<String> =
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ import space.rcmd.android.sizzle.ui.theme.LocalRetro
|
|||||||
|
|
||||||
private const val VEL_STEP = 8
|
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
|
* A stacked two-octave keyboard shared by the Toolbox (audition) and Tracker
|
||||||
* (punch-in) tabs. Its channel, octave range and velocity live on the
|
* (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
|
val c = LocalRetro.current
|
||||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // refresh key filtering on scale/root change
|
@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
|
// 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 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(
|
Column(
|
||||||
modifier.fillMaxWidth().background(c.background).padding(6.dp),
|
modifier.fillMaxWidth().background(c.background).padding(6.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import androidx.compose.ui.geometry.Offset
|
|||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import space.rcmd.android.sizzle.ui.theme.LocalRetro
|
import space.rcmd.android.sizzle.ui.theme.LocalRetro
|
||||||
@@ -51,6 +52,7 @@ fun Knob(
|
|||||||
valueText: String,
|
valueText: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
default: Float = min,
|
default: Float = min,
|
||||||
|
knobSize: Dp = 44.dp,
|
||||||
onChange: (Float) -> Unit,
|
onChange: (Float) -> Unit,
|
||||||
) {
|
) {
|
||||||
val c = LocalRetro.current
|
val c = LocalRetro.current
|
||||||
@@ -71,7 +73,7 @@ fun Knob(
|
|||||||
)
|
)
|
||||||
Canvas(
|
Canvas(
|
||||||
Modifier
|
Modifier
|
||||||
.size(44.dp)
|
.size(knobSize)
|
||||||
.pointerInput(min, max) {
|
.pointerInput(min, max) {
|
||||||
// Relative: slide up to raise, down to lower. A full min..max sweep
|
// 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),
|
// takes KNOB_TRAVEL_DP of vertical travel (not the 44dp knob height),
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ fun SettingsScreen(vm: AppViewModel) {
|
|||||||
) {
|
) {
|
||||||
item { PanicButton(vm) }
|
item { PanicButton(vm) }
|
||||||
item { ProjectCard(vm) }
|
item { ProjectCard(vm) }
|
||||||
|
item { BackupCard(vm) }
|
||||||
item { HelpCard() }
|
item { HelpCard() }
|
||||||
item { AudioDevicesCard(vm) }
|
item { AudioDevicesCard(vm) }
|
||||||
item { InterfaceCard(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. */
|
/** About + external links: app version, source, user guide, and support. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun HelpCard() {
|
private fun HelpCard() {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
@Composable
|
||||||
private fun DevicePicker(onDismiss: () -> Unit, onPick: (ToolboxType) -> Unit) {
|
private fun DevicePicker(onDismiss: () -> Unit, onPick: (ToolboxType) -> Unit) {
|
||||||
val c = LocalRetro.current
|
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(
|
Box(
|
||||||
Modifier.fillMaxSize().background(c.background.copy(alpha = 0.92f))
|
Modifier.fillMaxSize().background(c.background.copy(alpha = 0.92f))
|
||||||
.pointerInput(Unit) { detectTapGestures { onDismiss() } },
|
.pointerInput(Unit) { detectTapGestures { onDismiss() } },
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Column(Modifier.padding(16.dp)) {
|
Column(Modifier.fillMaxHeight().padding(16.dp).verticalScroll(rememberScrollState())) {
|
||||||
Text("SELECT DEVICE", color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 14.sp)
|
Text("SELECT DEVICE", color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 16.sp)
|
||||||
ToolboxType.entries.forEach { type ->
|
groups.forEach { (header, types) ->
|
||||||
|
Spacer(Modifier.height(16.dp)) // visual gap between groups
|
||||||
Text(
|
Text(
|
||||||
"[${type.kind.name.take(3)}] ${type.displayName}",
|
header, color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 11.sp,
|
||||||
color = c.text, fontFamily = FontFamily.Monospace, fontSize = 13.sp,
|
modifier = Modifier.padding(bottom = 2.dp),
|
||||||
|
)
|
||||||
|
types.forEach { type ->
|
||||||
|
Text(
|
||||||
|
type.displayName,
|
||||||
|
color = c.text, fontFamily = FontFamily.Monospace, fontSize = 18.sp,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.pointerInput(type) { detectTapGestures { onPick(type) } }
|
.pointerInput(type) { detectTapGestures { onPick(type) } }
|
||||||
.padding(vertical = 6.dp),
|
.padding(vertical = 8.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Auto-generated parameter editor. Presets are managed by the [PresetBar]. */
|
/** 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) {
|
when (type) {
|
||||||
ToolboxType.NES_SYNTH -> NesSynthEditor(vm, slot)
|
ToolboxType.NES_SYNTH -> NesSynthEditor(vm, slot)
|
||||||
ToolboxType.MIX_TIGHTENER -> MixTightenerEditor(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.SAMPLER -> SamplerEditor(vm, slot, samplerPad, onSelectPad = { samplerPad = it })
|
||||||
ToolboxType.SOUNDFONT -> SoundFontEditor(vm, slot)
|
ToolboxType.SOUNDFONT -> SoundFontEditor(vm, slot)
|
||||||
ToolboxType.DELAY -> DelayEditor(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
|
* single stacked column. Each [ParamControl] fills its half-width cell (an odd last
|
||||||
* param gets an empty cell beside it). */
|
* param gets an empty cell beside it). */
|
||||||
@Composable
|
@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)) {
|
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
var i = 0
|
var i = 0
|
||||||
while (i < params.size) {
|
while (i < params.size) {
|
||||||
|
|||||||
@@ -94,8 +94,8 @@ fun ArrangementRoll(vm: AppViewModel) {
|
|||||||
val glyphs = remember(measurer) { GlyphCache(measurer) }
|
val glyphs = remember(measurer) { GlyphCache(measurer) }
|
||||||
|
|
||||||
val beatWidth = 20.dp
|
val beatWidth = 20.dp
|
||||||
val muteColWidth = 34.dp // wide enough to hit comfortably from the screen edge
|
val muteColWidth = 51.dp // ~1.5× — wider, easy to hit from the screen edge
|
||||||
val laneColWidth = 68.dp // mute toggle + block number
|
val laneColWidth = 102.dp // mute toggle (51) + block number header (51), both ~1.5×
|
||||||
val rulerHeight = 18.dp
|
val rulerHeight = 18.dp
|
||||||
val beatWidthPx = with(density) { beatWidth.toPx() }
|
val beatWidthPx = with(density) { beatWidth.toPx() }
|
||||||
val rulerHeightPx = with(density) { rulerHeight.toPx() }
|
val rulerHeightPx = with(density) { rulerHeight.toPx() }
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user