diff --git a/PLANS.md b/PLANS.md index 0aba113..10546f7 100644 --- a/PLANS.md +++ b/PLANS.md @@ -29,10 +29,9 @@ 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. #47 IndexedDB project persistence -> `docs/features/18-indexeddb-project-persistence.md` -2. #48 Variable hybrid clip length -> `docs/features/19-variable-hybrid-clip-length.md` -3. #49 Adjustable arrangement length -> `docs/features/20-adjustable-arrangement-length.md` -4. #50 Arrangement WAV export -> `docs/features/21-arrangement-wav-export.md` +1. #48 Variable hybrid clip length -> `docs/features/19-variable-hybrid-clip-length.md` +2. #49 Adjustable arrangement length -> `docs/features/20-adjustable-arrangement-length.md` +3. #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 773b175..b0388c7 100644 --- a/docs/audio-engine.md +++ b/docs/audio-engine.md @@ -280,7 +280,9 @@ Prefer an audio-engine API that can update active scheduler tempo while preservi ## Looping Behavior -For M1, loop playback targets a selected 1-bar clip. The default loop range is 0 to 1920 ticks. +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. + +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. 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. diff --git a/docs/data-model.md b/docs/data-model.md index c795480..3bea4f7 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -42,7 +42,11 @@ Early clips may contain both drum events and note events. This keeps the M1 edit Later, the model can evolve toward separate drum, MIDI, and audio clip types if arrangement and editing workflows need stronger separation. -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. +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. + +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. + +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. Imported WAV files should use a separate audio clip shape rather than forcing audio file state into the M1 hybrid clip fields. @@ -240,7 +244,7 @@ Examples: ## Initial Drum Clip Implementation -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`. +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. 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. diff --git a/docs/features/19-variable-hybrid-clip-length.md b/docs/features/19-variable-hybrid-clip-length.md index d57f997..cc15e41 100644 --- a/docs/features/19-variable-hybrid-clip-length.md +++ b/docs/features/19-variable-hybrid-clip-length.md @@ -1,7 +1,7 @@ # Feature: 19 Variable Hybrid Clip Length ## Status -Planned +In Review ## Goal diff --git a/docs/testing.md b/docs/testing.md index 45ea53d..8c444ee 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -25,6 +25,7 @@ CI uses `--if-present` while the repository is still before the Vite scaffold. - Arrangement scheduler event expansion from clip instances: unit tests where practical. - IndexedDB persistence adapters, migrations, and serialization boundaries: unit or integration tests with mocked storage where practical. - Project autosave/manual restore checks should verify that imported audio metadata and blobs remain separated. +- Variable hybrid clip length should cover 1, 2, and 4 bar tick lengths, editor grid derivation, shortening behavior, and arrangement default instance length. - WAV encoder header, duration, and sample conversion helpers: unit tests. - Pitched instrument metadata and sample-zone mapping: unit tests. - Tempo control and scheduler tempo update behavior: unit tests where practical. diff --git a/docs/ui-design.md b/docs/ui-design.md index 60856aa..0b2467e 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -139,12 +139,13 @@ Start with a simple snap policy, such as beat-level snapping at 480 ticks, unles ## Clip Length Controls -Hybrid clip editors should eventually expose a compact length control for 1, 2, and 4 bars. +Hybrid clip editors expose a compact length control for 1, 2, and 4 bars. - The selected length should update clip `lengthTicks`. - Drum and piano roll grids should derive from the selected clip length. - Shortening a clip should avoid silent data loss when events would fall outside the new length. - The control belongs in the clip editor header or nearby toolbar, not in the audio engine. +- Longer piano roll grids may scroll horizontally; the 1-bar view should remain compact. ## Export UI diff --git a/src/app/App.module.css b/src/app/App.module.css index 191183d..1fb3c58 100644 --- a/src/app/App.module.css +++ b/src/app/App.module.css @@ -75,6 +75,37 @@ padding: var(--space-unit) var(--space-gutter); } +.lengthControl { + background: var(--color-surface-container-lowest); + border: 1px solid var(--color-outline-variant); + border-radius: var(--radius-sm); + display: flex; + overflow: hidden; +} + +.lengthButton { + background: transparent; + border: 0; + color: var(--color-text-muted); + cursor: pointer; + font-size: var(--font-size-label); + font-weight: var(--font-weight-label); + min-height: 24px; + padding: 0 var(--space-gutter); + text-transform: uppercase; +} + +.lengthButton:hover, +.lengthButton:focus-visible { + background: var(--color-surface-container-high); + color: var(--color-text); +} + +.lengthButtonActive { + background: var(--color-primary-container); + color: var(--color-on-primary-container); +} + .clipMeta .errorMeta { border-color: var(--color-status-error); color: var(--color-status-error); diff --git a/src/app/App.tsx b/src/app/App.tsx index 5aff709..30819c1 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -22,6 +22,7 @@ import { } from "../features"; import { DEFAULT_PITCHED_INSTRUMENT_ID, + HYBRID_CLIP_LENGTH_BARS, addPitchedInstrumentToClip, addNoteEvent, createClipInstance, @@ -34,7 +35,10 @@ import { createEmptyHybridClip, deleteClipInstance, deleteNoteEvent, + getHybridClipBarCount, + getHybridClipLengthTicks, getPitchedInstrument, + hasHybridClipEventsOutsideLength, hasNoteEventsForPitchedInstrument, isAudioClip, isHybridClip, @@ -49,6 +53,7 @@ import { updateMasterMixerState, updateDrumLaneSample, updateDrumStepSubdivision, + updateHybridClipLength, updateTrackMixerState, type ArrangementLoopRange, type ArrangementTrack, @@ -59,6 +64,7 @@ import { type DrumLaneId, type DrumStepSubdivision, type HybridClip, + type HybridClipLengthBars, type MasterMixerState, type NoteEvent, type PitchedInstrumentId, @@ -488,7 +494,10 @@ export function App() { }; }, [arrangementTracks, transportMode, transportState]); - function commitSelectedClip(nextClip: HybridClip) { + function commitSelectedClip( + nextClip: HybridClip, + { syncPlayback = true }: { syncPlayback?: boolean } = {}, + ) { const nextClips = clipsRef.current.map((clip) => clip.id === nextClip.id ? nextClip : clip, ); @@ -497,6 +506,10 @@ export function App() { clipsRef.current = nextClips; setClips(nextClips); + if (!syncPlayback) { + return; + } + if (transportState === "playing" && transportMode === "song") { void updatePlayingArrangementEvents(nextClips); } else if (transportState === "playing") { @@ -752,6 +765,59 @@ export function App() { ); } + function handleClipLengthChange(barCount: HybridClipLengthBars) { + const clip = getSelectedHybridClip(); + + if (!clip) { + return; + } + + const lengthTicks = getHybridClipLengthTicks(barCount); + + if (clip.lengthTicks === lengthTicks) { + return; + } + + const shouldTrimEvents = + lengthTicks < clip.lengthTicks && + hasHybridClipEventsOutsideLength({ + clip, + lengthTicks, + }); + + if ( + shouldTrimEvents && + !window.confirm( + `Shorten ${clip.name} to ${barCount} bar${ + barCount === 1 ? "" : "s" + }? Events outside the new length will be removed or trimmed.`, + ) + ) { + return; + } + + try { + const nextClip = updateHybridClipLength({ + clip, + lengthTicks, + trimEvents: shouldTrimEvents, + }); + + commitSelectedClip(nextClip, { + syncPlayback: transportMode === "song", + }); + setAudioError(null); + + if (transportState === "playing" && transportMode !== "song") { + void restartPatternPlayback(nextClip); + } + } catch (error) { + setAudioError( + error instanceof Error ? error.message : "Clip length update failed.", + ); + } + } + function handleLaneSampleChange( laneId: DrumLaneId, sample: BundledSampleMeta, @@ -1400,6 +1466,38 @@ export function App() { return loopRange.startTick; } + async function startPatternPlayback(clip: HybridClip, startTick: Tick) { + return audioEngine.startClipLoop({ + loopEndTick: clip.lengthTicks, + noteEvents: noteEventsToNoteLoopEvents( + clip.noteEvents, + ), + sampleEvents: drumEventsToSampleLoopEvents( + clip.drumEvents, + ), + startTick, + tempoBpm: bpmRef.current, + }); + } + + async function restartPatternPlayback( + clip: HybridClip, + startTick = playheadTickRef.current, + ) { + try { + const snapshot = await startPatternPlayback(clip, startTick); + + setTransportState("playing"); + commitPlayheadTick(snapshot.currentTick); + } catch (error) { + setTransportState("stopped"); + commitPlayheadTick(audioEngine.stopLoop().currentTick); + setAudioError( + error instanceof Error ? error.message : "Audio playback failed.", + ); + } + } + async function handleTransportStateChange(nextTransportState: TransportState) { setAudioError(null); @@ -1440,16 +1538,7 @@ export function App() { setTransportState("playing"); try { - const snapshot = await audioEngine.startClipLoop({ - noteEvents: noteEventsToNoteLoopEvents( - clip.noteEvents, - ), - sampleEvents: drumEventsToSampleLoopEvents( - clip.drumEvents, - ), - startTick, - tempoBpm: bpmRef.current, - }); + const snapshot = await startPatternPlayback(clip, startTick); commitPlayheadTick(snapshot.currentTick); } catch (error) { setTransportState("stopped"); @@ -1573,7 +1662,31 @@ export function App() {

{selectedHybridClip.name}

- 1 bar +
+ {HYBRID_CLIP_LENGTH_BARS.map((barCount) => { + const isSelected = + getHybridClipBarCount(selectedHybridClip.lengthTicks) === + barCount; + + return ( + + ); + })} +
4/4 PPQ 480 {selectedHybridClip.drumEvents.length} drum events @@ -1586,6 +1699,7 @@ export function App() {
(null); const sampleButtonRefs = useRef(new Map()); const openSampleLane = drumLanes.find((lane) => lane.id === openSampleLaneId); + const barCount = getHybridClipBarCount(clipLengthTicks); + const stepCount = getDrumStepCount(clipLengthTicks); const playheadStepIndex = shouldShowPlayhead - ? getPlayheadStepIndex(playheadTick) + ? getPlayheadStepIndex(playheadTick, clipLengthTicks) : null; const subdivisionStyle = { "--drum-step-subdivision": drumStepSubdivision, @@ -158,7 +164,8 @@ export function DrumSequencer({ actions={
- 1 BAR / 16 STEPS / {drumStepSubdivision}X + {barCount} BAR{barCount === 1 ? "" : "S"} / {stepCount} STEPS /{" "} + {drumStepSubdivision}X