Scale-filter toolbox keyboards; pin sampler pads at keyboard height (v0.7.2)

- Scale/root filtering on the Toolbox audition keyboards, matching the tracker
  punch-in keyboard: out-of-scale keys are dimmed and inert (Chromatic = all
  playable). StackedKeyboard now filters in both punch-in and audition mode,
  and SlotAuditionKeyboard (synth / SF2 editors) gained the same filter.
- Sampler editor restructured so the 16-pad grid is pinned at the bottom at the
  same height the audition keyboards use, consistent across every instrument
  editor. The per-pad volume faders moved to a second swipe page (waveform on
  page 1, volumes on page 2, with dot pagination), and pad cells now fill the
  pinned region instead of a fixed 46dp. Selected-pad state is hoisted into
  ParamEditor so the detail area and the pinned grid stay in sync.

Verified on-device: audition keyboard dims to F harmonic minor; sampler pads
fill the pinned bottom region with volumes on the swipe page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Reactorcoremeltdown
2026-07-18 20:05:53 +02:00
parent c4f4cf34c0
commit 3c0b93855b
5 changed files with 130 additions and 61 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 = 20 versionCode = 21
versionName = "0.7.1" versionName = "0.7.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"

View File

@@ -47,14 +47,10 @@ private const val VEL_STEP = 8
fun StackedKeyboard(vm: AppViewModel, punchIn: Boolean, modifier: Modifier) { fun StackedKeyboard(vm: AppViewModel, punchIn: Boolean, modifier: Modifier) {
val c = LocalRetro.current val c = LocalRetro.current
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // refresh key filtering on scale/root change @Suppress("UNUSED_VARIABLE") val rev = vm.revision // refresh key filtering on scale/root change
// In punch-in mode only in-scale keys are playable (Chromatic yields all twelve); // Only in-scale keys are playable on BOTH the tracker punch-in and the toolbox
// the toolbox audition keyboard stays fully chromatic (null = no filter). // audition keyboard (Chromatic yields all twelve = every key stays enabled).
val inKey: Set<Int>? = if (punchIn) { val root = vm.project.rootNote
val root = vm.project.rootNote val inKey: Set<Int> = vm.project.scale.intervals.map { (it + root) % 12 }.toSet()
vm.project.scale.intervals.map { (it + root) % 12 }.toSet()
} else {
null
}
Column( Column(
modifier.fillMaxWidth().background(c.background).padding(6.dp), modifier.fillMaxWidth().background(c.background).padding(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
@@ -90,18 +86,18 @@ private fun Selector(label: String, value: String, onDec: () -> Unit, onInc: ()
} }
/** One octave; hold a key to audition (and, in punch-in mode, record) the note. /** One octave; hold a key to audition (and, in punch-in mode, record) the note.
* When [inKey] is non-null, out-of-scale keys are dimmed and inert. */ * Out-of-scale keys (not in [inKey]) are dimmed and inert. */
@Composable @Composable
private fun KeyboardOctave( private fun KeyboardOctave(
vm: AppViewModel, vm: AppViewModel,
punchIn: Boolean, punchIn: Boolean,
inKey: Set<Int>?, inKey: Set<Int>,
startMidi: Int, startMidi: Int,
modifier: Modifier, modifier: Modifier,
) { ) {
PianoOctave(modifier.fillMaxWidth()) { pc, isBlack, keyMod -> PianoOctave(modifier.fillMaxWidth()) { pc, isBlack, keyMod ->
val midi = (startMidi + pc).coerceIn(Pitch.LOWEST, Pitch.HIGHEST) val midi = (startMidi + pc).coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
val enabled = inKey == null || pc in inKey val enabled = pc in inKey
val liveMidi = rememberUpdatedState(midi) val liveMidi = rememberUpdatedState(midi)
val livePunch = rememberUpdatedState(punchIn) val livePunch = rememberUpdatedState(punchIn)
PianoKey( PianoKey(

View File

@@ -8,6 +8,7 @@ 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.clickable
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.horizontalScroll import androidx.compose.foundation.horizontalScroll
@@ -15,9 +16,16 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -26,6 +34,7 @@ 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.rememberCoroutineScope
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
@@ -44,6 +53,7 @@ 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.RetroButton import space.rcmd.android.sizzle.ui.components.RetroButton
import space.rcmd.android.sizzle.ui.theme.LocalRetro import space.rcmd.android.sizzle.ui.theme.LocalRetro
import kotlinx.coroutines.launch
import kotlin.math.abs import kotlin.math.abs
/** /**
@@ -58,13 +68,18 @@ import kotlin.math.abs
* volume lives in the fader bank. When the sequencer plays a pad's note the sample * volume lives in the fader bank. When the sequencer plays a pad's note the sample
* fires at its natural pitch. * fires at its natural pitch.
*/ */
/**
* The Sampler's detail area (selected-pad source buttons + a swipeable waveform /
* per-pad-volume view). The playable 16-pad grid itself is rendered separately by
* [SamplerPadBank], pinned at the bottom of the editor at the same height as the
* other instruments' audition keyboards; [pad] selection is hoisted so both agree.
*/
@Composable @Composable
fun SamplerEditor(vm: AppViewModel, slot: ToolboxSlot) { fun SamplerEditor(vm: AppViewModel, slot: ToolboxSlot, pad: Int, onSelectPad: (Int) -> Unit) {
val c = LocalRetro.current val c = LocalRetro.current
val context = LocalContext.current val context = LocalContext.current
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // subscribe: pads/waveform refresh @Suppress("UNUSED_VARIABLE") val rev = vm.revision // subscribe: pads/waveform refresh
var pad by remember(slot.index) { mutableIntStateOf(0) }
var importError by remember(slot.index) { mutableStateOf<String?>(null) } var importError by remember(slot.index) { mutableStateOf<String?>(null) }
val sampleId = slot.string(SamplerPads.key(pad)) val sampleId = slot.string(SamplerPads.key(pad))
val sliceStart = slot.float(SamplerPads.sliceStartKey(pad), 0f) val sliceStart = slot.float(SamplerPads.sliceStartKey(pad), 0f)
@@ -112,14 +127,73 @@ fun SamplerEditor(vm: AppViewModel, slot: ToolboxSlot) {
} }
} }
// ----- Waveform + draggable slice markers for the selected pad ----- // ----- Swipe: page 1 = waveform + slice markers, page 2 = per-pad volumes -----
val sample = SampleStore.get(sampleId) val pagerState = rememberPagerState(pageCount = { 2 })
val peaks = remember(sampleId, sample?.data?.size) { sample?.let { computePeaks(it.data, 400) } } val scope = rememberCoroutineScope()
var dragging by remember { mutableIntStateOf(-1) } HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxWidth().height(200.dp),
pageSpacing = 12.dp,
) { page ->
if (page == 0) {
WaveformPage(vm, slot, pad, sampleId, sliceStart, sliceEnd)
} else {
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
"Pad volumes (drag a slider; tap selects that pad):",
color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 11.sp,
)
PadVolumeBank(vm, slot, pad, rev, onSelect = onSelectPad)
}
}
}
// Dot pagination: tap a dot (or swipe) to switch waveform <-> volumes.
Row(
Modifier.fillMaxWidth().padding(top = 2.dp),
horizontalArrangement = Arrangement.Center,
) {
for (i in 0 until 2) {
val active = i == pagerState.currentPage
Box(
Modifier
.padding(horizontal = 4.dp)
.size(if (active) 10.dp else 7.dp)
.background(if (active) c.accent else c.grid, CircleShape)
.clickable { scope.launch { pagerState.animateScrollToPage(i) } },
)
}
}
}
importError?.let { msg ->
AlertDialog(
onDismissRequest = { importError = null },
title = { Text("Import failed") },
text = { Text(msg) },
confirmButton = { TextButton(onClick = { importError = null }) { Text("OK") } },
)
}
}
/** Waveform + draggable slice markers for the selected pad (swipe page 1). */
@Composable
private fun WaveformPage(
vm: AppViewModel,
slot: ToolboxSlot,
pad: Int,
sampleId: String,
sliceStart: Float,
sliceEnd: Float,
) {
val c = LocalRetro.current
val sample = SampleStore.get(sampleId)
val peaks = remember(sampleId, sample?.data?.size) { sample?.let { computePeaks(it.data, 400) } }
var dragging by remember { mutableIntStateOf(-1) }
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Canvas( Canvas(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(110.dp) .height(150.dp)
.background(c.surface) .background(c.surface)
.pointerInput(sampleId, pad) { .pointerInput(sampleId, pad) {
detectDragGestures( detectDragGestures(
@@ -159,42 +233,27 @@ fun SamplerEditor(vm: AppViewModel, slot: ToolboxSlot) {
else "${sample.data.size} frames @ ${sample.sampleRate} Hz · drag the markers, then CLIP to trim", else "${sample.data.size} frames @ ${sample.sampleRate} Hz · drag the markers, then CLIP to trim",
color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 10.sp,
) )
// ----- Per-pad output volume: 16 vertical sliders laid out 8 x 2 -----
Text(
"Pad volumes (drag a slider; tap selects that pad):",
color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 11.sp,
)
PadVolumeBank(vm, slot, pad, rev, onSelect = { pad = it })
// ----- 16-pad bank (bottom section) -----
Text(
"Pads (from C2 — press to play & select; CLEAR empties the selected pad):",
color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 11.sp,
)
// Pass rev so assign/clear (which only bump revision) redraw the pads: a
// changed Int param defeats Compose strong-skipping on these leaves.
PadBank(vm, slot, pad, rev, onSelect = { pad = it })
}
importError?.let { msg ->
AlertDialog(
onDismissRequest = { importError = null },
title = { Text("Import failed") },
text = { Text(msg) },
confirmButton = { TextButton(onClick = { importError = null }) { Text("OK") } },
)
} }
} }
/** The 4x4 pad grid. Pads read ascending left-to-right, top-to-bottom (pad 0 = C2). */ /** The playable 4x4 pad grid, pinned at the editor bottom and filling [modifier]'s
* height so the pads match the audition keyboards on the other instrument editors.
* Pads read ascending left-to-right, top-to-bottom (pad 0 = C2). */
@Composable @Composable
private fun PadBank(vm: AppViewModel, slot: ToolboxSlot, selected: Int, rev: Int, onSelect: (Int) -> Unit) { fun SamplerPadBank(vm: AppViewModel, slot: ToolboxSlot, selected: Int, onSelect: (Int) -> Unit, modifier: Modifier) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { val c = LocalRetro.current
val rev = vm.revision // redraw pads on assign/clear (which only bump revision)
Column(
modifier.fillMaxWidth().background(c.background).padding(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
for (row in 0 until 4) { for (row in 0 until 4) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp)) { Row(
Modifier.fillMaxWidth().weight(1f),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
for (col in 0 until 4) { for (col in 0 until 4) {
PadCell(vm, slot, row * 4 + col, selected, rev, Modifier.weight(1f), onSelect) PadCell(vm, slot, row * 4 + col, selected, rev, Modifier.weight(1f).fillMaxHeight(), onSelect)
} }
} }
} }
@@ -224,7 +283,6 @@ private fun PadCell(
} }
Box( Box(
modifier modifier
.height(46.dp)
.border(if (isSelected) 2.dp else 1.dp, border, RectangleShape) .border(if (isSelected) 2.dp else 1.dp, border, RectangleShape)
.background(fill) .background(fill)
.pointerInput(pad, slot.index) { .pointerInput(pad, slot.index) {
@@ -286,7 +344,7 @@ private fun VerticalPadSlider(
Canvas( Canvas(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(96.dp) .height(76.dp)
.background(c.surface) .background(c.surface)
.border(if (isSelected) 2.dp else 1.dp, border, RectangleShape) .border(if (isSelected) 2.dp else 1.dp, border, RectangleShape)
.pointerInput(pad, slot.index) { .pointerInput(pad, slot.index) {

View File

@@ -218,7 +218,11 @@ private fun formatSliderValue(v: Float): String =
@Composable @Composable
fun SlotAuditionKeyboard(vm: AppViewModel, slot: ToolboxSlot, modifier: Modifier) { fun SlotAuditionKeyboard(vm: AppViewModel, slot: ToolboxSlot, modifier: Modifier) {
val c = LocalRetro.current val c = LocalRetro.current
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // refresh key filtering on scale/root change
var octave by remember(slot.index) { mutableIntStateOf(3) } var octave by remember(slot.index) { mutableIntStateOf(3) }
// In-scale keys only (Chromatic yields all twelve), matching the punch-in keyboard.
val root = vm.project.rootNote
val inKey: Set<Int> = vm.project.scale.intervals.map { (it + root) % 12 }.toSet()
Column( Column(
modifier.fillMaxWidth().background(c.background).padding(6.dp), modifier.fillMaxWidth().background(c.background).padding(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
@@ -232,8 +236,8 @@ fun SlotAuditionKeyboard(vm: AppViewModel, slot: ToolboxSlot, modifier: Modifier
Text("$octave${octave + 1}", color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 13.sp) Text("$octave${octave + 1}", color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 13.sp)
RepeatButton("+") { octave = (octave + 1).coerceAtMost(8) } RepeatButton("+") { octave = (octave + 1).coerceAtMost(8) }
} }
AuditionOctave(vm, slot.index, (octave + 2) * 12, Modifier.weight(1f)) // upper octave on top AuditionOctave(vm, slot.index, inKey, (octave + 2) * 12, Modifier.weight(1f)) // upper octave on top
AuditionOctave(vm, slot.index, (octave + 1) * 12, Modifier.weight(1f)) // lower octave below AuditionOctave(vm, slot.index, inKey, (octave + 1) * 12, Modifier.weight(1f)) // lower octave below
} }
} }
@@ -242,17 +246,18 @@ fun SlotAuditionKeyboard(vm: AppViewModel, slot: ToolboxSlot, modifier: Modifier
* instrument. [modifier] carries the weight so the two octaves share the pinned * instrument. [modifier] carries the weight so the two octaves share the pinned
* 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, startMidi: Int, modifier: Modifier) { private fun AuditionOctave(vm: AppViewModel, slotIndex: Int, inKey: Set<Int>, startMidi: Int, modifier: Modifier) {
PianoOctave(modifier.fillMaxWidth()) { pc, isBlack, keyMod -> PianoOctave(modifier.fillMaxWidth()) { pc, isBlack, keyMod ->
val midi = (startMidi + pc).coerceIn(Pitch.LOWEST, Pitch.HIGHEST) val midi = (startMidi + pc).coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
val enabled = pc in inKey
val liveMidi = rememberUpdatedState(midi) val liveMidi = rememberUpdatedState(midi)
PianoKey( PianoKey(
label = Pitch.name(midi).replace("-", ""), label = Pitch.name(midi).replace("-", ""),
isBlack = isBlack, isBlack = isBlack,
enabled = true, enabled = enabled,
selected = false, selected = false,
pressed = vm.isNoteHeld(midi), pressed = vm.isNoteHeld(midi),
modifier = keyMod.pointerInput(Unit) { modifier = if (!enabled) keyMod else keyMod.pointerInput(Unit) {
awaitEachGesture { awaitEachGesture {
awaitFirstDown() awaitFirstDown()
val n = liveMidi.value val n = liveMidi.value

View File

@@ -40,6 +40,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
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.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@@ -277,12 +278,17 @@ private fun ParamEditor(vm: AppViewModel, slot: ToolboxSlot, onClose: () -> Unit
// the toolbox / tracker keyboards), so their controls scroll in a weighted region // the toolbox / tracker keyboards), so their controls scroll in a weighted region
// above the keyboard rather than the whole editor scrolling as one. // above the keyboard rather than the whole editor scrolling as one.
val pinnedKeyboard = type == ToolboxType.SOUNDFONT || type == ToolboxType.NES_SYNTH val pinnedKeyboard = type == ToolboxType.SOUNDFONT || type == ToolboxType.NES_SYNTH
// The Sampler pins its 16-pad grid at the bottom in that same region, so the
// playable pads are the same height as the keyboards across every instrument editor.
val pinnedBottom = pinnedKeyboard || type == ToolboxType.SAMPLER
// Selected pad, hoisted so the scrolling detail area and the pinned pad grid agree.
var samplerPad by remember(slot.index) { mutableIntStateOf(0) }
Box(Modifier.fillMaxSize().background(c.background.copy(alpha = 0.96f))) { Box(Modifier.fillMaxSize().background(c.background.copy(alpha = 0.96f))) {
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
Column( Column(
Modifier Modifier
.weight(if (pinnedKeyboard) 1.7f else 1f) .weight(if (pinnedBottom) 1.7f else 1f)
.fillMaxWidth() .fillMaxWidth()
.padding(12.dp) .padding(12.dp)
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
@@ -304,7 +310,7 @@ private fun ParamEditor(vm: AppViewModel, slot: ToolboxSlot, onClose: () -> Unit
when (type) { when (type) {
ToolboxType.NES_SYNTH -> NesSynthEditor(vm, slot) ToolboxType.NES_SYNTH -> NesSynthEditor(vm, slot)
ToolboxType.MIX_TIGHTENER -> MixTightenerEditor(vm, slot) ToolboxType.MIX_TIGHTENER -> MixTightenerEditor(vm, slot)
ToolboxType.SAMPLER -> SamplerEditor(vm, slot) ToolboxType.SAMPLER -> SamplerEditor(vm, slot, samplerPad, onSelectPad = { samplerPad = it })
ToolboxType.SOUNDFONT -> SoundFontEditor(vm, slot) ToolboxType.SOUNDFONT -> SoundFontEditor(vm, slot)
ToolboxType.DELAY -> DelayEditor(vm, slot) ToolboxType.DELAY -> DelayEditor(vm, slot)
ToolboxType.AMBIENCE -> AmbiencePager(vm, slot) ToolboxType.AMBIENCE -> AmbiencePager(vm, slot)
@@ -316,9 +322,13 @@ private fun ParamEditor(vm: AppViewModel, slot: ToolboxSlot, onClose: () -> Unit
} }
} }
if (pinnedKeyboard) { if (pinnedBottom) {
Box(Modifier.fillMaxWidth().height(2.dp).background(c.accent)) // fixed divider Box(Modifier.fillMaxWidth().height(2.dp).background(c.accent)) // fixed divider
SlotAuditionKeyboard(vm, slot, Modifier.weight(1f)) if (type == ToolboxType.SAMPLER) {
SamplerPadBank(vm, slot, samplerPad, onSelect = { samplerPad = it }, Modifier.weight(1f))
} else {
SlotAuditionKeyboard(vm, slot, Modifier.weight(1f))
}
} }
} }
} }