From d43112f296c3fb581759be6f731014de37d4be00 Mon Sep 17 00:00:00 2001 From: Reactorcoremeltdown Date: Mon, 20 Jul 2026 15:48:31 +0200 Subject: [PATCH] Redraw Tape Engine as a real tape transport (v0.13.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reels are now NAB-style: a metal flange with three rotating rounded-triangular bobbin holes and a hub, instead of chariot-wheel spokes. Tape runs under tension as straight lines from each reel down to a rounded-rectangle playhead between and below them (the top tape line is gone). The four controls are vintage round knobs in a row — wow, flutter, hiss, wow-sync — replacing the sliders/stepper. Each knob is weighted so all four are visible (the shared Knob's internal fillMaxWidth text had let the first one swallow the row), and Knob gains an optional knobSize (default 44 dp, so other editors are unchanged) which the tape knobs set to 58 dp. Co-Authored-By: Claude Opus 4.8 --- app/build.gradle.kts | 4 +- .../rcmd/android/sizzle/ui/components/Knob.kt | 4 +- .../sizzle/ui/toolbox/TapeEngineEditor.kt | 188 +++++++++++++----- 3 files changed, 138 insertions(+), 58 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 624cc0d..486fe8a 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 = 33 - versionName = "0.13.0" + versionCode = 35 + versionName = "0.13.2" // 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/ui/components/Knob.kt b/app/src/main/java/space/rcmd/android/sizzle/ui/components/Knob.kt index 4971777..684acc1 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/ui/components/Knob.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/ui/components/Knob.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import space.rcmd.android.sizzle.ui.theme.LocalRetro @@ -51,6 +52,7 @@ fun Knob( valueText: String, modifier: Modifier = Modifier, default: Float = min, + knobSize: Dp = 44.dp, onChange: (Float) -> Unit, ) { val c = LocalRetro.current @@ -71,7 +73,7 @@ fun Knob( ) Canvas( Modifier - .size(44.dp) + .size(knobSize) .pointerInput(min, max) { // Relative: slide up to raise, down to lower. A full min..max sweep // takes KNOB_TRAVEL_DP of vertical travel (not the 44dp knob height), 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 index da507cd..78b57e6 100644 --- 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 @@ -12,7 +12,7 @@ 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.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -23,9 +23,13 @@ import androidx.compose.runtime.withFrameNanos import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign @@ -34,17 +38,21 @@ 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.components.Knob import space.rcmd.android.sizzle.ui.theme.LocalRetro +import kotlin.math.cos import kotlin.math.hypot import kotlin.math.min +import kotlin.math.roundToInt +import kotlin.math.sin /** - * Editor for the [space.rcmd.android.sizzle.model.ToolboxType.TAPE_ENGINE] effect. - * 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. + * Editor for the [space.rcmd.android.sizzle.model.ToolboxType.TAPE_ENGINE] effect, + * drawn as a reel-to-reel tape transport: two NAB reels (metal flange with three + * rounded-triangular bobbin holes) feeding tape down to a playhead block between them + * under tension. **Press and hold a reel to brake it** — the tape (and its pitch) + * dives, then springs back when released. Below is a row of vintage round knobs: wow, + * flutter, hiss, and the tempo-sync division for the wow wobble. */ @Composable fun TapeEngineEditor(vm: AppViewModel, slot: ToolboxSlot) { @@ -52,8 +60,8 @@ fun TapeEngineEditor(vm: AppViewModel, slot: ToolboxSlot) { @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. + // Rotation + a display speed eased toward the slot's target, so the reels dive and + // recover in step with the audio's own glide. Advanced once per frame. var angle by remember { mutableFloatStateOf(0f) } var displaySpeed by remember { mutableFloatStateOf(1f) } LaunchedEffect(slot.index) { @@ -75,7 +83,7 @@ fun TapeEngineEditor(vm: AppViewModel, slot: ToolboxSlot) { Box( Modifier .fillMaxWidth() - .height(150.dp) + .height(180.dp) .background(c.surface) // Press a reel to brake the tape; release to let it spin back up. .pointerInput(slot.index) { @@ -90,16 +98,44 @@ fun TapeEngineEditor(vm: AppViewModel, slot: ToolboxSlot) { ) }, ) { - Canvas(Modifier.fillMaxWidth().height(150.dp)) { + Canvas(Modifier.fillMaxWidth().height(180.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) + val cy = size.height * REEL_CY + val r = min(size.height * 0.30f, size.width * 0.20f) + + // Playhead block between + below the reels. + val hw = (cx1 - cx0) * 0.46f + val hh = size.height * 0.13f + val hx = size.width * 0.5f + val hy = cy + r + size.height * 0.14f + val tl = Offset(hx - hw / 2f, hy - hh / 2f) + val tr = Offset(hx + hw / 2f, hy - hh / 2f) + + // Tensioned tape: from each reel's bottom straight down to the playhead + // and across its top. No line over the top of the reels. + val p0 = Offset(cx0, cy + r) + val p1 = Offset(cx1, cy + r) + val tape = c.textDim + drawLine(tape, p0, tl, strokeWidth = 2.5f) + drawLine(tape, tl, tr, strokeWidth = 2.5f) + drawLine(tape, tr, p1, strokeWidth = 2.5f) + + // Playhead (rounded rectangle) drawn over the tape it tensions. + drawRoundRect( + color = c.grid, topLeft = Offset(hx - hw / 2f, hy - hh / 2f), + size = Size(hw, hh), cornerRadius = CornerRadius(hh * 0.45f, hh * 0.45f), + ) + drawRoundRect( + color = c.accent, topLeft = Offset(hx - hw / 2f, hy - hh / 2f), + size = Size(hw, hh), cornerRadius = CornerRadius(hh * 0.45f, hh * 0.45f), + style = Stroke(width = 2f), + ) + // Head gap slit in the middle of the playhead. + drawLine(c.accent, Offset(hx, hy - hh / 2f + 3f), Offset(hx, hy + hh / 2f - 3f), strokeWidth = 2f) + + drawReel(cx0, cy, r, angle, c.accent, c.grid, c.surface, c.textDim) + drawReel(cx1, cy, r, angle, c.accent, c.grid, c.surface, c.textDim) } } Text( @@ -108,64 +144,106 @@ fun TapeEngineEditor(vm: AppViewModel, slot: ToolboxSlot) { 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. + // Vintage round knobs, all in a row (each weighted so all four fit). val divIdx = slot.float("division", 2f).toInt().coerceIn(0, TimeDivision.entries.lastIndex) Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, + Modifier.fillMaxWidth().padding(top = 2.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.Top, ) { - 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), + PercentKnob(vm, slot, "wow", "WOW", 0.15f, Modifier.weight(1f)) + PercentKnob(vm, slot, "flutter", "FLUTTER", 0.15f, Modifier.weight(1f)) + PercentKnob(vm, slot, "hiss", "HISS", 0.10f, Modifier.weight(1f)) + Knob( + label = "WOW SYNC", + value = divIdx.toFloat(), min = 0f, max = TimeDivision.entries.lastIndex.toFloat(), + valueText = TimeDivision.fromIndex(divIdx).label, default = 2f, + modifier = Modifier.weight(1f), knobSize = KNOB_SIZE, + onChange = { v -> + slot.set("division", v.roundToInt().coerceIn(0, TimeDivision.entries.lastIndex).toFloat()) + vm.bumpForToolbar() + }, ) - 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() +/** A 0..1 knob showing its value as a percentage, writing [key] on the slot. */ +@Composable +private fun PercentKnob(vm: AppViewModel, slot: ToolboxSlot, key: String, label: String, default: Float, modifier: Modifier) { + @Suppress("UNUSED_VARIABLE") val rev = vm.revision + val value = slot.float(key, default).coerceIn(0f, 1f) + Knob( + label = label, value = value, min = 0f, max = 1f, + valueText = "${(value * 100).roundToInt()}%", default = default, + modifier = modifier, knobSize = KNOB_SIZE, + onChange = { slot.set(key, it.coerceIn(0f, 1f)); vm.bumpForToolbar() }, + ) } /** True if [off] is within either reel's circle (used to gate the brake gesture). */ private fun overReel(off: Offset, w: Float, h: Float): Boolean { - val cy = h * 0.5f - val r = min(h * 0.42f, w * 0.20f) + val cy = h * REEL_CY + val r = min(h * 0.30f, w * 0.20f) return hypot(off.x - w * REEL_L_X, off.y - cy) <= r || hypot(off.x - w * REEL_R_X, off.y - cy) <= r } -/** Draw one reel: rim, hub, and spokes rotated by [angle] degrees. */ -private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawReel( +/** Draw one NAB reel: metal flange, three rotating rounded-triangular bobbin holes, + * and the hub in the centre. */ +private fun DrawScope.drawReel( cx: Float, cy: Float, r: Float, angle: Float, - accent: androidx.compose.ui.graphics.Color, - rim: androidx.compose.ui.graphics.Color, - hub: androidx.compose.ui.graphics.Color, + accent: Color, flange: Color, hole: Color, rim: 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)) + drawCircle(flange, radius = r, center = center) // metal flange + for (k in 0 until 3) drawPath(bobbinHole(cx, cy, r, angle + k * 120f), hole) // triangular cutouts + drawCircle(hole, radius = r * 0.26f, center = center) // bobbin centre (open) + drawCircle(accent, radius = r * 0.26f, center = center, style = Stroke(width = 3f)) + drawCircle(accent, radius = r * 0.14f, center = center, style = Stroke(width = 2f)) + drawCircle(rim, radius = r, center = center, style = Stroke(width = 2.5f)) // flange edge } +/** One inward-pointing rounded-triangular bobbin hole at [thetaDeg] around the reel. */ +private fun bobbinHole(cx: Float, cy: Float, r: Float, thetaDeg: Float): Path { + val apex = polar(cx, cy, r * 0.36f, thetaDeg) // point toward the hub + val baseL = polar(cx, cy, r * 0.82f, thetaDeg - 34f) // wide base toward the rim + val baseR = polar(cx, cy, r * 0.82f, thetaDeg + 34f) + return roundedTri(apex, baseL, baseR, r * 0.10f) +} + +/** A triangle through [v0],[v1],[v2] with corners rounded to radius [corner]. */ +private fun roundedTri(v0: Offset, v1: Offset, v2: Offset, corner: Float): Path { + val v = arrayOf(v0, v1, v2) + val path = Path() + for (i in 0 until 3) { + val curr = v[i] + val prev = v[(i + 2) % 3] + val next = v[(i + 1) % 3] + val a = curr + toward(curr, prev, corner) // approach point on the incoming edge + val b = curr + toward(curr, next, corner) // leave point on the outgoing edge + if (i == 0) path.moveTo(a.x, a.y) else path.lineTo(a.x, a.y) + path.quadraticBezierTo(curr.x, curr.y, b.x, b.y) + } + path.close() + return path +} + +/** Vector of length [len] from [from] toward [to]. */ +private fun toward(from: Offset, to: Offset, len: Float): Offset { + val dx = to.x - from.x; val dy = to.y - from.y + val d = hypot(dx, dy).coerceAtLeast(1e-3f) + return Offset(dx / d * len, dy / d * len) +} + +private fun polar(cx: Float, cy: Float, rad: Float, deg: Float): Offset { + val a = Math.toRadians(deg.toDouble()) + return Offset(cx + rad * cos(a).toFloat(), cy + rad * sin(a).toFloat()) +} + +private val KNOB_SIZE = 58.dp // a touch bigger than the default 44 dp knob private const val DEG_PER_FRAME = 6f // ~1 rev/sec at 60 fps and full speed private const val BRAKE_SPEED = 0.5f // target while a reel is held (≈ one octave down) -private const val SPOKES = 6 private const val REEL_L_X = 0.30f private const val REEL_R_X = 0.70f +private const val REEL_CY = 0.38f // reel centre as a fraction of the canvas height