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