diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/AudioEngine.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/AudioEngine.kt index f57f132..ffd599e 100644 --- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/AudioEngine.kt +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/AudioEngine.kt @@ -303,7 +303,7 @@ class AudioEngine( val a = arr.regionA val b = arr.regionB // With no A/B loop regions in play, walk beats 0..last-filled — never out - // through the trailing empty beats up to lengthBeats. Whether this stops or + // through the trailing empty beats of the canvas. Whether this stops or // repeats is then decided by loopEnabled in advanceArrangement: loop off // plays it once and stops after the last filled block; loop on repeats the // filled span. Regions only take effect while looping (as before). diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/Transport.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/Transport.kt index c5af1d9..f4d5bf1 100644 --- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/Transport.kt +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/Transport.kt @@ -9,6 +9,6 @@ data class TransportState( val isPlaying: Boolean = false, /** Absolute line index within the currently-playing pattern. */ val currentLine: Int = 0, - /** Beat index within the arrangement (0 until arrangement.lengthBeats). */ + /** Beat index within the arrangement (0 until the canvas width in beats). */ val currentBeat: Int = 0, ) diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/ProjectIo.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/ProjectIo.kt index 5b77bf6..a0cca96 100644 --- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/ProjectIo.kt +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/ProjectIo.kt @@ -46,9 +46,10 @@ object ProjectIo { val arr = project.arrangement val mutes = arr.laneMuted.joinToString("") { if (it) "1" else "0" } - appendLine("[ARRANGEMENT len=${arr.lengthBeats} loop=${arr.loopEnabled.b()} mutes=$mutes]") + appendLine("[ARRANGEMENT bars=${arr.lengthBars} loop=${arr.loopEnabled.b()} mutes=$mutes]") + val usedBeats = arr.lastFilledBeat() + 1 for (lane in arr.slots.indices) { - for (beat in 0 until arr.lengthBeats) { + for (beat in 0 until usedBeats) { val pid = arr.slots[lane][beat] if (pid >= 0) appendLine("$lane,$beat,$pid") } @@ -110,7 +111,12 @@ object ProjectIo { } line.startsWith("[ARRANGEMENT") -> { val attrs = parseAttrs(line) - project.arrangement.lengthBeats = attrs["len"]?.toIntOrNull() ?: 64 + // "bars" is the current canvas unit; older files stored "len" in + // beats — those simply get the default 256-bar canvas (their cells + // are restored from the explicit lane,beat,pid lines regardless). + project.arrangement.lengthBars = + attrs["bars"]?.toIntOrNull() + ?: com.reactorcoremeltdown.sizzletracker.model.Arrangement.MAX_BARS project.arrangement.loopEnabled = attrs["loop"] == "1" attrs["mutes"]?.let { m -> val lanes = project.arrangement.laneMuted diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/SngFormat.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/SngFormat.kt index 62bcff1..9b1e9de 100644 --- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/SngFormat.kt +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/SngFormat.kt @@ -41,8 +41,9 @@ object SngFormat { appendLine() val arr = project.arrangement + val usedBeats = arr.lastFilledBeat() + 1 for (block in project.patterns) { - val rollUsed = (0 until arr.lengthBeats).any { arr.patternAt(block.id, it) != Arrangement.EMPTY } + val rollUsed = (0 until usedBeats).any { arr.patternAt(block.id, it) != Arrangement.EMPTY } val hasNotes = (0 until Pattern.TRACK_COUNT).any { t -> (0 until block.length).any { !block.cell(t, it).isEmpty } } @@ -51,7 +52,7 @@ object SngFormat { appendLine("block ${block.name} ${block.length} ${project.timeSignature.beatsPerBar}") val roll = buildString { - for (beat in 0 until arr.lengthBeats) { + for (beat in 0 until usedBeats) { append(if (arr.patternAt(block.id, beat) != Arrangement.EMPTY) '#' else '.') } }.trimEnd('.') @@ -120,7 +121,13 @@ object SngFormat { else -> applyStep(project, current, track, trackChannel, tok) } } - if (maxRoll > 0) project.arrangement.lengthBeats = maxOf(project.arrangement.lengthBeats, maxRoll) + // Grow the canvas (in bars) if the imported roll is longer than the default. + if (maxRoll > 0) { + val beatsPerBar = project.timeSignature.beatsPerBar + val neededBars = (maxRoll + beatsPerBar - 1) / beatsPerBar // ceil + project.arrangement.lengthBars = + maxOf(project.arrangement.lengthBars, neededBars).coerceAtMost(Arrangement.MAX_BARS) + } project.activePatternId = 0 return project } diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Arrangement.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Arrangement.kt index b579539..195d582 100644 --- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Arrangement.kt +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Arrangement.kt @@ -6,6 +6,10 @@ package com.reactorcoremeltdown.sizzletracker.model * the [Pattern] to trigger on that beat (or [EMPTY]). Per the spec, one marked * slot triggers exactly ONE beat of that pattern/block, even if the pattern is * longer — so the arrangement grid resolution is one-beat-per-column. + * + * The editable canvas is denominated in *bars* ([lengthBars]); its width in beats + * depends on the active time signature ([canvasBeats]). Playback is unaffected by + * the canvas width — the sequencer stops at [lastFilledBeat]. */ class Arrangement { /** slots[lane][beat] = patternId, or [EMPTY]. */ @@ -17,8 +21,14 @@ class Arrangement { fun isLaneMuted(lane: Int): Boolean = lane in 0 until LANE_COUNT && laneMuted[lane] - /** How many beats are actually in use; the roll can grow up to [MAX_BEATS]. */ - var lengthBeats: Int = 64 + /** Editable canvas width in *bars*. */ + var lengthBars: Int = MAX_BARS + + /** Canvas width in beats for [sig], clamped to the backing array. One bar is + * [TimeSignature.beatsPerBar] beats, so the canvas spans [lengthBars] bars + * exactly regardless of signature. */ + fun canvasBeats(sig: TimeSignature): Int = + (lengthBars * sig.beatsPerBar).coerceIn(1, MAX_BEATS) /** Loop transport state (the toolbar under the roll). */ var loopEnabled: Boolean = false @@ -31,7 +41,7 @@ class Arrangement { /** * The highest beat index holding any non-empty cell across all lanes, or -1 if * the arrangement is empty. Playback uses this to stop after the last filled - * block instead of running out to [lengthBeats] through trailing silence. + * block instead of running out across the empty canvas through trailing silence. */ fun lastFilledBeat(): Int { for (beat in MAX_BEATS - 1 downTo 0) { @@ -50,7 +60,11 @@ class Arrangement { companion object { const val LANE_COUNT = 8 - const val MAX_BEATS = 256 + /** Editable canvas length, in bars. */ + const val MAX_BARS = 256 + /** Backing array width in beats: enough to hold [MAX_BARS] at the widest + * supported signature (5/4 → 5 beats per bar). */ + const val MAX_BEATS = MAX_BARS * 5 const val EMPTY = -1 } } diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/tracker/ArrangementRoll.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/tracker/ArrangementRoll.kt index 4a13bbe..eec89e4 100644 --- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/tracker/ArrangementRoll.kt +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/tracker/ArrangementRoll.kt @@ -77,6 +77,10 @@ fun ArrangementRoll(vm: AppViewModel) { val transportState = vm.transport.collectAsState() val arr = vm.project.arrangement val sig = vm.project.timeSignature + // Canvas width in beats for the active signature (the roll spans arr.lengthBars + // bars). Recomputed on signature change; captured by the pointer handlers below, + // which are keyed on (arr, sig) so they see a consistent value. + val lengthBeats = arr.canvasBeats(sig) val scroll = rememberScrollState() // Width of the scrollable roll viewport (px), measured below; used to keep the // playhead centred once it reaches the middle of the view. @@ -208,7 +212,7 @@ fun ArrangementRoll(vm: AppViewModel) { ) { Canvas( Modifier - .width(beatWidth * arr.lengthBeats) + .width(beatWidth * lengthBeats) .fillMaxHeight() // Tap selects a single cell (no toggle). The ruler is // display-only, so ruler taps are ignored. @@ -218,7 +222,7 @@ fun ArrangementRoll(vm: AppViewModel) { val laneH = (size.height - rulerHeightPx) / ArrModel.LANE_COUNT val lane = ((off.y - rulerHeightPx) / laneH).toInt() .coerceIn(0, ArrModel.LANE_COUNT - 1) - val beat = (off.x / beatWidthPx).toInt().coerceIn(0, arr.lengthBeats - 1) + val beat = (off.x / beatWidthPx).toInt().coerceIn(0, lengthBeats - 1) selLane0 = lane; selLane1 = lane selBeat0 = beat; selBeat1 = beat } @@ -231,7 +235,7 @@ fun ArrangementRoll(vm: AppViewModel) { .coerceIn(0, ArrModel.LANE_COUNT - 1) } fun beatAt(off: Offset): Int = - (off.x / beatWidthPx).toInt().coerceIn(0, arr.lengthBeats - 1) + (off.x / beatWidthPx).toInt().coerceIn(0, lengthBeats - 1) detectDragGesturesAfterLongPress( onDragStart = { off -> selLane0 = laneAt(off); selLane1 = selLane0 @@ -256,9 +260,18 @@ fun ArrangementRoll(vm: AppViewModel) { val laneH = (h - rulerHeightPx) / ArrModel.LANE_COUNT val beatsPerBar = sig.beatsPerBar + // Viewport culling: with the canvas up to 256 bars wide, only + // iterate the beats currently scrolled into view (plus a small + // margin) instead of all of them. Reading scroll.value here ties + // the draw to scrolling, so the visible slice redraws as it moves. + val scrollPx = scroll.value.toFloat() + val visW = if (viewportWidthPx > 0) viewportWidthPx.toFloat() else w + val firstBeat = (scrollPx / beatWidthPx).toInt().coerceIn(0, (lengthBeats - 1).coerceAtLeast(0)) + val lastBeat = (((scrollPx + visW) / beatWidthPx).toInt() + 2).coerceIn(1, lengthBeats) + // Ruler background + per-bar labels/ticks. drawRect(c.surface, Offset(0f, 0f), Size(w, rulerHeightPx)) - for (beat in 0 until arr.lengthBeats) { + for (beat in firstBeat until lastBeat) { val x = beat * beatWidthPx if (beat % beatsPerBar == 0) { drawLine(c.grid, Offset(x, 0f), Offset(x, h), strokeWidth = 1f) @@ -293,7 +306,7 @@ fun ArrangementRoll(vm: AppViewModel) { // Lane cells. for (lane in 0 until ArrModel.LANE_COUNT) { val laneY = rulerHeightPx + lane * laneH - for (beat in 0 until arr.lengthBeats) { + for (beat in firstBeat until lastBeat) { val x = beat * beatWidthPx val filled = arr.patternAt(lane, beat) != ArrModel.EMPTY val onPlayhead = transport.isPlaying && beat == transport.currentBeat