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:
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
val root = vm.project.rootNote
|
||||
vm.project.scale.intervals.map { (it + root) % 12 }.toSet()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
// 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
|
||||
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(
|
||||
|
||||
@@ -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 -----
|
||||
val sample = SampleStore.get(sampleId)
|
||||
val peaks = remember(sampleId, sample?.data?.size) { sample?.let { computePeaks(it.data, 400) } }
|
||||
var dragging by remember { mutableIntStateOf(-1) }
|
||||
// ----- 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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user