diff --git a/app/build.gradle.kts b/app/build.gradle.kts index df5d95e..1de13a0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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" diff --git a/app/src/main/java/space/rcmd/android/sizzle/ui/StackedKeyboard.kt b/app/src/main/java/space/rcmd/android/sizzle/ui/StackedKeyboard.kt index a9aaad5..04ff5e7 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/ui/StackedKeyboard.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/ui/StackedKeyboard.kt @@ -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? = 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 = 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?, + inKey: Set, 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( diff --git a/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/SamplerEditor.kt b/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/SamplerEditor.kt index d810d9a..f73b372 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/SamplerEditor.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/SamplerEditor.kt @@ -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(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) { diff --git a/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/SoundFontEditor.kt b/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/SoundFontEditor.kt index 2239456..713f887 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/SoundFontEditor.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/SoundFontEditor.kt @@ -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 = 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, 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 diff --git a/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/ToolboxScreen.kt b/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/ToolboxScreen.kt index 1762c09..e8ba813 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/ToolboxScreen.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/ToolboxScreen.kt @@ -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)) + } } } }