Redraw Tape Engine as a real tape transport (v0.13.2)

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 <noreply@anthropic.com>
This commit is contained in:
Reactorcoremeltdown
2026-07-20 15:48:31 +02:00
parent 71bb50c775
commit d43112f296
3 changed files with 138 additions and 58 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 = 33 versionCode = 35
versionName = "0.13.0" versionName = "0.13.2"
// 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

@@ -21,6 +21,7 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import space.rcmd.android.sizzle.ui.theme.LocalRetro import space.rcmd.android.sizzle.ui.theme.LocalRetro
@@ -51,6 +52,7 @@ fun Knob(
valueText: String, valueText: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
default: Float = min, default: Float = min,
knobSize: Dp = 44.dp,
onChange: (Float) -> Unit, onChange: (Float) -> Unit,
) { ) {
val c = LocalRetro.current val c = LocalRetro.current
@@ -71,7 +73,7 @@ fun Knob(
) )
Canvas( Canvas(
Modifier Modifier
.size(44.dp) .size(knobSize)
.pointerInput(min, max) { .pointerInput(min, max) {
// Relative: slide up to raise, down to lower. A full min..max sweep // Relative: slide up to raise, down to lower. A full min..max sweep
// takes KNOB_TRAVEL_DP of vertical travel (not the 44dp knob height), // takes KNOB_TRAVEL_DP of vertical travel (not the 44dp knob height),

View File

@@ -12,7 +12,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -23,9 +23,13 @@ import androidx.compose.runtime.withFrameNanos
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset 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.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@@ -34,17 +38,21 @@ import androidx.compose.ui.unit.sp
import space.rcmd.android.sizzle.model.TimeDivision 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.ui.AppViewModel 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 space.rcmd.android.sizzle.ui.theme.LocalRetro
import kotlin.math.cos
import kotlin.math.hypot import kotlin.math.hypot
import kotlin.math.min import kotlin.math.min
import kotlin.math.roundToInt
import kotlin.math.sin
/** /**
* Editor for the [space.rcmd.android.sizzle.model.ToolboxType.TAPE_ENGINE] effect. * 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 * drawn as a reel-to-reel tape transport: two NAB reels (metal flange with three
* (and its pitch) dives, like putting a finger on a spinning reel, and springs back * rounded-triangular bobbin holes) feeding tape down to a playhead block between them
* when you let go. Below are the four controls: wow, flutter, hiss, and the tempo-sync * under tension. **Press and hold a reel to brake it** — the tape (and its pitch)
* division for the wow wobble. * 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 @Composable
fun TapeEngineEditor(vm: AppViewModel, slot: ToolboxSlot) { fun TapeEngineEditor(vm: AppViewModel, slot: ToolboxSlot) {
@@ -52,8 +60,8 @@ fun TapeEngineEditor(vm: AppViewModel, slot: ToolboxSlot) {
@Suppress("UNUSED_VARIABLE") val rev = vm.revision @Suppress("UNUSED_VARIABLE") val rev = vm.revision
val mono = FontFamily.Monospace val mono = FontFamily.Monospace
// Rotation angle + a display speed that eases toward the slot's target speed, so the // Rotation + a display speed eased toward the slot's target, so the reels dive and
// reels visibly dive/recover in step with the audio's own glide. Driven per frame. // recover in step with the audio's own glide. Advanced once per frame.
var angle by remember { mutableFloatStateOf(0f) } var angle by remember { mutableFloatStateOf(0f) }
var displaySpeed by remember { mutableFloatStateOf(1f) } var displaySpeed by remember { mutableFloatStateOf(1f) }
LaunchedEffect(slot.index) { LaunchedEffect(slot.index) {
@@ -75,7 +83,7 @@ fun TapeEngineEditor(vm: AppViewModel, slot: ToolboxSlot) {
Box( Box(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(150.dp) .height(180.dp)
.background(c.surface) .background(c.surface)
// Press a reel to brake the tape; release to let it spin back up. // Press a reel to brake the tape; release to let it spin back up.
.pointerInput(slot.index) { .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 cx0 = size.width * REEL_L_X
val cx1 = size.width * REEL_R_X val cx1 = size.width * REEL_R_X
val cy = size.height * 0.5f val cy = size.height * REEL_CY
val r = min(size.height * 0.42f, size.width * 0.20f) val r = min(size.height * 0.30f, 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) // Playhead block between + below the reels.
// Tape path between the reels (two straight spans across the hubs). val hw = (cx1 - cx0) * 0.46f
drawLine(c.grid, Offset(cx0, cy - r), Offset(cx1, cy - r), strokeWidth = 2f) val hh = size.height * 0.13f
drawLine(c.grid, Offset(cx0, cy + r), Offset(cx1, cy + r), strokeWidth = 2f) 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( Text(
@@ -108,64 +144,106 @@ fun TapeEngineEditor(vm: AppViewModel, slot: ToolboxSlot) {
textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(),
) )
// wow / flutter / hiss as the standard two-per-row sliders. // Vintage round knobs, all in a row (each weighted so all four fit).
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) val divIdx = slot.float("division", 2f).toInt().coerceIn(0, TimeDivision.entries.lastIndex)
Row( Row(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth().padding(top = 2.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.Top,
) { ) {
Text("Wow Sync", color = c.textDim, fontFamily = mono, fontSize = 11.sp) PercentKnob(vm, slot, "wow", "WOW", 0.15f, Modifier.weight(1f))
RepeatButton("-") { setDiv(vm, slot, divIdx - 1) } PercentKnob(vm, slot, "flutter", "FLUTTER", 0.15f, Modifier.weight(1f))
Text( PercentKnob(vm, slot, "hiss", "HISS", 0.10f, Modifier.weight(1f))
TimeDivision.fromIndex(divIdx).label, color = c.accent, fontFamily = mono, Knob(
fontSize = 13.sp, textAlign = TextAlign.Center, modifier = Modifier.width(48.dp), 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) { /** A 0..1 knob showing its value as a percentage, writing [key] on the slot. */
slot.set("division", idx.coerceIn(0, TimeDivision.entries.lastIndex).toFloat()) @Composable
vm.bumpForToolbar() 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). */ /** 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 { private fun overReel(off: Offset, w: Float, h: Float): Boolean {
val cy = h * 0.5f val cy = h * REEL_CY
val r = min(h * 0.42f, w * 0.20f) val r = min(h * 0.30f, w * 0.20f)
return hypot(off.x - w * REEL_L_X, off.y - cy) <= r || return hypot(off.x - w * REEL_L_X, off.y - cy) <= r ||
hypot(off.x - w * REEL_R_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. */ /** Draw one NAB reel: metal flange, three rotating rounded-triangular bobbin holes,
private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawReel( * and the hub in the centre. */
private fun DrawScope.drawReel(
cx: Float, cy: Float, r: Float, angle: Float, cx: Float, cy: Float, r: Float, angle: Float,
accent: androidx.compose.ui.graphics.Color, accent: Color, flange: Color, hole: Color, rim: Color,
rim: androidx.compose.ui.graphics.Color,
hub: androidx.compose.ui.graphics.Color,
) { ) {
val center = Offset(cx, cy) val center = Offset(cx, cy)
drawCircle(rim, radius = r, center = center, style = Stroke(width = 3f)) // outer flange drawCircle(flange, radius = r, center = center) // metal flange
drawCircle(accent.copy(alpha = 0.12f), radius = r * 0.82f, center = center) // wound tape for (k in 0 until 3) drawPath(bobbinHole(cx, cy, r, angle + k * 120f), hole) // triangular cutouts
rotate(angle, center) { drawCircle(hole, radius = r * 0.26f, center = center) // bobbin centre (open)
for (k in 0 until SPOKES) { drawCircle(accent, radius = r * 0.26f, center = center, style = Stroke(width = 3f))
rotate(k * (360f / SPOKES), center) { drawCircle(accent, radius = r * 0.14f, center = center, style = Stroke(width = 2f))
drawLine(accent, Offset(cx, cy), Offset(cx, cy - r * 0.78f), strokeWidth = 4f) drawCircle(rim, radius = r, center = center, style = Stroke(width = 2.5f)) // flange edge
}
}
}
drawCircle(hub, radius = r * 0.22f, center = center) // bobbin hub
drawCircle(accent, radius = r * 0.22f, center = center, style = Stroke(width = 2f))
} }
/** 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 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 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_L_X = 0.30f
private const val REEL_R_X = 0.70f private const val REEL_R_X = 0.70f
private const val REEL_CY = 0.38f // reel centre as a fraction of the canvas height