From 6aeb0c68a4955554660c070a557fd01a0a44452c Mon Sep 17 00:00:00 2001 From: Jaewan Park Date: Sun, 14 Jun 2026 18:24:11 +0900 Subject: [PATCH] feat: add adjustable arrangement length --- PLANS.md | 5 +- docs/audio-engine.md | 2 +- docs/data-model.md | 6 +- .../20-adjustable-arrangement-length.md | 2 +- docs/ui-design.md | 2 +- src/app/App.tsx | 110 +++++++++++++++++- .../ArrangementView.module.css | 47 ++++++++ .../arrangement-view/ArrangementView.tsx | 76 +++++++++--- src/model/arrangement.ts | 82 +++++++++++-- src/model/index.ts | 9 ++ src/persistence/project-store.ts | 16 ++- tests/unit/model/arrangement.test.ts | 72 ++++++++++++ tests/unit/persistence/project-store.test.ts | 24 ++++ 13 files changed, 416 insertions(+), 37 deletions(-) diff --git a/PLANS.md b/PLANS.md index bd4a763..6c3b621 100644 --- a/PLANS.md +++ b/PLANS.md @@ -29,9 +29,8 @@ M1 should include: Use this section as the current execution order for agent work. Feature document numbering describes the planned product sequence, but actual issue order may change as dependencies, review feedback, or implementation risks become clearer. -1. #55 Confirm before deleting clips used in arrangement -2. #49 Adjustable arrangement length -> `docs/features/20-adjustable-arrangement-length.md` -3. #50 Arrangement WAV export -> `docs/features/21-arrangement-wav-export.md` +1. #49 Adjustable arrangement length -> `docs/features/20-adjustable-arrangement-length.md` +2. #50 Arrangement WAV export -> `docs/features/21-arrangement-wav-export.md` ## Planned Milestones diff --git a/docs/audio-engine.md b/docs/audio-engine.md index b0388c7..490c2e7 100644 --- a/docs/audio-engine.md +++ b/docs/audio-engine.md @@ -65,7 +65,7 @@ Arrangement playback should: - Respect `clipInstance.lengthTicks` as the visible and playable duration boundary. - Keep play, pause, resume, and stop behavior separate from React render timing. -Arrangement playback should derive its outer bounds from arrangement state, such as `arrangement.lengthBars`, when that state exists. Before adjustable arrangement length exists, a fixed or inferred arrangement range is acceptable if it is documented in the feature PR. +Arrangement playback derives its outer bounds from arrangement state, specifically `arrangementLengthBars` and the normalized loop range. Avoid reintroducing fixed 16-bar playback assumptions. The current first pass reuses the existing lookahead loop scheduler for `SONG` mode by setting a loop range that covers the visible arrangement. This keeps scheduling independent from React and enables pause/resume with the existing transport API, but it is not yet a true one-shot linear song transport. A later transport task should add non-looping arrangement playback that stops at the arrangement end. diff --git a/docs/data-model.md b/docs/data-model.md index 33bc5a8..c371421 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -177,7 +177,7 @@ The arrangement view places reusable clips on tracks using `ClipInstance` object - Total arrangement length in bars. - Current loop range. -The first adjustable arrangement length feature should default to 16 bars, keep a minimum of 1 bar, and use a practical maximum such as 128 bars. The arrangement ruler, grid, scroll width, loop bounds, playback bounds, and export duration should derive from this state. +Arrangement length is serializable state represented as `lengthBars`. The first implementation defaults to 16 bars, keeps a minimum of 1 bar, and uses 128 bars as the practical maximum. The arrangement ruler, grid, scroll width, loop bounds, playback bounds, persistence, and export duration should derive from this state. `ArrangementLoopRange` owns the current song playback loop boundaries: @@ -212,7 +212,9 @@ The first arrangement placement feature should create, move, select, and delete Deleting a source clip from the sidebar removes that clip and all of its arrangement `ClipInstance` placements. The UI should require confirmation when the source clip has musical events, is an imported audio clip, or has one or more arrangement placements. -The first implementation keeps arrangement tracks, clip instances, and loop range in app-level state. The data is still serializable and should map directly into future `Project.tracks`, `Project.clipInstances`, and arrangement transport fields when export/import is implemented. +The first implementation keeps arrangement length, arrangement tracks, clip instances, and loop range in app-level state. The data is still serializable and should map directly into future `Project.tracks`, `Project.clipInstances`, and arrangement transport fields when export/import is implemented. + +When reducing `lengthBars`, clip instances that would extend beyond the new end should not be deleted silently. The first implementation confirms the destructive action and removes out-of-range arrangement placements without trimming source clips. Default instance lengths: diff --git a/docs/features/20-adjustable-arrangement-length.md b/docs/features/20-adjustable-arrangement-length.md index c500943..8777362 100644 --- a/docs/features/20-adjustable-arrangement-length.md +++ b/docs/features/20-adjustable-arrangement-length.md @@ -1,7 +1,7 @@ # Feature: 20 Adjustable Arrangement Length ## Status -Planned +In Review ## Goal diff --git a/docs/ui-design.md b/docs/ui-design.md index 0b2467e..78027ee 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -115,7 +115,7 @@ Do not introduce real arrangement data, persisted clip instances, arrangement pl The arrangement placement feature expands this shell into an editable first pass. Static demo clip blocks should be removed once real `ClipInstance` state is available. -The arrangement toolbar may later include length controls for adding or removing bars. These controls should update serializable arrangement state and the ruler/grid should derive from that state instead of hard-coded 16-bar assumptions. +The arrangement toolbar includes length controls for adding or removing bars. These controls update serializable arrangement state and the ruler/grid derive from that state instead of hard-coded 16-bar assumptions. ## Arrangement Clip Placement diff --git a/src/app/App.tsx b/src/app/App.tsx index 6664ca6..501897f 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -22,7 +22,10 @@ import { } from "../features"; import { DEFAULT_PITCHED_INSTRUMENT_ID, + DEFAULT_ARRANGEMENT_LENGTH_BARS, HYBRID_CLIP_LENGTH_BARS, + MAX_ARRANGEMENT_LENGTH_BARS, + MIN_ARRANGEMENT_LENGTH_BARS, addPitchedInstrumentToClip, addNoteEvent, createClipInstance, @@ -38,6 +41,7 @@ import { getClipDeleteConfirmationMessage, getHybridClipBarCount, getHybridClipLengthTicks, + getClipInstancesOutsideArrangementLength, getPitchedInstrument, hasHybridClipEventsOutsideLength, hasNoteEventsForPitchedInstrument, @@ -46,7 +50,9 @@ import { moveDrumLane, moveClipInstance, moveNoteEvent, + normalizeArrangementLengthBars, normalizeArrangementLoopRange, + removeClipInstancesOutsideArrangementLength, removePitchedInstrumentFromClip, renameClip, toggleDrumSubstep, @@ -164,6 +170,10 @@ export function App() { const [arrangementTracks, setArrangementTracks] = useState(() => createDefaultArrangementTracks(), ); + const [arrangementLengthBars, setArrangementLengthBars] = useState( + DEFAULT_ARRANGEMENT_LENGTH_BARS, + ); + const arrangementLengthBarsRef = useRef(arrangementLengthBars); const [trackMixerStates, setTrackMixerStates] = useState( () => createDefaultTrackMixerStates(arrangementTracks), ); @@ -176,7 +186,9 @@ export function App() { createEmptyMixerLevels(arrangementTracks), ); const [arrangementLoopRange, setArrangementLoopRange] = - useState(() => createDefaultArrangementLoopRange()); + useState(() => + createDefaultArrangementLoopRange(arrangementLengthBars), + ); const arrangementLoopRangeRef = useRef(arrangementLoopRange); const [clipInstances, setClipInstances] = useState([]); @@ -234,6 +246,10 @@ export function App() { clipInstancesRef.current = clipInstances; }, [clipInstances]); + useEffect(() => { + arrangementLengthBarsRef.current = arrangementLengthBars; + }, [arrangementLengthBars]); + useEffect(() => { arrangementLoopRangeRef.current = arrangementLoopRange; }, [arrangementLoopRange]); @@ -265,8 +281,12 @@ export function App() { ? persistedProject.clips : [createEmptyHybridClip({ id: DEFAULT_CLIP_ID, name: "Clip 1" })]; const restoredBpm = clampTempoBpm(persistedProject.tempoBpm); + const restoredArrangementLengthBars = normalizeArrangementLengthBars( + persistedProject.arrangementLengthBars, + ); const restoredLoopRange = normalizeArrangementLoopRange( persistedProject.arrangementLoopRange, + restoredArrangementLengthBars, ); const restoredTrackMixerStates = persistedProject.trackMixerStates.length > 0 @@ -278,6 +298,7 @@ export function App() { bpmRef.current = audioEngine.setTempoBpm(restoredBpm).tempoBpm; clipsRef.current = restoredClips; + arrangementLengthBarsRef.current = restoredArrangementLengthBars; arrangementLoopRangeRef.current = restoredLoopRange; clipInstancesRef.current = persistedProject.clipInstances; sampleMetasRef.current = persistedProject.sampleMetas; @@ -288,6 +309,7 @@ export function App() { setBpm(bpmRef.current); setClips(restoredClips); setArrangementTracks(restoredTracks); + setArrangementLengthBars(restoredArrangementLengthBars); setArrangementLoopRange(restoredLoopRange); setClipInstances(persistedProject.clipInstances); setSampleMetas(persistedProject.sampleMetas); @@ -382,6 +404,7 @@ export function App() { let isCancelled = false; const timeoutId = window.setTimeout(() => { const projectDocument = createPersistedProjectDocument({ + arrangementLengthBars, arrangementLoopRange, arrangementTracks, clipInstances, @@ -427,6 +450,7 @@ export function App() { }; }, [ arrangementLoopRange, + arrangementLengthBars, arrangementTracks, bpm, clipInstances, @@ -590,8 +614,14 @@ export function App() { setClipInstances(nextClipInstances); } - function commitArrangementLoopRange(nextLoopRange: ArrangementLoopRange) { - const normalizedLoopRange = normalizeArrangementLoopRange(nextLoopRange); + function commitArrangementLoopRange( + nextLoopRange: ArrangementLoopRange, + lengthBars = arrangementLengthBarsRef.current, + ) { + const normalizedLoopRange = normalizeArrangementLoopRange( + nextLoopRange, + lengthBars, + ); arrangementLoopRangeRef.current = normalizedLoopRange; setArrangementLoopRange(normalizedLoopRange); @@ -1283,8 +1313,71 @@ export function App() { } } + function handleArrangementLengthChange(nextLengthBars: number) { + const normalizedLengthBars = normalizeArrangementLengthBars(nextLengthBars); + + if (normalizedLengthBars === arrangementLengthBarsRef.current) { + return; + } + + const currentClipInstances = clipInstancesRef.current; + const outOfRangeInstances = getClipInstancesOutsideArrangementLength({ + instances: currentClipInstances, + lengthBars: normalizedLengthBars, + }); + + if ( + outOfRangeInstances.length > 0 && + !window.confirm( + `Shorten arrangement to ${normalizedLengthBars} bar${ + normalizedLengthBars === 1 ? "" : "s" + }? ${outOfRangeInstances.length} clip placement${ + outOfRangeInstances.length === 1 ? "" : "s" + } beyond the new end will be removed.`, + ) + ) { + return; + } + + const nextClipInstances = + outOfRangeInstances.length > 0 + ? removeClipInstancesOutsideArrangementLength({ + instances: currentClipInstances, + lengthBars: normalizedLengthBars, + }) + : currentClipInstances; + const nextLoopRange = normalizeArrangementLoopRange( + arrangementLoopRangeRef.current, + normalizedLengthBars, + ); + + arrangementLengthBarsRef.current = normalizedLengthBars; + setArrangementLengthBars(normalizedLengthBars); + commitArrangementLoopRange(nextLoopRange, normalizedLengthBars); + + if (nextClipInstances !== currentClipInstances) { + commitClipInstances(nextClipInstances); + + if ( + selectedClipInstanceId && + nextClipInstances.every((instance) => instance.id !== selectedClipInstanceId) + ) { + setSelectedClipInstanceId(null); + } + } + + setAudioError(null); + + if (transportState === "playing" && transportMode === "song") { + void restartArrangementPlayback(playheadTickRef.current, nextLoopRange); + } + } + function handleArrangementLoopRangeChange(nextLoopRange: ArrangementLoopRange) { - const normalizedLoopRange = normalizeArrangementLoopRange(nextLoopRange); + const normalizedLoopRange = normalizeArrangementLoopRange( + nextLoopRange, + arrangementLengthBarsRef.current, + ); commitArrangementLoopRange(normalizedLoopRange); setAudioError(null); @@ -1336,7 +1429,10 @@ export function App() { loopRange = arrangementLoopRangeRef.current, ) { const currentClipInstances = clipInstancesRef.current; - const normalizedLoopRange = normalizeArrangementLoopRange(loopRange); + const normalizedLoopRange = normalizeArrangementLoopRange( + loopRange, + arrangementLengthBarsRef.current, + ); if (currentClipInstances.length === 0) { throw new Error("Place at least one clip in the arrangement before playback."); @@ -1603,10 +1699,14 @@ export function App() { > {transportMode === "song" ? ( ; interface ArrangementViewProps { + arrangementLengthBars: number; clipInstances: readonly ClipInstance[]; clips: readonly Clip[]; errorMessage?: string | null; loopRange: ArrangementLoopRange; masterMixerState: MasterMixerState; + maxArrangementLengthBars: number; + minArrangementLengthBars: number; mixerLevels: MixerLevelSnapshot; + onArrangementLengthChange: (lengthBars: number) => void; onClipDrop: (placement: { clipId: string; startTick: Tick; @@ -65,19 +67,19 @@ interface ArrangementViewProps { tracks: readonly ArrangementTrack[]; } -const barNumbers = Array.from( - { length: ARRANGEMENT_BAR_COUNT }, - (_, index) => index + 1, -); type LoopBoundaryKind = "start" | "end"; export function ArrangementView({ + arrangementLengthBars, clipInstances, clips, errorMessage = null, loopRange, masterMixerState, + maxArrangementLengthBars, + minArrangementLengthBars, mixerLevels, + onArrangementLengthChange, onClipDrop, onClipInstanceDelete, onClipInstanceMove, @@ -103,15 +105,24 @@ export function ArrangementView({ const activeTrackIds = new Set( clipInstances.map((instance) => instance.trackId), ); - const loopStartBoundaryIndex = getArrangementLoopBoundaryIndex( + const barNumbers = Array.from( + { length: arrangementLengthBars }, + (_, index) => index + 1, + ); + const timelineWidth = BAR_WIDTH * arrangementLengthBars; + const loopStartBoundaryIndex = getArrangementLoopBoundaryIndexForLength( loopRange.startTick, + arrangementLengthBars, + ); + const loopEndBoundaryIndex = getArrangementLoopBoundaryIndexForLength( + loopRange.endTick, + arrangementLengthBars, ); - const loopEndBoundaryIndex = getArrangementLoopBoundaryIndex(loopRange.endTick); const rootStyle: ArrangementStyle = { "--arrangement-bar-width": `${BAR_WIDTH}px`, "--arrangement-beat-width": `${BAR_WIDTH / BEATS_PER_BAR}px`, "--arrangement-ruler-height": `${RULER_HEIGHT}px`, - "--arrangement-timeline-width": `${TIMELINE_WIDTH}px`, + "--arrangement-timeline-width": `${timelineWidth}px`, "--arrangement-track-count": `${tracks.length}`, "--arrangement-track-header-width": `${TRACK_HEADER_WIDTH}px`, }; @@ -200,7 +211,11 @@ export function ArrangementView({ clientX: number, boundary: LoopBoundaryKind, ) { - const boundaryIndex = getBoundaryIndexFromClientX(clientX, rulerRef.current); + const boundaryIndex = getBoundaryIndexFromClientX({ + arrangementLengthBars, + clientX, + ruler: rulerRef.current, + }); if (boundary === "start") { onLoopRangeChange({ @@ -235,6 +250,29 @@ export function ArrangementView({ Beat +
+ + + {arrangementLengthBars} bars + + +
@@ -457,17 +495,25 @@ function getLoopRegionStyle(loopRange: ArrangementLoopRange): CSSProperties { } function getBoundaryIndexFromClientX( - clientX: number, - ruler: HTMLDivElement | null, + { + arrangementLengthBars, + clientX, + ruler, + }: { + arrangementLengthBars: number; + clientX: number; + ruler: HTMLDivElement | null; + }, ): number { if (!ruler) { return 0; } const rect = ruler.getBoundingClientRect(); - const x = Math.max(0, Math.min(clientX - rect.left, TIMELINE_WIDTH)); + const timelineWidth = BAR_WIDTH * arrangementLengthBars; + const x = Math.max(0, Math.min(clientX - rect.left, timelineWidth)); - return clamp(Math.round(x / BAR_WIDTH), 0, ARRANGEMENT_BAR_COUNT); + return clamp(Math.round(x / BAR_WIDTH), 0, arrangementLengthBars); } function getClipStyle({ diff --git a/src/model/arrangement.ts b/src/model/arrangement.ts index f54fe5e..39e3c7d 100644 --- a/src/model/arrangement.ts +++ b/src/model/arrangement.ts @@ -28,11 +28,19 @@ export interface ArrangementLoopRange { endTick: Tick; } +export interface ArrangementState { + lengthBars: number; + loopRange: ArrangementLoopRange; +} + export const ARRANGEMENT_TRACK_COUNT = 12; -export const ARRANGEMENT_BAR_COUNT = 16; +export const MIN_ARRANGEMENT_LENGTH_BARS = 1; +export const DEFAULT_ARRANGEMENT_LENGTH_BARS = 16; +export const MAX_ARRANGEMENT_LENGTH_BARS = 128; +export const ARRANGEMENT_BAR_COUNT = DEFAULT_ARRANGEMENT_LENGTH_BARS; export const ARRANGEMENT_SNAP_TICKS = TICKS_PER_BEAT; export const ARRANGEMENT_VISIBLE_LENGTH_TICKS = - ARRANGEMENT_BAR_COUNT * TICKS_PER_4_4_BAR; + getArrangementLengthTicks(DEFAULT_ARRANGEMENT_LENGTH_BARS); export const ARRANGEMENT_CLIP_DRAG_TYPE = "application/x-mini-daw-clip-id"; export const ARRANGEMENT_CLIP_INSTANCE_DRAG_TYPE = "application/x-mini-daw-clip-instance-id"; @@ -46,9 +54,27 @@ export function createDefaultArrangementTracks( })); } -export function createDefaultArrangementLoopRange(): ArrangementLoopRange { +export function getArrangementLengthTicks(lengthBars: number): Tick { + return normalizeArrangementLengthBars(lengthBars) * TICKS_PER_4_4_BAR; +} + +export function normalizeArrangementLengthBars(lengthBars: number): number { + if (!Number.isFinite(lengthBars)) { + return DEFAULT_ARRANGEMENT_LENGTH_BARS; + } + + return clampInteger( + Math.round(lengthBars), + MIN_ARRANGEMENT_LENGTH_BARS, + MAX_ARRANGEMENT_LENGTH_BARS, + ); +} + +export function createDefaultArrangementLoopRange( + lengthBars = DEFAULT_ARRANGEMENT_LENGTH_BARS, +): ArrangementLoopRange { return { - endTick: ARRANGEMENT_VISIBLE_LENGTH_TICKS, + endTick: getArrangementLengthTicks(lengthBars), startTick: 0, }; } @@ -123,7 +149,7 @@ export function snapArrangementTick( export function getArrangementPlaybackEndTick( instances: readonly ClipInstance[], - minimumEndTick = ARRANGEMENT_VISIBLE_LENGTH_TICKS, + minimumEndTick = getArrangementLengthTicks(DEFAULT_ARRANGEMENT_LENGTH_BARS), ): Tick { const lastInstanceEndTick = instances.reduce( (highestEndTick, instance) => @@ -137,8 +163,8 @@ export function getArrangementPlaybackEndTick( export function normalizeArrangementLoopRange({ endTick, startTick, -}: ArrangementLoopRange): ArrangementLoopRange { - const maxEndBoundaryIndex = ARRANGEMENT_BAR_COUNT; +}: ArrangementLoopRange, lengthBars = DEFAULT_ARRANGEMENT_LENGTH_BARS): ArrangementLoopRange { + const maxEndBoundaryIndex = normalizeArrangementLengthBars(lengthBars); const startBoundaryIndex = clampInteger( Math.round(startTick / TICKS_PER_4_4_BAR), 0, @@ -158,11 +184,51 @@ export function normalizeArrangementLoopRange({ } export function getArrangementLoopBoundaryIndex(tick: Tick): number { + return getArrangementLoopBoundaryIndexForLength( + tick, + DEFAULT_ARRANGEMENT_LENGTH_BARS, + ); +} + +export function getArrangementLoopBoundaryIndexForLength( + tick: Tick, + lengthBars: number, +): number { return clampInteger( Math.round(tick / TICKS_PER_4_4_BAR), 0, - ARRANGEMENT_BAR_COUNT, + normalizeArrangementLengthBars(lengthBars), + ); +} + +export function getClipInstancesOutsideArrangementLength({ + instances, + lengthBars, +}: { + instances: readonly ClipInstance[]; + lengthBars: number; +}): ClipInstance[] { + const arrangementLengthTicks = getArrangementLengthTicks(lengthBars); + + return instances.filter( + (instance) => instance.startTick + instance.lengthTicks > arrangementLengthTicks, + ); +} + +export function removeClipInstancesOutsideArrangementLength({ + instances, + lengthBars, +}: { + instances: readonly ClipInstance[]; + lengthBars: number; +}): ClipInstance[] { + const outOfRangeInstanceIds = new Set( + getClipInstancesOutsideArrangementLength({ instances, lengthBars }).map( + (instance) => instance.id, + ), ); + + return instances.filter((instance) => !outOfRangeInstanceIds.has(instance.id)); } function getDefaultClipInstanceLength({ diff --git a/src/model/index.ts b/src/model/index.ts index c9fd74c..a636e16 100644 --- a/src/model/index.ts +++ b/src/model/index.ts @@ -2,6 +2,9 @@ export { ARRANGEMENT_BAR_COUNT, ARRANGEMENT_CLIP_DRAG_TYPE, ARRANGEMENT_CLIP_INSTANCE_DRAG_TYPE, + DEFAULT_ARRANGEMENT_LENGTH_BARS, + MAX_ARRANGEMENT_LENGTH_BARS, + MIN_ARRANGEMENT_LENGTH_BARS, ARRANGEMENT_SNAP_TICKS, ARRANGEMENT_TRACK_COUNT, ARRANGEMENT_VISIBLE_LENGTH_TICKS, @@ -9,10 +12,15 @@ export { createDefaultArrangementLoopRange, createDefaultArrangementTracks, deleteClipInstance, + getArrangementLengthTicks, getArrangementLoopBoundaryIndex, + getArrangementLoopBoundaryIndexForLength, getArrangementPlaybackEndTick, + getClipInstancesOutsideArrangementLength, moveClipInstance, normalizeArrangementLoopRange, + normalizeArrangementLengthBars, + removeClipInstancesOutsideArrangementLength, snapArrangementTick, } from "./arrangement"; export { @@ -94,6 +102,7 @@ export { export type { ArrangementTrack, ArrangementLoopRange, + ArrangementState, ClipInstance, ClipInstanceId, TrackId, diff --git a/src/persistence/project-store.ts b/src/persistence/project-store.ts index ff607f5..c1b7697 100644 --- a/src/persistence/project-store.ts +++ b/src/persistence/project-store.ts @@ -7,6 +7,10 @@ import type { SampleMeta, TrackMixerState, } from "../model"; +import { + DEFAULT_ARRANGEMENT_LENGTH_BARS, + normalizeArrangementLengthBars, +} from "../model"; export const ACTIVE_PROJECT_ID = "active-project"; export const PROJECT_DOCUMENT_VERSION = 1; @@ -17,6 +21,7 @@ const PROJECT_STORE_NAME = "projects"; const SAMPLE_BLOB_STORE_NAME = "sampleBlobs"; export interface PersistedProjectDocument { + arrangementLengthBars: number; arrangementLoopRange: ArrangementLoopRange; arrangementTracks: ArrangementTrack[]; clipInstances: ClipInstance[]; @@ -32,6 +37,7 @@ export interface PersistedProjectDocument { } export interface CreatePersistedProjectDocumentOptions { + arrangementLengthBars: number; arrangementLoopRange: ArrangementLoopRange; arrangementTracks: readonly ArrangementTrack[]; clipInstances: readonly ClipInstance[]; @@ -68,6 +74,7 @@ export interface ProjectStore { } export function createPersistedProjectDocument({ + arrangementLengthBars, arrangementLoopRange, arrangementTracks, clipInstances, @@ -80,6 +87,7 @@ export function createPersistedProjectDocument({ trackMixerStates, }: CreatePersistedProjectDocumentOptions): PersistedProjectDocument { return { + arrangementLengthBars: normalizeArrangementLengthBars(arrangementLengthBars), arrangementLoopRange, arrangementTracks: [...arrangementTracks], clipInstances: [...clipInstances], @@ -126,7 +134,13 @@ export function migratePersistedProjectDocument( return null; } - return value as unknown as PersistedProjectDocument; + return { + ...(value as unknown as PersistedProjectDocument), + arrangementLengthBars: + typeof value.arrangementLengthBars === "number" + ? normalizeArrangementLengthBars(value.arrangementLengthBars) + : DEFAULT_ARRANGEMENT_LENGTH_BARS, + }; } export function createIndexedDbProjectStore( diff --git a/tests/unit/model/arrangement.test.ts b/tests/unit/model/arrangement.test.ts index 2b9c0e7..562e345 100644 --- a/tests/unit/model/arrangement.test.ts +++ b/tests/unit/model/arrangement.test.ts @@ -2,6 +2,9 @@ import { describe, expect, it } from "vitest"; import { ARRANGEMENT_SNAP_TICKS, + DEFAULT_ARRANGEMENT_LENGTH_BARS, + MAX_ARRANGEMENT_LENGTH_BARS, + MIN_ARRANGEMENT_LENGTH_BARS, createClipInstance, createDefaultArrangementLoopRange, createDefaultArrangementTracks, @@ -9,10 +12,15 @@ import { createImportedAudioClipDraft, deleteClipInstance, getHybridClipLengthTicks, + getArrangementLengthTicks, getArrangementLoopBoundaryIndex, + getArrangementLoopBoundaryIndexForLength, getArrangementPlaybackEndTick, + getClipInstancesOutsideArrangementLength, moveClipInstance, + normalizeArrangementLengthBars, normalizeArrangementLoopRange, + removeClipInstancesOutsideArrangementLength, snapArrangementTick, } from "../../../src/model"; @@ -32,6 +40,16 @@ describe("arrangement model", () => { expect(snapArrangementTick(-120)).toBe(0); }); + it("normalizes serializable arrangement length in bars", () => { + expect(DEFAULT_ARRANGEMENT_LENGTH_BARS).toBe(16); + expect(MIN_ARRANGEMENT_LENGTH_BARS).toBe(1); + expect(MAX_ARRANGEMENT_LENGTH_BARS).toBe(128); + expect(normalizeArrangementLengthBars(0)).toBe(1); + expect(normalizeArrangementLengthBars(12.4)).toBe(12); + expect(normalizeArrangementLengthBars(200)).toBe(128); + expect(getArrangementLengthTicks(12)).toBe(23040); + }); + it("normalizes arrangement loop ranges to bar boundaries", () => { expect(createDefaultArrangementLoopRange()).toEqual({ endTick: 30720, @@ -58,6 +76,26 @@ describe("arrangement model", () => { expect(getArrangementLoopBoundaryIndex(3840)).toBe(2); }); + it("normalizes arrangement loop ranges within dynamic arrangement length", () => { + expect(createDefaultArrangementLoopRange(8)).toEqual({ + endTick: 15360, + startTick: 0, + }); + expect( + normalizeArrangementLoopRange( + { + endTick: 30720, + startTick: 14400, + }, + 8, + ), + ).toEqual({ + endTick: 15360, + startTick: 13440, + }); + expect(getArrangementLoopBoundaryIndexForLength(30720, 8)).toBe(8); + }); + it("creates clip instances using hybrid clip length", () => { const clip = createEmptyHybridClip({ id: "clip-1", @@ -146,4 +184,38 @@ describe("arrangement model", () => { expect(getArrangementPlaybackEndTick([])).toBe(30720); expect(getArrangementPlaybackEndTick([instance])).toBe(41760); }); + + it("finds and removes clip instances outside a shortened arrangement", () => { + const insideInstance = createClipInstance({ + clip: createEmptyHybridClip({ id: "clip-1" }), + existingInstanceIds: [], + startTick: 0, + tempoBpm: 120, + trackId: "track-1", + }); + const outsideInstance = createClipInstance({ + clip: createEmptyHybridClip({ + id: "clip-2", + lengthTicks: getHybridClipLengthTicks(2), + }), + existingInstanceIds: [insideInstance.id], + startTick: 3360, + tempoBpm: 120, + trackId: "track-1", + }); + const instances = [insideInstance, outsideInstance]; + + expect( + getClipInstancesOutsideArrangementLength({ + instances, + lengthBars: 2, + }).map((instance) => instance.id), + ).toEqual([outsideInstance.id]); + expect( + removeClipInstancesOutsideArrangementLength({ + instances, + lengthBars: 2, + }), + ).toEqual([insideInstance]); + }); }); diff --git a/tests/unit/persistence/project-store.test.ts b/tests/unit/persistence/project-store.test.ts index 59e027f..d8e0a58 100644 --- a/tests/unit/persistence/project-store.test.ts +++ b/tests/unit/persistence/project-store.test.ts @@ -20,6 +20,7 @@ describe("project persistence document helpers", () => { it("creates a versioned serializable active project document", () => { const tracks = createDefaultArrangementTracks(2); const document = createPersistedProjectDocument({ + arrangementLengthBars: 16, arrangementLoopRange: createDefaultArrangementLoopRange(), arrangementTracks: tracks, clipInstances: [], @@ -33,6 +34,7 @@ describe("project persistence document helpers", () => { }); expect(document).toMatchObject({ + arrangementLengthBars: 16, id: ACTIVE_PROJECT_ID, name: "Project 1", savedAt: 123, @@ -53,6 +55,7 @@ describe("project persistence document helpers", () => { sampleId: "imported-audio-loop", }); const document = createPersistedProjectDocument({ + arrangementLengthBars: 16, arrangementLoopRange: createDefaultArrangementLoopRange(), arrangementTracks: [], clipInstances: [], @@ -88,4 +91,25 @@ describe("project persistence document helpers", () => { }), ).toBeNull(); }); + + it("defaults old persisted project documents to 16 arrangement bars", () => { + expect( + migratePersistedProjectDocument({ + arrangementLoopRange: createDefaultArrangementLoopRange(), + arrangementTracks: [], + clipInstances: [], + clips: [], + id: ACTIVE_PROJECT_ID, + masterMixerState: createDefaultMasterMixerState(), + name: "Project 1", + sampleMetas: [], + savedAt: 123, + tempoBpm: 128, + trackMixerStates: [], + version: PROJECT_DOCUMENT_VERSION, + }), + ).toMatchObject({ + arrangementLengthBars: 16, + }); + }); });