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.
minSdk = 26
targetSdk = 34
versionCode = 20
versionName = "0.7.1"
versionCode = 21
versionName = "0.7.2"
// We provide our own instrumentation runner if/when tests are added.
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) {
val c = LocalRetro.current
@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);
// the toolbox audition keyboard stays fully chromatic (null = no filter).
val inKey: Set<Int>? = if (punchIn) {
// Only in-scale keys are playable on BOTH the tracker punch-in and the toolbox
// audition keyboard (Chromatic yields all twelve = every key stays enabled).
val root = vm.project.rootNote
vm.project.scale.intervals.map { (it + root) % 12 }.toSet()
} else {
null
}
val inKey: Set<Int> = vm.project.scale.intervals.map { (it + root) % 12 }.toSet()
Column(
modifier.fillMaxWidth().background(c.background).padding(6.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.
* 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
private fun KeyboardOctave(
vm: AppViewModel,
punchIn: Boolean,
inKey: Set<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 = inKey == null || pc in inKey
val enabled = pc in inKey
val liveMidi = rememberUpdatedState(midi)
val livePunch = rememberUpdatedState(punchIn)
PianoKey(

View File

@@ -8,6 +8,7 @@ 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.clickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
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.Column
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.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.shape.CircleShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -26,6 +34,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
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.components.RetroButton
import space.rcmd.android.sizzle.ui.theme.LocalRetro
import kotlinx.coroutines.launch
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
* 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
fun SamplerEditor(vm: AppViewModel, slot: ToolboxSlot) {
fun SamplerEditor(vm: AppViewModel, slot: ToolboxSlot, pad: Int, onSelectPad: (Int) -> Unit) {
val c = LocalRetro.current
val context = LocalContext.current
@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) }
val sampleId = slot.string(SamplerPads.key(pad))
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 pagerState = rememberPagerState(pageCount = { 2 })
val scope = rememberCoroutineScope()
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(
Modifier
.fillMaxWidth()
.height(110.dp)
.height(150.dp)
.background(c.surface)
.pointerInput(sampleId, pad) {
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",
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
private fun PadBank(vm: AppViewModel, slot: ToolboxSlot, selected: Int, rev: Int, onSelect: (Int) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
fun SamplerPadBank(vm: AppViewModel, slot: ToolboxSlot, selected: Int, onSelect: (Int) -> Unit, modifier: Modifier) {
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) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Row(
Modifier.fillMaxWidth().weight(1f),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
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(
modifier
.height(46.dp)
.border(if (isSelected) 2.dp else 1.dp, border, RectangleShape)
.background(fill)
.pointerInput(pad, slot.index) {
@@ -286,7 +344,7 @@ private fun VerticalPadSlider(
Canvas(
Modifier
.fillMaxWidth()
.height(96.dp)
.height(76.dp)
.background(c.surface)
.border(if (isSelected) 2.dp else 1.dp, border, RectangleShape)
.pointerInput(pad, slot.index) {

View File

@@ -218,7 +218,11 @@ private fun formatSliderValue(v: Float): String =
@Composable
fun SlotAuditionKeyboard(vm: AppViewModel, slot: ToolboxSlot, modifier: Modifier) {
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) }
// 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(
modifier.fillMaxWidth().background(c.background).padding(6.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)
RepeatButton("+") { octave = (octave + 1).coerceAtMost(8) }
}
AuditionOctave(vm, slot.index, (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 + 2) * 12, Modifier.weight(1f)) // upper octave on top
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
* keyboard's height (PianoOctave needs a bounded height to lay its keys out). */
@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 ->
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 = true,
enabled = enabled,
selected = false,
pressed = vm.isNoteHeld(midi),
modifier = keyMod.pointerInput(Unit) {
modifier = if (!enabled) keyMod else keyMod.pointerInput(Unit) {
awaitEachGesture {
awaitFirstDown()
val n = liveMidi.value

View File

@@ -40,6 +40,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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
// above the keyboard rather than the whole editor scrolling as one.
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))) {
Column(Modifier.fillMaxSize()) {
Column(
Modifier
.weight(if (pinnedKeyboard) 1.7f else 1f)
.weight(if (pinnedBottom) 1.7f else 1f)
.fillMaxWidth()
.padding(12.dp)
.verticalScroll(rememberScrollState()),
@@ -304,7 +310,7 @@ private fun ParamEditor(vm: AppViewModel, slot: ToolboxSlot, onClose: () -> Unit
when (type) {
ToolboxType.NES_SYNTH -> NesSynthEditor(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.DELAY -> DelayEditor(vm, slot)
ToolboxType.AMBIENCE -> AmbiencePager(vm, slot)
@@ -316,13 +322,17 @@ private fun ParamEditor(vm: AppViewModel, slot: ToolboxSlot, onClose: () -> Unit
}
}
if (pinnedKeyboard) {
if (pinnedBottom) {
Box(Modifier.fillMaxWidth().height(2.dp).background(c.accent)) // fixed divider
if (type == ToolboxType.SAMPLER) {
SamplerPadBank(vm, slot, samplerPad, onSelect = { samplerPad = it }, Modifier.weight(1f))
} else {
SlotAuditionKeyboard(vm, slot, Modifier.weight(1f))
}
}
}
}
}
/** Lay a list of [params] out two-per-row so the editor fits more controls than a
* single stacked column. Each [ParamControl] fills its half-width cell (an odd last