Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions PLANS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/audio-engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
6 changes: 4 additions & 2 deletions docs/data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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:

Expand Down
2 changes: 1 addition & 1 deletion docs/features/20-adjustable-arrangement-length.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Feature: 20 Adjustable Arrangement Length

## Status
Planned
In Review

## Goal

Expand Down
2 changes: 1 addition & 1 deletion docs/ui-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
110 changes: 105 additions & 5 deletions src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -38,6 +41,7 @@ import {
getClipDeleteConfirmationMessage,
getHybridClipBarCount,
getHybridClipLengthTicks,
getClipInstancesOutsideArrangementLength,
getPitchedInstrument,
hasHybridClipEventsOutsideLength,
hasNoteEventsForPitchedInstrument,
Expand All @@ -46,7 +50,9 @@ import {
moveDrumLane,
moveClipInstance,
moveNoteEvent,
normalizeArrangementLengthBars,
normalizeArrangementLoopRange,
removeClipInstancesOutsideArrangementLength,
removePitchedInstrumentFromClip,
renameClip,
toggleDrumSubstep,
Expand Down Expand Up @@ -164,6 +170,10 @@ export function App() {
const [arrangementTracks, setArrangementTracks] = useState<ArrangementTrack[]>(() =>
createDefaultArrangementTracks(),
);
const [arrangementLengthBars, setArrangementLengthBars] = useState(
DEFAULT_ARRANGEMENT_LENGTH_BARS,
);
const arrangementLengthBarsRef = useRef(arrangementLengthBars);
const [trackMixerStates, setTrackMixerStates] = useState<TrackMixerState[]>(
() => createDefaultTrackMixerStates(arrangementTracks),
);
Expand All @@ -176,7 +186,9 @@ export function App() {
createEmptyMixerLevels(arrangementTracks),
);
const [arrangementLoopRange, setArrangementLoopRange] =
useState<ArrangementLoopRange>(() => createDefaultArrangementLoopRange());
useState<ArrangementLoopRange>(() =>
createDefaultArrangementLoopRange(arrangementLengthBars),
);
const arrangementLoopRangeRef =
useRef<ArrangementLoopRange>(arrangementLoopRange);
const [clipInstances, setClipInstances] = useState<ClipInstance[]>([]);
Expand Down Expand Up @@ -234,6 +246,10 @@ export function App() {
clipInstancesRef.current = clipInstances;
}, [clipInstances]);

useEffect(() => {
arrangementLengthBarsRef.current = arrangementLengthBars;
}, [arrangementLengthBars]);

useEffect(() => {
arrangementLoopRangeRef.current = arrangementLoopRange;
}, [arrangementLoopRange]);
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -288,6 +309,7 @@ export function App() {
setBpm(bpmRef.current);
setClips(restoredClips);
setArrangementTracks(restoredTracks);
setArrangementLengthBars(restoredArrangementLengthBars);
setArrangementLoopRange(restoredLoopRange);
setClipInstances(persistedProject.clipInstances);
setSampleMetas(persistedProject.sampleMetas);
Expand Down Expand Up @@ -382,6 +404,7 @@ export function App() {
let isCancelled = false;
const timeoutId = window.setTimeout(() => {
const projectDocument = createPersistedProjectDocument({
arrangementLengthBars,
arrangementLoopRange,
arrangementTracks,
clipInstances,
Expand Down Expand Up @@ -427,6 +450,7 @@ export function App() {
};
}, [
arrangementLoopRange,
arrangementLengthBars,
arrangementTracks,
bpm,
clipInstances,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.");
Expand Down Expand Up @@ -1603,10 +1699,14 @@ export function App() {
>
{transportMode === "song" ? (
<ArrangementView
arrangementLengthBars={arrangementLengthBars}
clipInstances={clipInstances}
clips={clips}
errorMessage={audioError}
loopRange={arrangementLoopRange}
maxArrangementLengthBars={MAX_ARRANGEMENT_LENGTH_BARS}
minArrangementLengthBars={MIN_ARRANGEMENT_LENGTH_BARS}
onArrangementLengthChange={handleArrangementLengthChange}
onClipDrop={handleArrangementClipDrop}
onClipInstanceDelete={handleClipInstanceDelete}
onClipInstanceMove={handleClipInstanceMove}
Expand Down
47 changes: 47 additions & 0 deletions src/features/arrangement-view/ArrangementView.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,53 @@
padding: 0 var(--space-gutter);
}

.lengthControl {
align-items: center;
background: var(--color-surface-container-lowest);
border: 1px solid var(--color-outline-variant);
border-radius: var(--radius-sm);
color: var(--color-text-muted);
display: flex;
min-height: 28px;
overflow: hidden;
}

.lengthButton {
align-items: center;
background: transparent;
border: 0;
color: var(--color-text-muted);
cursor: pointer;
display: inline-flex;
height: 28px;
justify-content: center;
padding: 0 var(--space-gutter);
}

.lengthButton:hover:not(:disabled),
.lengthButton:focus-visible {
background: var(--color-surface-container-high);
color: var(--color-text);
}

.lengthButton:disabled {
color: var(--color-text-dim);
cursor: not-allowed;
opacity: 0.45;
}

.lengthValue {
border-left: 1px solid var(--color-outline-variant);
border-right: 1px solid var(--color-outline-variant);
color: var(--color-primary-strong);
font-size: var(--font-size-label);
font-weight: var(--font-weight-label);
min-width: 64px;
padding: 0 var(--space-gutter);
text-align: center;
text-transform: uppercase;
}

.controlLabel {
color: var(--color-text-dim);
font-size: var(--font-size-label);
Expand Down
Loading
Loading