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 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).

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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
}

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
* 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
}
}

View File

@@ -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