Skip to content

Commit b4aea8f

Browse files
authored
Merge pull request #57 from boostcampwm-snu-2026-1/feat/49-adjustable-arrangement-length
feat: add adjustable arrangement length
2 parents ff8b0fb + 6aeb0c6 commit b4aea8f

13 files changed

Lines changed: 416 additions & 37 deletions

File tree

PLANS.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,8 @@ M1 should include:
2929

3030
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.
3131

32-
1. #55 Confirm before deleting clips used in arrangement
33-
2. #49 Adjustable arrangement length -> `docs/features/20-adjustable-arrangement-length.md`
34-
3. #50 Arrangement WAV export -> `docs/features/21-arrangement-wav-export.md`
32+
1. #49 Adjustable arrangement length -> `docs/features/20-adjustable-arrangement-length.md`
33+
2. #50 Arrangement WAV export -> `docs/features/21-arrangement-wav-export.md`
3534

3635
## Planned Milestones
3736

docs/audio-engine.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ Arrangement playback should:
6565
- Respect `clipInstance.lengthTicks` as the visible and playable duration boundary.
6666
- Keep play, pause, resume, and stop behavior separate from React render timing.
6767

68-
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.
68+
Arrangement playback derives its outer bounds from arrangement state, specifically `arrangementLengthBars` and the normalized loop range. Avoid reintroducing fixed 16-bar playback assumptions.
6969

7070
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.
7171

docs/data-model.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ The arrangement view places reusable clips on tracks using `ClipInstance` object
177177
- Total arrangement length in bars.
178178
- Current loop range.
179179

180-
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.
180+
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.
181181

182182
`ArrangementLoopRange` owns the current song playback loop boundaries:
183183

@@ -212,7 +212,9 @@ The first arrangement placement feature should create, move, select, and delete
212212

213213
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.
214214

215-
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.
215+
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.
216+
217+
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.
216218

217219
Default instance lengths:
218220

docs/features/20-adjustable-arrangement-length.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Feature: 20 Adjustable Arrangement Length
22

33
## Status
4-
Planned
4+
In Review
55

66
## Goal
77

docs/ui-design.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ Do not introduce real arrangement data, persisted clip instances, arrangement pl
115115

116116
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.
117117

118-
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.
118+
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.
119119

120120
## Arrangement Clip Placement
121121

src/app/App.tsx

Lines changed: 105 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@ import {
2222
} from "../features";
2323
import {
2424
DEFAULT_PITCHED_INSTRUMENT_ID,
25+
DEFAULT_ARRANGEMENT_LENGTH_BARS,
2526
HYBRID_CLIP_LENGTH_BARS,
27+
MAX_ARRANGEMENT_LENGTH_BARS,
28+
MIN_ARRANGEMENT_LENGTH_BARS,
2629
addPitchedInstrumentToClip,
2730
addNoteEvent,
2831
createClipInstance,
@@ -38,6 +41,7 @@ import {
3841
getClipDeleteConfirmationMessage,
3942
getHybridClipBarCount,
4043
getHybridClipLengthTicks,
44+
getClipInstancesOutsideArrangementLength,
4145
getPitchedInstrument,
4246
hasHybridClipEventsOutsideLength,
4347
hasNoteEventsForPitchedInstrument,
@@ -46,7 +50,9 @@ import {
4650
moveDrumLane,
4751
moveClipInstance,
4852
moveNoteEvent,
53+
normalizeArrangementLengthBars,
4954
normalizeArrangementLoopRange,
55+
removeClipInstancesOutsideArrangementLength,
5056
removePitchedInstrumentFromClip,
5157
renameClip,
5258
toggleDrumSubstep,
@@ -164,6 +170,10 @@ export function App() {
164170
const [arrangementTracks, setArrangementTracks] = useState<ArrangementTrack[]>(() =>
165171
createDefaultArrangementTracks(),
166172
);
173+
const [arrangementLengthBars, setArrangementLengthBars] = useState(
174+
DEFAULT_ARRANGEMENT_LENGTH_BARS,
175+
);
176+
const arrangementLengthBarsRef = useRef(arrangementLengthBars);
167177
const [trackMixerStates, setTrackMixerStates] = useState<TrackMixerState[]>(
168178
() => createDefaultTrackMixerStates(arrangementTracks),
169179
);
@@ -176,7 +186,9 @@ export function App() {
176186
createEmptyMixerLevels(arrangementTracks),
177187
);
178188
const [arrangementLoopRange, setArrangementLoopRange] =
179-
useState<ArrangementLoopRange>(() => createDefaultArrangementLoopRange());
189+
useState<ArrangementLoopRange>(() =>
190+
createDefaultArrangementLoopRange(arrangementLengthBars),
191+
);
180192
const arrangementLoopRangeRef =
181193
useRef<ArrangementLoopRange>(arrangementLoopRange);
182194
const [clipInstances, setClipInstances] = useState<ClipInstance[]>([]);
@@ -234,6 +246,10 @@ export function App() {
234246
clipInstancesRef.current = clipInstances;
235247
}, [clipInstances]);
236248

249+
useEffect(() => {
250+
arrangementLengthBarsRef.current = arrangementLengthBars;
251+
}, [arrangementLengthBars]);
252+
237253
useEffect(() => {
238254
arrangementLoopRangeRef.current = arrangementLoopRange;
239255
}, [arrangementLoopRange]);
@@ -265,8 +281,12 @@ export function App() {
265281
? persistedProject.clips
266282
: [createEmptyHybridClip({ id: DEFAULT_CLIP_ID, name: "Clip 1" })];
267283
const restoredBpm = clampTempoBpm(persistedProject.tempoBpm);
284+
const restoredArrangementLengthBars = normalizeArrangementLengthBars(
285+
persistedProject.arrangementLengthBars,
286+
);
268287
const restoredLoopRange = normalizeArrangementLoopRange(
269288
persistedProject.arrangementLoopRange,
289+
restoredArrangementLengthBars,
270290
);
271291
const restoredTrackMixerStates =
272292
persistedProject.trackMixerStates.length > 0
@@ -278,6 +298,7 @@ export function App() {
278298

279299
bpmRef.current = audioEngine.setTempoBpm(restoredBpm).tempoBpm;
280300
clipsRef.current = restoredClips;
301+
arrangementLengthBarsRef.current = restoredArrangementLengthBars;
281302
arrangementLoopRangeRef.current = restoredLoopRange;
282303
clipInstancesRef.current = persistedProject.clipInstances;
283304
sampleMetasRef.current = persistedProject.sampleMetas;
@@ -288,6 +309,7 @@ export function App() {
288309
setBpm(bpmRef.current);
289310
setClips(restoredClips);
290311
setArrangementTracks(restoredTracks);
312+
setArrangementLengthBars(restoredArrangementLengthBars);
291313
setArrangementLoopRange(restoredLoopRange);
292314
setClipInstances(persistedProject.clipInstances);
293315
setSampleMetas(persistedProject.sampleMetas);
@@ -382,6 +404,7 @@ export function App() {
382404
let isCancelled = false;
383405
const timeoutId = window.setTimeout(() => {
384406
const projectDocument = createPersistedProjectDocument({
407+
arrangementLengthBars,
385408
arrangementLoopRange,
386409
arrangementTracks,
387410
clipInstances,
@@ -427,6 +450,7 @@ export function App() {
427450
};
428451
}, [
429452
arrangementLoopRange,
453+
arrangementLengthBars,
430454
arrangementTracks,
431455
bpm,
432456
clipInstances,
@@ -590,8 +614,14 @@ export function App() {
590614
setClipInstances(nextClipInstances);
591615
}
592616

593-
function commitArrangementLoopRange(nextLoopRange: ArrangementLoopRange) {
594-
const normalizedLoopRange = normalizeArrangementLoopRange(nextLoopRange);
617+
function commitArrangementLoopRange(
618+
nextLoopRange: ArrangementLoopRange,
619+
lengthBars = arrangementLengthBarsRef.current,
620+
) {
621+
const normalizedLoopRange = normalizeArrangementLoopRange(
622+
nextLoopRange,
623+
lengthBars,
624+
);
595625

596626
arrangementLoopRangeRef.current = normalizedLoopRange;
597627
setArrangementLoopRange(normalizedLoopRange);
@@ -1283,8 +1313,71 @@ export function App() {
12831313
}
12841314
}
12851315

1316+
function handleArrangementLengthChange(nextLengthBars: number) {
1317+
const normalizedLengthBars = normalizeArrangementLengthBars(nextLengthBars);
1318+
1319+
if (normalizedLengthBars === arrangementLengthBarsRef.current) {
1320+
return;
1321+
}
1322+
1323+
const currentClipInstances = clipInstancesRef.current;
1324+
const outOfRangeInstances = getClipInstancesOutsideArrangementLength({
1325+
instances: currentClipInstances,
1326+
lengthBars: normalizedLengthBars,
1327+
});
1328+
1329+
if (
1330+
outOfRangeInstances.length > 0 &&
1331+
!window.confirm(
1332+
`Shorten arrangement to ${normalizedLengthBars} bar${
1333+
normalizedLengthBars === 1 ? "" : "s"
1334+
}? ${outOfRangeInstances.length} clip placement${
1335+
outOfRangeInstances.length === 1 ? "" : "s"
1336+
} beyond the new end will be removed.`,
1337+
)
1338+
) {
1339+
return;
1340+
}
1341+
1342+
const nextClipInstances =
1343+
outOfRangeInstances.length > 0
1344+
? removeClipInstancesOutsideArrangementLength({
1345+
instances: currentClipInstances,
1346+
lengthBars: normalizedLengthBars,
1347+
})
1348+
: currentClipInstances;
1349+
const nextLoopRange = normalizeArrangementLoopRange(
1350+
arrangementLoopRangeRef.current,
1351+
normalizedLengthBars,
1352+
);
1353+
1354+
arrangementLengthBarsRef.current = normalizedLengthBars;
1355+
setArrangementLengthBars(normalizedLengthBars);
1356+
commitArrangementLoopRange(nextLoopRange, normalizedLengthBars);
1357+
1358+
if (nextClipInstances !== currentClipInstances) {
1359+
commitClipInstances(nextClipInstances);
1360+
1361+
if (
1362+
selectedClipInstanceId &&
1363+
nextClipInstances.every((instance) => instance.id !== selectedClipInstanceId)
1364+
) {
1365+
setSelectedClipInstanceId(null);
1366+
}
1367+
}
1368+
1369+
setAudioError(null);
1370+
1371+
if (transportState === "playing" && transportMode === "song") {
1372+
void restartArrangementPlayback(playheadTickRef.current, nextLoopRange);
1373+
}
1374+
}
1375+
12861376
function handleArrangementLoopRangeChange(nextLoopRange: ArrangementLoopRange) {
1287-
const normalizedLoopRange = normalizeArrangementLoopRange(nextLoopRange);
1377+
const normalizedLoopRange = normalizeArrangementLoopRange(
1378+
nextLoopRange,
1379+
arrangementLengthBarsRef.current,
1380+
);
12881381

12891382
commitArrangementLoopRange(normalizedLoopRange);
12901383
setAudioError(null);
@@ -1336,7 +1429,10 @@ export function App() {
13361429
loopRange = arrangementLoopRangeRef.current,
13371430
) {
13381431
const currentClipInstances = clipInstancesRef.current;
1339-
const normalizedLoopRange = normalizeArrangementLoopRange(loopRange);
1432+
const normalizedLoopRange = normalizeArrangementLoopRange(
1433+
loopRange,
1434+
arrangementLengthBarsRef.current,
1435+
);
13401436

13411437
if (currentClipInstances.length === 0) {
13421438
throw new Error("Place at least one clip in the arrangement before playback.");
@@ -1603,10 +1699,14 @@ export function App() {
16031699
>
16041700
{transportMode === "song" ? (
16051701
<ArrangementView
1702+
arrangementLengthBars={arrangementLengthBars}
16061703
clipInstances={clipInstances}
16071704
clips={clips}
16081705
errorMessage={audioError}
16091706
loopRange={arrangementLoopRange}
1707+
maxArrangementLengthBars={MAX_ARRANGEMENT_LENGTH_BARS}
1708+
minArrangementLengthBars={MIN_ARRANGEMENT_LENGTH_BARS}
1709+
onArrangementLengthChange={handleArrangementLengthChange}
16101710
onClipDrop={handleArrangementClipDrop}
16111711
onClipInstanceDelete={handleClipInstanceDelete}
16121712
onClipInstanceMove={handleClipInstanceMove}

src/features/arrangement-view/ArrangementView.module.css

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,53 @@
6363
padding: 0 var(--space-gutter);
6464
}
6565

66+
.lengthControl {
67+
align-items: center;
68+
background: var(--color-surface-container-lowest);
69+
border: 1px solid var(--color-outline-variant);
70+
border-radius: var(--radius-sm);
71+
color: var(--color-text-muted);
72+
display: flex;
73+
min-height: 28px;
74+
overflow: hidden;
75+
}
76+
77+
.lengthButton {
78+
align-items: center;
79+
background: transparent;
80+
border: 0;
81+
color: var(--color-text-muted);
82+
cursor: pointer;
83+
display: inline-flex;
84+
height: 28px;
85+
justify-content: center;
86+
padding: 0 var(--space-gutter);
87+
}
88+
89+
.lengthButton:hover:not(:disabled),
90+
.lengthButton:focus-visible {
91+
background: var(--color-surface-container-high);
92+
color: var(--color-text);
93+
}
94+
95+
.lengthButton:disabled {
96+
color: var(--color-text-dim);
97+
cursor: not-allowed;
98+
opacity: 0.45;
99+
}
100+
101+
.lengthValue {
102+
border-left: 1px solid var(--color-outline-variant);
103+
border-right: 1px solid var(--color-outline-variant);
104+
color: var(--color-primary-strong);
105+
font-size: var(--font-size-label);
106+
font-weight: var(--font-weight-label);
107+
min-width: 64px;
108+
padding: 0 var(--space-gutter);
109+
text-align: center;
110+
text-transform: uppercase;
111+
}
112+
66113
.controlLabel {
67114
color: var(--color-text-dim);
68115
font-size: var(--font-size-label);

0 commit comments

Comments
 (0)