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

View File

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

View File

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