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