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:
Reactorcoremeltdown
2026-07-17 08:11:40 +02:00
parent 61d7de1f25
commit 3df4bb645a
6 changed files with 57 additions and 17 deletions

View File

@@ -303,7 +303,7 @@ class AudioEngine(
val a = arr.regionA val a = arr.regionA
val b = arr.regionB val b = arr.regionB
// With no A/B loop regions in play, walk beats 0..last-filled — never out // 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 // repeats is then decided by loopEnabled in advanceArrangement: loop off
// plays it once and stops after the last filled block; loop on repeats the // plays it once and stops after the last filled block; loop on repeats the
// filled span. Regions only take effect while looping (as before). // filled span. Regions only take effect while looping (as before).

View File

@@ -9,6 +9,6 @@ data class TransportState(
val isPlaying: Boolean = false, val isPlaying: Boolean = false,
/** Absolute line index within the currently-playing pattern. */ /** Absolute line index within the currently-playing pattern. */
val currentLine: Int = 0, 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, val currentBeat: Int = 0,
) )

View File

@@ -46,9 +46,10 @@ object ProjectIo {
val arr = project.arrangement val arr = project.arrangement
val mutes = arr.laneMuted.joinToString("") { if (it) "1" else "0" } 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 (lane in arr.slots.indices) {
for (beat in 0 until arr.lengthBeats) { for (beat in 0 until usedBeats) {
val pid = arr.slots[lane][beat] val pid = arr.slots[lane][beat]
if (pid >= 0) appendLine("$lane,$beat,$pid") if (pid >= 0) appendLine("$lane,$beat,$pid")
} }
@@ -110,7 +111,12 @@ object ProjectIo {
} }
line.startsWith("[ARRANGEMENT") -> { line.startsWith("[ARRANGEMENT") -> {
val attrs = parseAttrs(line) 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" project.arrangement.loopEnabled = attrs["loop"] == "1"
attrs["mutes"]?.let { m -> attrs["mutes"]?.let { m ->
val lanes = project.arrangement.laneMuted val lanes = project.arrangement.laneMuted

View File

@@ -41,8 +41,9 @@ object SngFormat {
appendLine() appendLine()
val arr = project.arrangement val arr = project.arrangement
val usedBeats = arr.lastFilledBeat() + 1
for (block in project.patterns) { 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 -> val hasNotes = (0 until Pattern.TRACK_COUNT).any { t ->
(0 until block.length).any { !block.cell(t, it).isEmpty } (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}") appendLine("block ${block.name} ${block.length} ${project.timeSignature.beatsPerBar}")
val roll = buildString { 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 '.') append(if (arr.patternAt(block.id, beat) != Arrangement.EMPTY) '#' else '.')
} }
}.trimEnd('.') }.trimEnd('.')
@@ -120,7 +121,13 @@ object SngFormat {
else -> applyStep(project, current, track, trackChannel, tok) 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 project.activePatternId = 0
return project return project
} }

View File

@@ -6,6 +6,10 @@ package com.reactorcoremeltdown.sizzletracker.model
* the [Pattern] to trigger on that beat (or [EMPTY]). Per the spec, one marked * 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 * 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. * 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 { class Arrangement {
/** slots[lane][beat] = patternId, or [EMPTY]. */ /** 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] 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]. */ /** Editable canvas width in *bars*. */
var lengthBeats: Int = 64 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). */ /** Loop transport state (the toolbar under the roll). */
var loopEnabled: Boolean = false 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 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 * 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 { fun lastFilledBeat(): Int {
for (beat in MAX_BEATS - 1 downTo 0) { for (beat in MAX_BEATS - 1 downTo 0) {
@@ -50,7 +60,11 @@ class Arrangement {
companion object { companion object {
const val LANE_COUNT = 8 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 const val EMPTY = -1
} }
} }

View File

@@ -77,6 +77,10 @@ fun ArrangementRoll(vm: AppViewModel) {
val transportState = vm.transport.collectAsState() val transportState = vm.transport.collectAsState()
val arr = vm.project.arrangement val arr = vm.project.arrangement
val sig = vm.project.timeSignature 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() val scroll = rememberScrollState()
// Width of the scrollable roll viewport (px), measured below; used to keep the // Width of the scrollable roll viewport (px), measured below; used to keep the
// playhead centred once it reaches the middle of the view. // playhead centred once it reaches the middle of the view.
@@ -208,7 +212,7 @@ fun ArrangementRoll(vm: AppViewModel) {
) { ) {
Canvas( Canvas(
Modifier Modifier
.width(beatWidth * arr.lengthBeats) .width(beatWidth * lengthBeats)
.fillMaxHeight() .fillMaxHeight()
// Tap selects a single cell (no toggle). The ruler is // Tap selects a single cell (no toggle). The ruler is
// display-only, so ruler taps are ignored. // display-only, so ruler taps are ignored.
@@ -218,7 +222,7 @@ fun ArrangementRoll(vm: AppViewModel) {
val laneH = (size.height - rulerHeightPx) / ArrModel.LANE_COUNT val laneH = (size.height - rulerHeightPx) / ArrModel.LANE_COUNT
val lane = ((off.y - rulerHeightPx) / laneH).toInt() val lane = ((off.y - rulerHeightPx) / laneH).toInt()
.coerceIn(0, ArrModel.LANE_COUNT - 1) .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 selLane0 = lane; selLane1 = lane
selBeat0 = beat; selBeat1 = beat selBeat0 = beat; selBeat1 = beat
} }
@@ -231,7 +235,7 @@ fun ArrangementRoll(vm: AppViewModel) {
.coerceIn(0, ArrModel.LANE_COUNT - 1) .coerceIn(0, ArrModel.LANE_COUNT - 1)
} }
fun beatAt(off: Offset): Int = fun beatAt(off: Offset): Int =
(off.x / beatWidthPx).toInt().coerceIn(0, arr.lengthBeats - 1) (off.x / beatWidthPx).toInt().coerceIn(0, lengthBeats - 1)
detectDragGesturesAfterLongPress( detectDragGesturesAfterLongPress(
onDragStart = { off -> onDragStart = { off ->
selLane0 = laneAt(off); selLane1 = selLane0 selLane0 = laneAt(off); selLane1 = selLane0
@@ -256,9 +260,18 @@ fun ArrangementRoll(vm: AppViewModel) {
val laneH = (h - rulerHeightPx) / ArrModel.LANE_COUNT val laneH = (h - rulerHeightPx) / ArrModel.LANE_COUNT
val beatsPerBar = sig.beatsPerBar 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. // Ruler background + per-bar labels/ticks.
drawRect(c.surface, Offset(0f, 0f), Size(w, rulerHeightPx)) 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 val x = beat * beatWidthPx
if (beat % beatsPerBar == 0) { if (beat % beatsPerBar == 0) {
drawLine(c.grid, Offset(x, 0f), Offset(x, h), strokeWidth = 1f) drawLine(c.grid, Offset(x, 0f), Offset(x, h), strokeWidth = 1f)
@@ -293,7 +306,7 @@ fun ArrangementRoll(vm: AppViewModel) {
// Lane cells. // Lane cells.
for (lane in 0 until ArrModel.LANE_COUNT) { for (lane in 0 until ArrModel.LANE_COUNT) {
val laneY = rulerHeightPx + lane * laneH val laneY = rulerHeightPx + lane * laneH
for (beat in 0 until arr.lengthBeats) { for (beat in firstBeat until lastBeat) {
val x = beat * beatWidthPx val x = beat * beatWidthPx
val filled = arr.patternAt(lane, beat) != ArrModel.EMPTY val filled = arr.patternAt(lane, beat) != ArrModel.EMPTY
val onPlayhead = transport.isPlaying && beat == transport.currentBeat val onPlayhead = transport.isPlaying && beat == transport.currentBeat