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:
@@ -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"
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user