Skip to content

Commit 5890bfd

Browse files
authored
Merge pull request #54 from boostcampwm-snu-2026-1/feat/48-variable-hybrid-clip-length
feat: support variable hybrid clip lengths
2 parents 7dae7a7 + a817ec7 commit 5890bfd

17 files changed

Lines changed: 600 additions & 75 deletions

PLANS.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,9 @@ 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. #47 IndexedDB project persistence -> `docs/features/18-indexeddb-project-persistence.md`
33-
2. #48 Variable hybrid clip length -> `docs/features/19-variable-hybrid-clip-length.md`
34-
3. #49 Adjustable arrangement length -> `docs/features/20-adjustable-arrangement-length.md`
35-
4. #50 Arrangement WAV export -> `docs/features/21-arrangement-wav-export.md`
32+
1. #48 Variable hybrid clip length -> `docs/features/19-variable-hybrid-clip-length.md`
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`
3635

3736
## Planned Milestones
3837

docs/audio-engine.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,9 @@ Prefer an audio-engine API that can update active scheduler tempo while preservi
280280

281281
## Looping Behavior
282282

283-
For M1, loop playback targets a selected 1-bar clip. The default loop range is 0 to 1920 ticks.
283+
For M1, pattern loop playback targets the selected hybrid clip. The default loop range is 0 to 1920 ticks for a 1-bar clip, but 2-bar and 4-bar hybrid clips must pass `loopEndTick` as 3840 or 7680 ticks respectively.
284+
285+
Do not hard-code one-bar loop boundaries in the UI or feature orchestration once a clip has a `lengthTicks` value. The audio engine scheduler already accepts tick-based loop boundaries; the caller should provide the active clip length.
284286

285287
Events at the loop start should play when the loop begins. Events at the loop end should belong to the next loop iteration only if explicitly represented there; avoid double-triggering boundary events.
286288

docs/data-model.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,11 @@ Early clips may contain both drum events and note events. This keeps the M1 edit
4242

4343
Later, the model can evolve toward separate drum, MIDI, and audio clip types if arrangement and editing workflows need stronger separation.
4444

45-
The initial clip length is 1 bar, represented as `lengthTicks: 1920`. Later clip editing should support 1, 2, and 4 bar hybrid clips by changing `lengthTicks` to 1920, 3840, or 7680 ticks. Event positions and note durations remain tick-based and must stay inside the clip length.
45+
Hybrid clip length is represented as `lengthTicks`. Supported M1 lengths are 1, 2, and 4 bars: 1920, 3840, or 7680 ticks. Event positions and note durations remain tick-based and must stay inside the clip length.
46+
47+
Editor grids derive from `lengthTicks`. Drum sequencing uses 16 primary steps per bar, so 1, 2, and 4 bar clips expose 16, 32, and 64 primary drum steps. Piano roll editing uses 32 columns per bar, so 1, 2, and 4 bar clips expose 32, 64, and 128 columns.
48+
49+
Shortening a hybrid clip must not silently drop data. The UI should require confirmation before deleting drum events outside the new length or trimming note durations that extend beyond the new end tick.
4650

4751
Imported WAV files should use a separate audio clip shape rather than forcing audio file state into the M1 hybrid clip fields.
4852

@@ -240,7 +244,7 @@ Examples:
240244

241245
## Initial Drum Clip Implementation
242246

243-
The initial drum step sequencer stores lane settings and drum hits in the selected hybrid clip. A 16-step grid maps step indices to ticks with `stepIndex * 120`.
247+
The drum step sequencer stores lane settings and drum hits in the selected hybrid clip. A 16-step-per-bar grid maps step indices to ticks with `stepIndex * 120`; longer clips extend this same mapping across the selected clip length.
244248

245249
The clip stores `drumLanes` as an ordered array. That array controls both visual lane order and the current sample assigned to each lane. Reordering lanes or changing a lane's sample must update serializable clip state, not runtime-only audio state.
246250

docs/features/19-variable-hybrid-clip-length.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Feature: 19 Variable Hybrid Clip Length
22

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

66
## Goal
77

docs/testing.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ CI uses `--if-present` while the repository is still before the Vite scaffold.
2525
- Arrangement scheduler event expansion from clip instances: unit tests where practical.
2626
- IndexedDB persistence adapters, migrations, and serialization boundaries: unit or integration tests with mocked storage where practical.
2727
- Project autosave/manual restore checks should verify that imported audio metadata and blobs remain separated.
28+
- Variable hybrid clip length should cover 1, 2, and 4 bar tick lengths, editor grid derivation, shortening behavior, and arrangement default instance length.
2829
- WAV encoder header, duration, and sample conversion helpers: unit tests.
2930
- Pitched instrument metadata and sample-zone mapping: unit tests.
3031
- Tempo control and scheduler tempo update behavior: unit tests where practical.

docs/ui-design.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,12 +139,13 @@ Start with a simple snap policy, such as beat-level snapping at 480 ticks, unles
139139

140140
## Clip Length Controls
141141

142-
Hybrid clip editors should eventually expose a compact length control for 1, 2, and 4 bars.
142+
Hybrid clip editors expose a compact length control for 1, 2, and 4 bars.
143143

144144
- The selected length should update clip `lengthTicks`.
145145
- Drum and piano roll grids should derive from the selected clip length.
146146
- Shortening a clip should avoid silent data loss when events would fall outside the new length.
147147
- The control belongs in the clip editor header or nearby toolbar, not in the audio engine.
148+
- Longer piano roll grids may scroll horizontally; the 1-bar view should remain compact.
148149

149150
## Export UI
150151

src/app/App.module.css

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,37 @@
7575
padding: var(--space-unit) var(--space-gutter);
7676
}
7777

78+
.lengthControl {
79+
background: var(--color-surface-container-lowest);
80+
border: 1px solid var(--color-outline-variant);
81+
border-radius: var(--radius-sm);
82+
display: flex;
83+
overflow: hidden;
84+
}
85+
86+
.lengthButton {
87+
background: transparent;
88+
border: 0;
89+
color: var(--color-text-muted);
90+
cursor: pointer;
91+
font-size: var(--font-size-label);
92+
font-weight: var(--font-weight-label);
93+
min-height: 24px;
94+
padding: 0 var(--space-gutter);
95+
text-transform: uppercase;
96+
}
97+
98+
.lengthButton:hover,
99+
.lengthButton:focus-visible {
100+
background: var(--color-surface-container-high);
101+
color: var(--color-text);
102+
}
103+
104+
.lengthButtonActive {
105+
background: var(--color-primary-container);
106+
color: var(--color-on-primary-container);
107+
}
108+
78109
.clipMeta .errorMeta {
79110
border-color: var(--color-status-error);
80111
color: var(--color-status-error);

src/app/App.tsx

Lines changed: 126 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
} from "../features";
2323
import {
2424
DEFAULT_PITCHED_INSTRUMENT_ID,
25+
HYBRID_CLIP_LENGTH_BARS,
2526
addPitchedInstrumentToClip,
2627
addNoteEvent,
2728
createClipInstance,
@@ -34,7 +35,10 @@ import {
3435
createEmptyHybridClip,
3536
deleteClipInstance,
3637
deleteNoteEvent,
38+
getHybridClipBarCount,
39+
getHybridClipLengthTicks,
3740
getPitchedInstrument,
41+
hasHybridClipEventsOutsideLength,
3842
hasNoteEventsForPitchedInstrument,
3943
isAudioClip,
4044
isHybridClip,
@@ -49,6 +53,7 @@ import {
4953
updateMasterMixerState,
5054
updateDrumLaneSample,
5155
updateDrumStepSubdivision,
56+
updateHybridClipLength,
5257
updateTrackMixerState,
5358
type ArrangementLoopRange,
5459
type ArrangementTrack,
@@ -59,6 +64,7 @@ import {
5964
type DrumLaneId,
6065
type DrumStepSubdivision,
6166
type HybridClip,
67+
type HybridClipLengthBars,
6268
type MasterMixerState,
6369
type NoteEvent,
6470
type PitchedInstrumentId,
@@ -488,7 +494,10 @@ export function App() {
488494
};
489495
}, [arrangementTracks, transportMode, transportState]);
490496

491-
function commitSelectedClip(nextClip: HybridClip) {
497+
function commitSelectedClip(
498+
nextClip: HybridClip,
499+
{ syncPlayback = true }: { syncPlayback?: boolean } = {},
500+
) {
492501
const nextClips = clipsRef.current.map((clip) =>
493502
clip.id === nextClip.id ? nextClip : clip,
494503
);
@@ -497,6 +506,10 @@ export function App() {
497506
clipsRef.current = nextClips;
498507
setClips(nextClips);
499508

509+
if (!syncPlayback) {
510+
return;
511+
}
512+
500513
if (transportState === "playing" && transportMode === "song") {
501514
void updatePlayingArrangementEvents(nextClips);
502515
} else if (transportState === "playing") {
@@ -752,6 +765,59 @@ export function App() {
752765
);
753766
}
754767

768+
function handleClipLengthChange(barCount: HybridClipLengthBars) {
769+
const clip = getSelectedHybridClip();
770+
771+
if (!clip) {
772+
return;
773+
}
774+
775+
const lengthTicks = getHybridClipLengthTicks(barCount);
776+
777+
if (clip.lengthTicks === lengthTicks) {
778+
return;
779+
}
780+
781+
const shouldTrimEvents =
782+
lengthTicks < clip.lengthTicks &&
783+
hasHybridClipEventsOutsideLength({
784+
clip,
785+
lengthTicks,
786+
});
787+
788+
if (
789+
shouldTrimEvents &&
790+
!window.confirm(
791+
`Shorten ${clip.name} to ${barCount} bar${
792+
barCount === 1 ? "" : "s"
793+
}? Events outside the new length will be removed or trimmed.`,
794+
)
795+
) {
796+
return;
797+
}
798+
799+
try {
800+
const nextClip = updateHybridClipLength({
801+
clip,
802+
lengthTicks,
803+
trimEvents: shouldTrimEvents,
804+
});
805+
806+
commitSelectedClip(nextClip, {
807+
syncPlayback: transportMode === "song",
808+
});
809+
setAudioError(null);
810+
811+
if (transportState === "playing" && transportMode !== "song") {
812+
void restartPatternPlayback(nextClip);
813+
}
814+
} catch (error) {
815+
setAudioError(
816+
error instanceof Error ? error.message : "Clip length update failed.",
817+
);
818+
}
819+
}
820+
755821
function handleLaneSampleChange(
756822
laneId: DrumLaneId,
757823
sample: BundledSampleMeta,
@@ -1400,6 +1466,38 @@ export function App() {
14001466
return loopRange.startTick;
14011467
}
14021468

1469+
async function startPatternPlayback(clip: HybridClip, startTick: Tick) {
1470+
return audioEngine.startClipLoop({
1471+
loopEndTick: clip.lengthTicks,
1472+
noteEvents: noteEventsToNoteLoopEvents(
1473+
clip.noteEvents,
1474+
),
1475+
sampleEvents: drumEventsToSampleLoopEvents(
1476+
clip.drumEvents,
1477+
),
1478+
startTick,
1479+
tempoBpm: bpmRef.current,
1480+
});
1481+
}
1482+
1483+
async function restartPatternPlayback(
1484+
clip: HybridClip,
1485+
startTick = playheadTickRef.current,
1486+
) {
1487+
try {
1488+
const snapshot = await startPatternPlayback(clip, startTick);
1489+
1490+
setTransportState("playing");
1491+
commitPlayheadTick(snapshot.currentTick);
1492+
} catch (error) {
1493+
setTransportState("stopped");
1494+
commitPlayheadTick(audioEngine.stopLoop().currentTick);
1495+
setAudioError(
1496+
error instanceof Error ? error.message : "Audio playback failed.",
1497+
);
1498+
}
1499+
}
1500+
14031501
async function handleTransportStateChange(nextTransportState: TransportState) {
14041502
setAudioError(null);
14051503

@@ -1440,16 +1538,7 @@ export function App() {
14401538
setTransportState("playing");
14411539

14421540
try {
1443-
const snapshot = await audioEngine.startClipLoop({
1444-
noteEvents: noteEventsToNoteLoopEvents(
1445-
clip.noteEvents,
1446-
),
1447-
sampleEvents: drumEventsToSampleLoopEvents(
1448-
clip.drumEvents,
1449-
),
1450-
startTick,
1451-
tempoBpm: bpmRef.current,
1452-
});
1541+
const snapshot = await startPatternPlayback(clip, startTick);
14531542
commitPlayheadTick(snapshot.currentTick);
14541543
} catch (error) {
14551544
setTransportState("stopped");
@@ -1573,7 +1662,31 @@ export function App() {
15731662
<h1 className={styles.title}>{selectedHybridClip.name}</h1>
15741663
</div>
15751664
<div className={styles.clipMeta}>
1576-
<span>1 bar</span>
1665+
<div
1666+
className={styles.lengthControl}
1667+
role="group"
1668+
aria-label="Clip length"
1669+
>
1670+
{HYBRID_CLIP_LENGTH_BARS.map((barCount) => {
1671+
const isSelected =
1672+
getHybridClipBarCount(selectedHybridClip.lengthTicks) ===
1673+
barCount;
1674+
1675+
return (
1676+
<button
1677+
aria-pressed={isSelected}
1678+
className={`${styles.lengthButton} ${
1679+
isSelected ? styles.lengthButtonActive : ""
1680+
}`}
1681+
key={barCount}
1682+
onClick={() => handleClipLengthChange(barCount)}
1683+
type="button"
1684+
>
1685+
{barCount} bar{barCount === 1 ? "" : "s"}
1686+
</button>
1687+
);
1688+
})}
1689+
</div>
15771690
<span>4/4</span>
15781691
<span>PPQ 480</span>
15791692
<span>{selectedHybridClip.drumEvents.length} drum events</span>
@@ -1586,6 +1699,7 @@ export function App() {
15861699

15871700
<div className={styles.editorStack}>
15881701
<DrumSequencer
1702+
clipLengthTicks={selectedHybridClip.lengthTicks}
15891703
drumEvents={selectedHybridClip.drumEvents}
15901704
drumLanes={selectedHybridClip.drumLanes}
15911705
drumStepSubdivision={selectedHybridClip.drumStepSubdivision}

src/features/drum-sequencer/DrumSequencer.module.css

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,28 @@
5858
gap: var(--space-gutter);
5959
overflow-x: auto;
6060
padding: var(--space-panel);
61+
scrollbar-color: var(--color-primary-container) var(--color-surface-container-lowest);
62+
scrollbar-width: thin;
63+
}
64+
65+
.sequencer::-webkit-scrollbar {
66+
height: 10px;
67+
width: 10px;
68+
}
69+
70+
.sequencer::-webkit-scrollbar-track {
71+
background: var(--color-surface-container-lowest);
72+
border-top: 1px solid var(--color-outline-variant);
73+
}
74+
75+
.sequencer::-webkit-scrollbar-thumb {
76+
background: var(--color-primary-container);
77+
border: 2px solid var(--color-surface-container-lowest);
78+
border-radius: var(--radius-md);
79+
}
80+
81+
.sequencer::-webkit-scrollbar-thumb:hover {
82+
background: var(--color-primary);
6183
}
6284

6385
.beatHeader,

0 commit comments

Comments
 (0)