Extend the arrangement canvas to 256 bars
- Denominate the canvas in bars (lengthBars, default 256) instead of a raw beat count; canvasBeats(sig) = lengthBars * beatsPerBar, so it spans exactly 256 bars in any signature. Backing arrays sized for the widest case (5/4 -> 1280 beats). Playback still stops at lastFilledBeat, so the wider empty canvas adds no trailing silence. - Roll: draw with viewport culling (iterate only the beats scrolled into view) so the ~16x-wider canvas stays cheap to redraw and scrolls smoothly. - Persistence: store bars= (was len= in beats) and write cells only up to the last filled beat; old files without bars= load the default 256-bar canvas with their cells intact. .sng import grows the canvas in bars as needed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user