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:
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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
|
||||
@@ -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<ParamSpec>) {
|
||||
internal fun ParamGrid(vm: AppViewModel, slot: ToolboxSlot, params: List<ParamSpec>) {
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
var i = 0
|
||||
while (i < params.size) {
|
||||
|
||||
Reference in New Issue
Block a user