From 71bb50c77599728540ebbf02ca33e2fd4a62f065 Mon Sep 17 00:00:00 2001 From: Reactorcoremeltdown Date: Mon, 20 Jul 2026 15:20:36 +0200 Subject: [PATCH] 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 --- app/build.gradle.kts | 4 +- .../rcmd/android/sizzle/audio/AudioEngine.kt | 18 ++ .../rcmd/android/sizzle/audio/Effects.kt | 209 ++++++++++++++++++ .../rcmd/android/sizzle/model/Toolbox.kt | 24 ++ .../rcmd/android/sizzle/ui/AppViewModel.kt | 7 + .../sizzle/ui/toolbox/GranularEditor.kt | 155 +++++++++++++ .../sizzle/ui/toolbox/TapeEngineEditor.kt | 171 ++++++++++++++ .../sizzle/ui/toolbox/ToolboxScreen.kt | 6 +- 8 files changed, 590 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/GranularEditor.kt create mode 100644 app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/TapeEngineEditor.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cc7e573..624cc0d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -22,8 +22,8 @@ android { // (android.media.midi) and AAudio low-latency audio we rely on. minSdk = 26 targetSdk = 34 - versionCode = 32 - versionName = "0.12.1" + versionCode = 33 + versionName = "0.13.0" // We provide our own instrumentation runner if/when tests are added. testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/main/java/space/rcmd/android/sizzle/audio/AudioEngine.kt b/app/src/main/java/space/rcmd/android/sizzle/audio/AudioEngine.kt index 9969e5b..0595182 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/audio/AudioEngine.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/audio/AudioEngine.kt @@ -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. diff --git a/app/src/main/java/space/rcmd/android/sizzle/audio/Effects.kt b/app/src/main/java/space/rcmd/android/sizzle/audio/Effects.kt index 925b574..f98d63f 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/audio/Effects.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/audio/Effects.kt @@ -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) + } +} diff --git a/app/src/main/java/space/rcmd/android/sizzle/model/Toolbox.kt b/app/src/main/java/space/rcmd/android/sizzle/model/Toolbox.kt index a853f2a..c888f1d 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/model/Toolbox.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/model/Toolbox.kt @@ -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 diff --git a/app/src/main/java/space/rcmd/android/sizzle/ui/AppViewModel.kt b/app/src/main/java/space/rcmd/android/sizzle/ui/AppViewModel.kt index 1df5cd3..e594939 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/ui/AppViewModel.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/ui/AppViewModel.kt @@ -239,6 +239,13 @@ class AppViewModel( /** 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. diff --git a/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/GranularEditor.kt b/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/GranularEditor.kt new file mode 100644 index 0000000..de1fcd9 --- /dev/null +++ b/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/GranularEditor.kt @@ -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 diff --git a/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/TapeEngineEditor.kt b/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/TapeEngineEditor.kt new file mode 100644 index 0000000..da507cd --- /dev/null +++ b/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/TapeEngineEditor.kt @@ -0,0 +1,171 @@ +// 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.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.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.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.rotate +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.RepeatButton +import space.rcmd.android.sizzle.ui.theme.LocalRetro +import kotlin.math.hypot +import kotlin.math.min + +/** + * Editor for the [space.rcmd.android.sizzle.model.ToolboxType.TAPE_ENGINE] effect. + * Two reels spin at the tape speed; **press and hold a reel to brake it** — the tape + * (and its pitch) dives, like putting a finger on a spinning reel, and springs back + * when you let go. Below are the four controls: 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 angle + a display speed that eases toward the slot's target speed, so the + // reels visibly dive/recover in step with the audio's own glide. Driven 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(150.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(150.dp)) { + val cx0 = size.width * REEL_L_X + val cx1 = size.width * REEL_R_X + val cy = size.height * 0.5f + val r = min(size.height * 0.42f, size.width * 0.20f) + drawReel(cx0, cy, r, angle, c.accent, c.grid, c.textDim) + drawReel(cx1, cy, r, angle, c.accent, c.grid, c.textDim) + // Tape path between the reels (two straight spans across the hubs). + drawLine(c.grid, Offset(cx0, cy - r), Offset(cx1, cy - r), strokeWidth = 2f) + drawLine(c.grid, Offset(cx0, cy + r), Offset(cx1, cy + r), strokeWidth = 2f) + } + } + 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(), + ) + + // wow / flutter / hiss as the standard two-per-row sliders. + val byKey = (slot.type ?: return).params.associateBy { it.key } + ParamGrid(vm, slot, listOf("wow", "flutter", "hiss").mapNotNull { byKey[it] }) + + // Tempo-sync division for the wow wobble, shown with its note-value label. + val divIdx = slot.float("division", 2f).toInt().coerceIn(0, TimeDivision.entries.lastIndex) + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Wow Sync", color = c.textDim, fontFamily = mono, fontSize = 11.sp) + RepeatButton("-") { setDiv(vm, slot, divIdx - 1) } + Text( + TimeDivision.fromIndex(divIdx).label, color = c.accent, fontFamily = mono, + fontSize = 13.sp, textAlign = TextAlign.Center, modifier = Modifier.width(48.dp), + ) + RepeatButton("+") { setDiv(vm, slot, divIdx + 1) } + } + } +} + +private fun setDiv(vm: AppViewModel, slot: ToolboxSlot, idx: Int) { + slot.set("division", idx.coerceIn(0, TimeDivision.entries.lastIndex).toFloat()) + 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 * 0.5f + val r = min(h * 0.42f, 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 reel: rim, hub, and spokes rotated by [angle] degrees. */ +private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawReel( + cx: Float, cy: Float, r: Float, angle: Float, + accent: androidx.compose.ui.graphics.Color, + rim: androidx.compose.ui.graphics.Color, + hub: androidx.compose.ui.graphics.Color, +) { + val center = Offset(cx, cy) + drawCircle(rim, radius = r, center = center, style = Stroke(width = 3f)) // outer flange + drawCircle(accent.copy(alpha = 0.12f), radius = r * 0.82f, center = center) // wound tape + rotate(angle, center) { + for (k in 0 until SPOKES) { + rotate(k * (360f / SPOKES), center) { + drawLine(accent, Offset(cx, cy), Offset(cx, cy - r * 0.78f), strokeWidth = 4f) + } + } + } + drawCircle(hub, radius = r * 0.22f, center = center) // bobbin hub + drawCircle(accent, radius = r * 0.22f, center = center, style = Stroke(width = 2f)) +} + +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 SPOKES = 6 +private const val REEL_L_X = 0.30f +private const val REEL_R_X = 0.70f diff --git a/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/ToolboxScreen.kt b/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/ToolboxScreen.kt index e8ba813..ebb6812 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/ToolboxScreen.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/ToolboxScreen.kt @@ -251,7 +251,7 @@ private fun DevicePicker(onDismiss: () -> Unit, onPick: (ToolboxType) -> Unit) { .pointerInput(Unit) { detectTapGestures { onDismiss() } }, 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) ToolboxType.entries.forEach { type -> Text( @@ -310,6 +310,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 +340,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) { +internal fun ParamGrid(vm: AppViewModel, slot: ToolboxSlot, params: List) { Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) { var i = 0 while (i < params.size) {