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 <noreply@anthropic.com>
This commit is contained in:
Reactorcoremeltdown
2026-07-18 20:19:00 +02:00
parent 3c0b93855b
commit b7ae8cfbff
4 changed files with 119 additions and 72 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 = 21 versionCode = 22
versionName = "0.7.2" versionName = "0.7.3"
// 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

@@ -4,9 +4,6 @@
package space.rcmd.android.sizzle.ui package space.rcmd.android.sizzle.ui
import androidx.compose.foundation.background 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.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -17,17 +14,13 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
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.rememberUpdatedState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
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.sp import androidx.compose.ui.unit.sp
import space.rcmd.android.sizzle.model.Pitch 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.PianoOctave
import space.rcmd.android.sizzle.ui.components.RepeatButton import space.rcmd.android.sizzle.ui.components.RepeatButton
import space.rcmd.android.sizzle.ui.components.RetroButton 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) RepeatButton("+", onStep = onInc)
} }
/** One octave; hold a key to audition (and, in punch-in mode, record) the note. /** One octave; hold or SLIDE across keys to audition (and, in punch-in mode, record)
* Out-of-scale keys (not in [inKey]) are dimmed and inert. */ * 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 @Composable
private fun KeyboardOctave( private fun KeyboardOctave(
vm: AppViewModel, vm: AppViewModel,
@@ -95,27 +90,17 @@ private fun KeyboardOctave(
startMidi: Int, startMidi: Int,
modifier: Modifier, modifier: Modifier,
) { ) {
PianoOctave(modifier.fillMaxWidth()) { pc, isBlack, keyMod -> fun midiOf(pc: Int) = (startMidi + pc).coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
val midi = (startMidi + pc).coerceIn(Pitch.LOWEST, Pitch.HIGHEST) PianoOctave(
val enabled = pc in inKey label = { pc -> Pitch.name(midiOf(pc)).replace("-", "") },
val liveMidi = rememberUpdatedState(midi) enabled = { pc -> pc in inKey },
val livePunch = rememberUpdatedState(punchIn) pressed = { pc -> vm.isNoteHeld(midiOf(pc)) },
PianoKey( onNoteOn = { pc, slid ->
label = Pitch.name(midi).replace("-", ""), val n = midiOf(pc)
isBlack = isBlack, if (punchIn && !slid) vm.punchNote(n)
enabled = enabled, vm.kbdNoteOn(n)
selected = false, },
pressed = vm.isNoteHeld(midi), onNoteOff = { pc -> vm.kbdNoteOff(midiOf(pc)) },
modifier = if (!enabled) keyMod else keyMod.pointerInput(Unit) { modifier = modifier.fillMaxWidth(),
awaitEachGesture { )
awaitFirstDown()
val n = liveMidi.value
if (livePunch.value) vm.punchNote(n)
vm.kbdNoteOn(n)
waitForUpOrCancellation()
vm.kbdNoteOff(n)
}
},
)
}
} }

View File

@@ -5,6 +5,7 @@ package space.rcmd.android.sizzle.ui.components
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row 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.foundation.layout.width
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.rememberUpdatedState
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.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape 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.text.font.FontFamily
import androidx.compose.ui.unit.IntSize
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
/** /**
* 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 * 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#). * white keys it sits between (C# D# _ F# G# A#).
* *
* Layout-only: the [key] slot renders each key and callers attach their own gesture * Input is handled at the octave level so a finger can **slide across keys** — a
* to [keyModifier] (tap-to-enter, hold-to-audition, …). Black keys are emitted last * glissando: the note under each finger retriggers as it crosses a key boundary
* so they draw — and receive touches — on top of the white keys they overlap, just * (black keys win in their upper region, exactly like a physical keyboard), and
* like a physical keyboard. * 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 @Composable
fun PianoOctave( 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, modifier: Modifier = Modifier,
blackWidthFraction: Float = 0.62f, blackWidthFraction: Float = 0.62f,
blackHeightFraction: 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<PointerId, Int>()
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 whiteW = maxWidth / 7
val blackW = whiteW * blackWidthFraction val blackW = whiteW * blackWidthFraction
val blackH = maxHeight * blackHeightFraction val blackH = maxHeight * blackHeightFraction
// White keys: C D E F G A B, filling the width evenly. // White keys: C D E F G A B, filling the width evenly.
Row(Modifier.fillMaxSize()) { 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) { for ((pc, afterWhite) in BLACK_PCS) {
key( PianoKey(
pc, true, label(pc), isBlack = true, enabled = enabled(pc), selected = false,
Modifier modifier = Modifier
.offset(x = whiteW * (afterWhite + 1) - blackW / 2) .offset(x = whiteW * (afterWhite + 1) - blackW / 2)
.width(blackW) .width(blackW)
.height(blackH), .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 * 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 * [enabled] is false and accent-filled when [selected]. [pressed] lights the key

View File

@@ -8,11 +8,8 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border 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.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -27,7 +24,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.Pitch
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.PianoKey
import space.rcmd.android.sizzle.ui.components.PianoOctave import space.rcmd.android.sizzle.ui.components.PianoOctave
import space.rcmd.android.sizzle.ui.components.RepeatButton import space.rcmd.android.sizzle.ui.components.RepeatButton
import space.rcmd.android.sizzle.ui.components.RetroButton 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). */ * keyboard's height (PianoOctave needs a bounded height to lay its keys out). */
@Composable @Composable
private fun AuditionOctave(vm: AppViewModel, slotIndex: Int, inKey: Set<Int>, startMidi: Int, modifier: Modifier) { private fun AuditionOctave(vm: AppViewModel, slotIndex: Int, inKey: Set<Int>, startMidi: Int, modifier: Modifier) {
PianoOctave(modifier.fillMaxWidth()) { pc, isBlack, keyMod -> fun midiOf(pc: Int) = (startMidi + pc).coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
val midi = (startMidi + pc).coerceIn(Pitch.LOWEST, Pitch.HIGHEST) PianoOctave(
val enabled = pc in inKey label = { pc -> Pitch.name(midiOf(pc)).replace("-", "") },
val liveMidi = rememberUpdatedState(midi) enabled = { pc -> pc in inKey },
PianoKey( pressed = { pc -> vm.isNoteHeld(midiOf(pc)) },
label = Pitch.name(midi).replace("-", ""), // Sliding across keys auditions each in turn (a glissando); a lifted finger
isBlack = isBlack, // stops its note.
enabled = enabled, onNoteOn = { pc, _ -> vm.auditionOn(slotIndex, midiOf(pc)) },
selected = false, onNoteOff = { pc -> vm.auditionOff(midiOf(pc)) },
pressed = vm.isNoteHeld(midi), modifier = modifier.fillMaxWidth(),
modifier = if (!enabled) keyMod else keyMod.pointerInput(Unit) { )
awaitEachGesture {
awaitFirstDown()
val n = liveMidi.value
vm.auditionOn(slotIndex, n)
waitForUpOrCancellation()
vm.auditionOff(n)
}
},
)
}
} }