From b7ae8cfbff040157688e37bb658f458c3044ba1d Mon Sep 17 00:00:00 2001 From: Reactorcoremeltdown Date: Sat, 18 Jul 2026 20:19:00 +0200 Subject: [PATCH] Glissando: slide across stacked keyboards to play (v0.7.3) Input moves from per-key gestures to one octave-level multi-touch handler in PianoOctave, so a finger can slide across keys and each note retriggers as it crosses a key boundary (black keys win in their upper region via geometry-aware hit-testing); multiple fingers still play independently for chords. Out-of-scale keys are skipped mid-slide. onNoteOn carries a `slid` flag so the tracker punch-in keyboard records only the initial press (a slide auditions a glide without spraying a run of notes into the pattern), while the toolbox / editor audition keyboards play every key crossed. StackedKeyboard and SlotAuditionKeyboard both migrate to the new callback API; PianoOctave now renders the keys itself. Verified on-device: a slow slide lights each in-scale key as the finger reaches it and skips out-of-scale keys. Co-Authored-By: Claude Opus 4.8 --- app/build.gradle.kts | 4 +- .../rcmd/android/sizzle/ui/StackedKeyboard.kt | 49 +++------ .../android/sizzle/ui/components/Piano.kt | 101 +++++++++++++++--- .../sizzle/ui/toolbox/SoundFontEditor.kt | 37 ++----- 4 files changed, 119 insertions(+), 72 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1de13a0..2d46949 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 = 21 - versionName = "0.7.2" + versionCode = 22 + versionName = "0.7.3" // 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/StackedKeyboard.kt b/app/src/main/java/space/rcmd/android/sizzle/ui/StackedKeyboard.kt index 04ff5e7..e263dbb 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/ui/StackedKeyboard.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/ui/StackedKeyboard.kt @@ -4,9 +4,6 @@ package space.rcmd.android.sizzle.ui import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -17,17 +14,13 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -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.sp import space.rcmd.android.sizzle.model.Pitch -import space.rcmd.android.sizzle.ui.components.PianoKey import space.rcmd.android.sizzle.ui.components.PianoOctave import space.rcmd.android.sizzle.ui.components.RepeatButton import space.rcmd.android.sizzle.ui.components.RetroButton @@ -85,8 +78,10 @@ private fun Selector(label: String, value: String, onDec: () -> Unit, onInc: () RepeatButton("+", onStep = onInc) } -/** One octave; hold a key to audition (and, in punch-in mode, record) the note. - * Out-of-scale keys (not in [inKey]) are dimmed and inert. */ +/** One octave; hold or SLIDE across keys to audition (and, in punch-in mode, record) + * notes. Out-of-scale keys (not in [inKey]) are dimmed and skipped. Sliding auditions + * every key it crosses; in punch-in mode only the initial press records, so a slide + * plays a glide without spraying a run of notes into the pattern. */ @Composable private fun KeyboardOctave( vm: AppViewModel, @@ -95,27 +90,17 @@ private fun KeyboardOctave( startMidi: Int, modifier: Modifier, ) { - PianoOctave(modifier.fillMaxWidth()) { pc, isBlack, keyMod -> - val midi = (startMidi + pc).coerceIn(Pitch.LOWEST, Pitch.HIGHEST) - val enabled = pc in inKey - val liveMidi = rememberUpdatedState(midi) - val livePunch = rememberUpdatedState(punchIn) - PianoKey( - label = Pitch.name(midi).replace("-", ""), - isBlack = isBlack, - enabled = enabled, - selected = false, - pressed = vm.isNoteHeld(midi), - modifier = if (!enabled) keyMod else keyMod.pointerInput(Unit) { - awaitEachGesture { - awaitFirstDown() - val n = liveMidi.value - if (livePunch.value) vm.punchNote(n) - vm.kbdNoteOn(n) - waitForUpOrCancellation() - vm.kbdNoteOff(n) - } - }, - ) - } + fun midiOf(pc: Int) = (startMidi + pc).coerceIn(Pitch.LOWEST, Pitch.HIGHEST) + PianoOctave( + label = { pc -> Pitch.name(midiOf(pc)).replace("-", "") }, + enabled = { pc -> pc in inKey }, + pressed = { pc -> vm.isNoteHeld(midiOf(pc)) }, + onNoteOn = { pc, slid -> + val n = midiOf(pc) + if (punchIn && !slid) vm.punchNote(n) + vm.kbdNoteOn(n) + }, + onNoteOff = { pc -> vm.kbdNoteOff(midiOf(pc)) }, + modifier = modifier.fillMaxWidth(), + ) } diff --git a/app/src/main/java/space/rcmd/android/sizzle/ui/components/Piano.kt b/app/src/main/java/space/rcmd/android/sizzle/ui/components/Piano.kt index e9e423c..9817888 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/ui/components/Piano.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/ui/components/Piano.kt @@ -5,6 +5,7 @@ package space.rcmd.android.sizzle.ui.components import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Row @@ -16,53 +17,129 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.input.pointer.PointerId +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import space.rcmd.android.sizzle.ui.theme.LocalRetro /** - * Lays out one octave as a real piano keyboard: seven white keys across the bottom + * One octave laid out as a real piano keyboard: seven white keys across the bottom * with the five black keys overlaid on top, each centred on the gap between the two * white keys it sits between (C# D# _ F# G# A#). * - * Layout-only: the [key] slot renders each key and callers attach their own gesture - * to [keyModifier] (tap-to-enter, hold-to-audition, …). Black keys are emitted last - * so they draw — and receive touches — on top of the white keys they overlap, just - * like a physical keyboard. + * Input is handled at the octave level so a finger can **slide across keys** — a + * glissando: the note under each finger retriggers as it crosses a key boundary + * (black keys win in their upper region, exactly like a physical keyboard), and + * multiple fingers play independently (chords still work). For each pitch class the + * caller supplies its [label], whether it's [enabled] (out-of-scale keys are dimmed + * and skipped), whether it's currently [pressed] (visual), and [onNoteOn] / + * [onNoteOff] to sound it — [onNoteOn]'s `slid` flag is false on the initial press + * and true when a finger slides onto the key, so callers can treat the two + * differently (e.g. only punch-record the initial press). */ @Composable fun PianoOctave( + label: (pitchClass: Int) -> String, + enabled: (pitchClass: Int) -> Boolean, + pressed: (pitchClass: Int) -> Boolean, + onNoteOn: (pitchClass: Int, slid: Boolean) -> Unit, + onNoteOff: (pitchClass: Int) -> Unit, modifier: Modifier = Modifier, blackWidthFraction: Float = 0.62f, blackHeightFraction: Float = 0.62f, - key: @Composable (pitchClass: Int, isBlack: Boolean, keyModifier: Modifier) -> Unit, ) { - BoxWithConstraints(modifier) { + // Latest callbacks, so the long-lived gesture always calls the current ones + // (octave / scale changes swap the lambdas without restarting the gesture). + val onOn by rememberUpdatedState(onNoteOn) + val onOff by rememberUpdatedState(onNoteOff) + val isEnabled by rememberUpdatedState(enabled) + + BoxWithConstraints( + modifier.pointerInput(Unit) { + awaitEachGesture { + // Per-pointer current pitch class (-1 = on a disabled key or off the + // keyboard). Independent pointers → chords; a moving pointer → glissando. + val active = HashMap() + do { + val event = awaitPointerEvent() + for (ch in event.changes) { + if (ch.pressed) { + val raw = pcAt(ch.position, size, blackWidthFraction, blackHeightFraction) + val pc = if (raw in 0..11 && isEnabled(raw)) raw else -1 + val prev = active[ch.id] + when { + prev == null -> { active[ch.id] = pc; if (pc >= 0) onOn(pc, false) } + pc != prev -> { + if (prev >= 0) onOff(prev) + if (pc >= 0) onOn(pc, true) + active[ch.id] = pc + } + } + ch.consume() + } else { + val prev = active.remove(ch.id) + if (prev != null && prev >= 0) onOff(prev) + ch.consume() + } + } + } while (active.isNotEmpty()) + } + }, + ) { val whiteW = maxWidth / 7 val blackW = whiteW * blackWidthFraction val blackH = maxHeight * blackHeightFraction // White keys: C D E F G A B, filling the width evenly. Row(Modifier.fillMaxSize()) { - for (pc in WHITE_PCS) key(pc, false, Modifier.weight(1f).fillMaxHeight()) + for (pc in WHITE_PCS) { + PianoKey(label(pc), isBlack = false, enabled = enabled(pc), selected = false, + modifier = Modifier.weight(1f).fillMaxHeight(), pressed = pressed(pc)) + } } - // Black keys, each centred on the boundary after a given white-key index. + // Black keys, each centred on the boundary after a given white-key index, + // drawn last so they sit on top of the white keys they overlap. for ((pc, afterWhite) in BLACK_PCS) { - key( - pc, true, - Modifier + PianoKey( + label(pc), isBlack = true, enabled = enabled(pc), selected = false, + modifier = Modifier .offset(x = whiteW * (afterWhite + 1) - blackW / 2) .width(blackW) .height(blackH), + pressed = pressed(pc), ) } } } +/** Hit-test a touch position to a pitch class (0..11), or -1 if off the keyboard. + * Black keys occupy their narrower upper region; below that (and between them) the + * white key at that x wins — matching how the keys are drawn. */ +private fun pcAt(pos: Offset, size: IntSize, blackWidthFraction: Float, blackHeightFraction: Float): Int { + val w = size.width.toFloat() + val h = size.height.toFloat() + if (w <= 0f || h <= 0f || pos.x < 0f || pos.x >= w || pos.y < 0f || pos.y >= h) return -1 + val whiteW = w / 7f + if (pos.y <= h * blackHeightFraction) { + val blackW = whiteW * blackWidthFraction + for ((pc, afterWhite) in BLACK_PCS) { + val bx = whiteW * (afterWhite + 1) - blackW / 2f + if (pos.x >= bx && pos.x <= bx + blackW) return pc + } + } + val wi = (pos.x / whiteW).toInt().coerceIn(0, 6) + return WHITE_PCS[wi] +} + /** * A single piano key face: light for white keys, dark for black, dimmed when * [enabled] is false and accent-filled when [selected]. [pressed] lights the key diff --git a/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/SoundFontEditor.kt b/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/SoundFontEditor.kt index 713f887..07a958f 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/SoundFontEditor.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/SoundFontEditor.kt @@ -8,11 +8,8 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -27,7 +24,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -43,7 +39,6 @@ import space.rcmd.android.sizzle.audio.SampleStore import space.rcmd.android.sizzle.model.Pitch import space.rcmd.android.sizzle.model.ToolboxSlot import space.rcmd.android.sizzle.ui.AppViewModel -import space.rcmd.android.sizzle.ui.components.PianoKey import space.rcmd.android.sizzle.ui.components.PianoOctave import space.rcmd.android.sizzle.ui.components.RepeatButton import space.rcmd.android.sizzle.ui.components.RetroButton @@ -247,25 +242,15 @@ fun SlotAuditionKeyboard(vm: AppViewModel, slot: ToolboxSlot, modifier: Modifier * keyboard's height (PianoOctave needs a bounded height to lay its keys out). */ @Composable private fun AuditionOctave(vm: AppViewModel, slotIndex: Int, inKey: Set, startMidi: Int, modifier: Modifier) { - PianoOctave(modifier.fillMaxWidth()) { pc, isBlack, keyMod -> - val midi = (startMidi + pc).coerceIn(Pitch.LOWEST, Pitch.HIGHEST) - val enabled = pc in inKey - val liveMidi = rememberUpdatedState(midi) - PianoKey( - label = Pitch.name(midi).replace("-", ""), - isBlack = isBlack, - enabled = enabled, - selected = false, - pressed = vm.isNoteHeld(midi), - modifier = if (!enabled) keyMod else keyMod.pointerInput(Unit) { - awaitEachGesture { - awaitFirstDown() - val n = liveMidi.value - vm.auditionOn(slotIndex, n) - waitForUpOrCancellation() - vm.auditionOff(n) - } - }, - ) - } + fun midiOf(pc: Int) = (startMidi + pc).coerceIn(Pitch.LOWEST, Pitch.HIGHEST) + PianoOctave( + label = { pc -> Pitch.name(midiOf(pc)).replace("-", "") }, + enabled = { pc -> pc in inKey }, + pressed = { pc -> vm.isNoteHeld(midiOf(pc)) }, + // Sliding across keys auditions each in turn (a glissando); a lifted finger + // stops its note. + onNoteOn = { pc, _ -> vm.auditionOn(slotIndex, midiOf(pc)) }, + onNoteOff = { pc -> vm.auditionOff(midiOf(pc)) }, + modifier = modifier.fillMaxWidth(), + ) }