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 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).
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user