Add Tape Engine and Granular Glitch visual effects (v0.13.0)

Tape Engine: a varispeed reel-to-reel. Audio runs through a ring buffer read at a
glided 'speed'; the editor draws two spinning reels you press-and-hold to brake,
diving the pitch like a finger on a slowing tape. Wow (slow, tempo-synced via
division) and flutter (fast) wobble the rate; hiss adds tape noise.

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Reactorcoremeltdown
2026-07-20 15:20:36 +02:00
parent ef13a46026
commit 71bb50c775
8 changed files with 590 additions and 4 deletions

View File

@@ -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 = 32 versionCode = 33
versionName = "0.12.1" versionName = "0.13.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"

View File

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

View File

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

View File

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

View File

@@ -239,6 +239,13 @@ class AppViewModel(
/** 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.

View File

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

View File

@@ -0,0 +1,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

View File

@@ -251,7 +251,7 @@ private fun DevicePicker(onDismiss: () -> Unit, onPick: (ToolboxType) -> Unit) {
.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 = 14.sp)
ToolboxType.entries.forEach { type -> ToolboxType.entries.forEach { type ->
Text( Text(
@@ -310,6 +310,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 +340,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) {