From f690395d779ceedbd6b22791bb17991e8f30f09b Mon Sep 17 00:00:00 2001 From: Jaewan Park Date: Sat, 13 Jun 2026 22:27:43 +0900 Subject: [PATCH 1/5] feat: enable arrangement clip placement playback --- PLANS.md | 5 +- docs/audio-engine.md | 2 + docs/data-model.md | 2 + ...arrangement-clip-placement-and-playback.md | 4 +- docs/ui-design.md | 2 + src/app/App.tsx | 315 +++++++++++++- src/audio/arrangement-events.ts | 85 ++++ src/audio/browser-audio-engine.ts | 56 ++- src/audio/index.ts | 4 + .../ArrangementView.module.css | 96 ++++- .../arrangement-view/ArrangementView.tsx | 385 ++++++++++++++---- .../project-sidebar/ProjectSidebar.tsx | 12 + src/model/arrangement.ts | 181 ++++++++ src/model/index.ts | 20 + tests/unit/audio/arrangement-events.test.ts | 144 +++++++ tests/unit/model/arrangement.test.ts | 116 ++++++ 16 files changed, 1299 insertions(+), 130 deletions(-) create mode 100644 src/audio/arrangement-events.ts create mode 100644 src/model/arrangement.ts create mode 100644 tests/unit/audio/arrangement-events.test.ts create mode 100644 tests/unit/model/arrangement.test.ts diff --git a/PLANS.md b/PLANS.md index f474ef4..f7be15c 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. #39 WAV file import as audio clip -> `docs/features/15-wav-file-import-as-audio-clip.md` -2. #41 Arrangement clip placement and playback -> `docs/features/16-arrangement-clip-placement-and-playback.md` -3. #43 Mixer audio routing and track controls -> `docs/features/17-mixer-audio-routing-and-track-controls.md` +1. #41 Arrangement clip placement and playback -> `docs/features/16-arrangement-clip-placement-and-playback.md` +2. #43 Mixer audio routing and track controls -> `docs/features/17-mixer-audio-routing-and-track-controls.md` ## Planned Milestones diff --git a/docs/audio-engine.md b/docs/audio-engine.md index a471edc..dd73305 100644 --- a/docs/audio-engine.md +++ b/docs/audio-engine.md @@ -67,6 +67,8 @@ Arrangement playback should: The first arrangement playback implementation may play linearly from tick 0 through the end of the last placed clip instance, then stop. Arrangement loop regions can be added later. +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. + Imported audio clips should play at original speed unless a later time-stretching feature explicitly changes that behavior. If an audio clip instance is shorter than the source buffer, playback should be cropped. If the instance is longer than the source buffer, playback may end naturally and leave silence. If runtime file data is missing after refresh, the engine should report a clear missing-source error rather than silently failing. Arrangement playback still uses the lookahead scheduler. UI drag state, arrangement DOM geometry, visual playheads, decoded buffers, and active source nodes remain runtime-only. diff --git a/docs/data-model.md b/docs/data-model.md index c9195d6..9149f4e 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -173,6 +173,8 @@ export interface ClipInstance { The first arrangement placement feature should create, move, select, and delete `ClipInstance` objects without mutating the source `Clip`. Deleting a placed clip from the arrangement removes only that instance. It does not delete the sidebar clip. +The first implementation keeps arrangement tracks and clip instances in app-level state. The data is still serializable and should map directly into a future `Project.tracks` and `Project.clipInstances` shape when export/import is implemented. + Default instance lengths: - Hybrid clip: use the source clip's `lengthTicks`, initially 1920 ticks for a 1-bar clip. diff --git a/docs/features/16-arrangement-clip-placement-and-playback.md b/docs/features/16-arrangement-clip-placement-and-playback.md index 4df8ecc..3ab82aa 100644 --- a/docs/features/16-arrangement-clip-placement-and-playback.md +++ b/docs/features/16-arrangement-clip-placement-and-playback.md @@ -1,7 +1,7 @@ # Feature: 16 Arrangement Clip Placement and Playback ## Status -Planned +In Review ## Goal @@ -117,6 +117,8 @@ The audio engine or feature orchestration should expand clip instances into runt The arrangement transport may initially play linearly from tick 0 through the end of the last clip instance and then stop. Arrangement loop ranges can be added later. +The first implementation may reuse the existing lookahead loop scheduler over the visible arrangement range while the arrangement-specific one-shot/linear transport matures. If so, document the limitation in the PR and keep the arrangement event expansion independent from React. + Events already scheduled inside the lookahead window may still play briefly after edits, pause, or stop. Keep the scheduling window short enough for interactive editing. ## Done when diff --git a/docs/ui-design.md b/docs/ui-design.md index 27ae1ae..a19ce92 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -113,6 +113,8 @@ Track headers, timeline lanes, and clip blocks must align exactly. Use shared ge Do not introduce real arrangement data, persisted clip instances, arrangement playback, or drag-and-drop editing in the first arrangement UI shell unless a feature spec explicitly expands the scope. +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. + ## Arrangement Clip Placement The next arrangement step should make the `SONG` view editable and playable. diff --git a/src/app/App.tsx b/src/app/App.tsx index d47db32..8d1156e 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from "react"; import { BUNDLED_DRUM_SAMPLES, createAudioEngine, + expandClipInstancesForPlayback, type BundledSampleMeta, type NoteLoopEvent, type SampleLoopEvent, @@ -22,15 +23,20 @@ import { DEFAULT_PITCHED_INSTRUMENT_ID, addPitchedInstrumentToClip, addNoteEvent, + createClipInstance, + createDefaultArrangementTracks, createImportedAudioClipDraft, createImportedAudioIds, createEmptyHybridClip, + deleteClipInstance, deleteNoteEvent, + getArrangementPlaybackEndTick, getPitchedInstrument, hasNoteEventsForPitchedInstrument, isAudioClip, isHybridClip, moveDrumLane, + moveClipInstance, moveNoteEvent, removePitchedInstrumentFromClip, renameClip, @@ -38,7 +44,9 @@ import { validateImportedWavFile, updateDrumLaneSample, updateDrumStepSubdivision, + type ArrangementTrack, type Clip, + type ClipInstance, type DrumEvent, type DrumLaneId, type DrumStepSubdivision, @@ -101,6 +109,14 @@ export function App() { createEmptyHybridClip({ id: DEFAULT_CLIP_ID, name: "Clip 1" }), ]); const clipsRef = useRef(clips); + const [arrangementTracks] = useState(() => + createDefaultArrangementTracks(), + ); + const [clipInstances, setClipInstances] = useState([]); + const clipInstancesRef = useRef(clipInstances); + const [selectedClipInstanceId, setSelectedClipInstanceId] = useState< + string | null + >(null); const [sampleMetas, setSampleMetas] = useState([]); const sampleMetasRef = useRef(sampleMetas); const [isClipImporting, setIsClipImporting] = useState(false); @@ -141,6 +157,10 @@ export function App() { selectedClipRef.current = selectedClip; }, [clips, selectedClip]); + useEffect(() => { + clipInstancesRef.current = clipInstances; + }, [clipInstances]); + useEffect(() => { sampleMetasRef.current = sampleMetas; }, [sampleMetas]); @@ -174,35 +194,35 @@ export function App() { }, [transportState]); function commitSelectedClip(nextClip: HybridClip) { - selectedClipRef.current = nextClip; - setClips((currentClips) => { - const nextClips = currentClips.map((clip) => - clip.id === nextClip.id ? nextClip : clip, - ); + const nextClips = clipsRef.current.map((clip) => + clip.id === nextClip.id ? nextClip : clip, + ); - clipsRef.current = nextClips; - return nextClips; - }); + selectedClipRef.current = nextClip; + clipsRef.current = nextClips; + setClips(nextClips); - if (transportState === "playing") { + if (transportState === "playing" && transportMode === "song") { + void updatePlayingArrangementEvents(nextClips); + } else if (transportState === "playing") { void updatePlayingClipEvents(nextClip); } } function commitAnyClip(nextClip: Clip) { - setClips((currentClips) => { - const nextClips = currentClips.map((clip) => - clip.id === nextClip.id ? nextClip : clip, - ); + const nextClips = clipsRef.current.map((clip) => + clip.id === nextClip.id ? nextClip : clip, + ); - clipsRef.current = nextClips; - return nextClips; - }); + clipsRef.current = nextClips; + setClips(nextClips); if (nextClip.id === selectedClipRef.current.id) { selectedClipRef.current = nextClip; - if (transportState === "playing" && isHybridClip(nextClip)) { + if (transportState === "playing" && transportMode === "song") { + void updatePlayingArrangementEvents(nextClips); + } else if (transportState === "playing" && isHybridClip(nextClip)) { void updatePlayingClipEvents(nextClip); } } @@ -256,6 +276,11 @@ export function App() { commitPlayheadTick(snapshot.currentTick); } + function commitClipInstances(nextClipInstances: ClipInstance[]) { + clipInstancesRef.current = nextClipInstances; + setClipInstances(nextClipInstances); + } + function getSelectedHybridClip(): HybridClip | null { const clip = selectedClipRef.current; @@ -603,20 +628,42 @@ export function App() { clipsRef.current = nextClips; setClips(nextClips); + const nextClipInstances = clipInstancesRef.current.filter( + (instance) => instance.clipId !== clipId, + ); + + commitClipInstances(nextClipInstances); + + if ( + selectedClipInstanceId && + clipInstancesRef.current.every( + (instance) => instance.id !== selectedClipInstanceId, + ) + ) { + setSelectedClipInstanceId(null); + } if (clipId === selectedClipId) { stopAudioClipPreview(); selectClipDefault(fallbackClip); - if (transportState === "playing" && isHybridClip(fallbackClip)) { + if ( + transportState === "playing" && + transportMode !== "song" && + isHybridClip(fallbackClip) + ) { void updatePlayingClipEvents(fallbackClip); - } else if (transportState === "playing") { + } else if (transportState === "playing" && transportMode !== "song") { const snapshot = audioEngine.stopLoop(); setTransportState(snapshot.status); commitPlayheadTick(snapshot.currentTick); } } + + if (transportState === "playing" && transportMode === "song") { + void updatePlayingArrangementEvents(nextClips, nextClipInstances); + } } function handleInstrumentSelect(clipId: string, instrumentId: InstrumentId) { @@ -696,6 +743,202 @@ export function App() { } } + function handleArrangementClipDrop({ + clipId, + startTick, + trackId, + }: { + clipId: string; + startTick: Tick; + trackId: string; + }) { + const clip = clipsRef.current.find((candidate) => candidate.id === clipId); + + if (!clip) { + return; + } + + const nextInstance = createClipInstance({ + clip, + existingInstanceIds: clipInstancesRef.current.map( + (instance) => instance.id, + ), + startTick, + tempoBpm: bpmRef.current, + trackId, + }); + const nextClipInstances = [...clipInstancesRef.current, nextInstance]; + + commitClipInstances(nextClipInstances); + setSelectedClipInstanceId(nextInstance.id); + setAudioError(null); + + if (transportState === "playing" && transportMode === "song") { + void updatePlayingArrangementEvents(clipsRef.current, nextClipInstances); + } + } + + function handleClipInstanceMove({ + instanceId, + startTick, + trackId, + }: { + instanceId: string; + startTick: Tick; + trackId: string; + }) { + const nextClipInstances = clipInstancesRef.current.map((instance) => + instance.id === instanceId + ? moveClipInstance({ instance, startTick, trackId }) + : instance, + ); + + commitClipInstances(nextClipInstances); + setSelectedClipInstanceId(instanceId); + + if (transportState === "playing" && transportMode === "song") { + void updatePlayingArrangementEvents(clipsRef.current, nextClipInstances); + } + } + + function handleClipInstanceDelete(instanceId: string) { + const nextClipInstances = deleteClipInstance( + clipInstancesRef.current, + instanceId, + ); + + commitClipInstances(nextClipInstances); + + if (selectedClipInstanceId === instanceId) { + setSelectedClipInstanceId(null); + } + + if (transportState === "playing" && transportMode === "song") { + void updatePlayingArrangementEvents(clipsRef.current, nextClipInstances); + } + } + + function handleTransportModeChange(nextTransportMode: TransportMode) { + if (nextTransportMode === transportMode) { + return; + } + + stopAudioClipPreview(); + + if (transportState !== "stopped") { + const snapshot = audioEngine.stopLoop(); + + setTransportState(snapshot.status); + commitPlayheadTick(snapshot.currentTick); + } + + setTransportMode(nextTransportMode); + } + + async function startArrangementPlayback(startTick: Tick) { + const currentClipInstances = clipInstancesRef.current; + + if (currentClipInstances.length === 0) { + throw new Error("Place at least one clip in the arrangement before playback."); + } + + const missingImportedAudioClipNames = getMissingImportedAudioRuntimeClipNames( + currentClipInstances, + ); + + if (missingImportedAudioClipNames.length > 0) { + throw new Error( + `Imported audio data is missing for ${missingImportedAudioClipNames.join( + ", ", + )}. Re-import the file in this session to play it.`, + ); + } + + const playbackEvents = buildArrangementPlaybackEvents({ + clipInstances: currentClipInstances, + clips: clipsRef.current, + }); + + return audioEngine.startClipLoop({ + loopEndTick: getArrangementPlaybackEndTick(currentClipInstances), + noteEvents: playbackEvents.noteEvents, + sampleEvents: playbackEvents.sampleEvents, + startTick, + tempoBpm: bpmRef.current, + }); + } + + async function updatePlayingArrangementEvents( + nextClips = clipsRef.current, + nextClipInstances = clipInstancesRef.current, + ) { + setAudioError(null); + + try { + const playbackEvents = buildArrangementPlaybackEvents({ + clipInstances: nextClipInstances, + clips: nextClips, + }); + + await audioEngine.updateClipLoopEvents({ + noteEvents: playbackEvents.noteEvents, + sampleEvents: playbackEvents.sampleEvents, + }); + } catch (error) { + setAudioError( + error instanceof Error + ? error.message + : "Arrangement playback update failed.", + ); + } + } + + function buildArrangementPlaybackEvents({ + clipInstances: instances, + clips: sourceClips, + }: { + clipInstances: readonly ClipInstance[]; + clips: readonly Clip[]; + }) { + const playbackEvents = expandClipInstancesForPlayback({ + clipInstances: instances, + clips: sourceClips, + }); + + if (playbackEvents.missingClipIds.length > 0) { + throw new Error( + `Arrangement contains missing source clips: ${playbackEvents.missingClipIds.join( + ", ", + )}.`, + ); + } + + return playbackEvents; + } + + function getMissingImportedAudioRuntimeClipNames( + instances: readonly ClipInstance[], + ): string[] { + const loadedSampleIds = new Set(audioEngine.getSnapshot().loadedSampleIds); + const missingClipNames: string[] = []; + + for (const instance of instances) { + const clip = clipsRef.current.find( + (candidate) => candidate.id === instance.clipId, + ); + + if (!clip || !isAudioClip(clip)) { + continue; + } + + if (!loadedSampleIds.has(clip.sampleId)) { + missingClipNames.push(clip.name); + } + } + + return missingClipNames; + } + async function handleTransportStateChange(nextTransportState: TransportState) { setAudioError(null); @@ -719,6 +962,24 @@ export function App() { const clip = selectedClipRef.current; stopAudioClipPreview(); + if (transportMode === "song") { + setTransportState("playing"); + + try { + const snapshot = await startArrangementPlayback(startTick); + + commitPlayheadTick(snapshot.currentTick); + } catch (error) { + setTransportState("stopped"); + commitPlayheadTick(audioEngine.stopLoop().currentTick); + setAudioError( + error instanceof Error ? error.message : "Arrangement playback failed.", + ); + } + + return; + } + if (!isHybridClip(clip)) { await handleAudioClipPreviewPlay(); return; @@ -768,7 +1029,7 @@ export function App() { bpm={bpm} mode={transportMode} onBpmChange={commitBpm} - onModeChange={setTransportMode} + onModeChange={handleTransportModeChange} onTransportStateChange={handleTransportStateChange} transportState={transportState} /> @@ -799,7 +1060,19 @@ export function App() { } > {transportMode === "song" ? ( - + ) : selectedAudioClip ? ( <>
diff --git a/src/audio/arrangement-events.ts b/src/audio/arrangement-events.ts new file mode 100644 index 0000000..8581d76 --- /dev/null +++ b/src/audio/arrangement-events.ts @@ -0,0 +1,85 @@ +import { + isAudioClip, + isHybridClip, + type Clip, + type ClipInstance, +} from "../model"; +import type { NoteLoopEvent, SampleLoopEvent } from "./types"; + +export interface ArrangementPlaybackEvents { + missingClipIds: string[]; + noteEvents: NoteLoopEvent[]; + sampleEvents: SampleLoopEvent[]; +} + +export function expandClipInstancesForPlayback({ + clipInstances, + clips, +}: { + clipInstances: readonly ClipInstance[]; + clips: readonly Clip[]; +}): ArrangementPlaybackEvents { + const clipsById = new Map(clips.map((clip) => [clip.id, clip])); + const missingClipIds: string[] = []; + const noteEvents: NoteLoopEvent[] = []; + const sampleEvents: SampleLoopEvent[] = []; + + for (const instance of clipInstances) { + const clip = clipsById.get(instance.clipId); + + if (!clip) { + missingClipIds.push(instance.clipId); + continue; + } + + if (isAudioClip(clip)) { + if (instance.lengthTicks > 0) { + sampleEvents.push({ + id: `${instance.id}:audio`, + sampleId: clip.sampleId, + startTick: instance.startTick, + }); + } + + continue; + } + + if (!isHybridClip(clip)) { + continue; + } + + for (const event of clip.drumEvents) { + if (event.startTick >= instance.lengthTicks) { + continue; + } + + sampleEvents.push({ + gain: event.velocity, + id: `${instance.id}:${event.id}`, + sampleId: event.sampleId, + startTick: instance.startTick + event.startTick, + }); + } + + for (const event of clip.noteEvents) { + if (event.startTick >= instance.lengthTicks) { + continue; + } + + noteEvents.push({ + durationTicks: event.durationTicks, + gain: event.velocity, + id: `${instance.id}:${event.id}`, + instrumentId: event.instrumentId, + midiNote: event.midiNote, + startTick: instance.startTick + event.startTick, + }); + } + } + + return { + missingClipIds, + noteEvents, + sampleEvents, + }; +} diff --git a/src/audio/browser-audio-engine.ts b/src/audio/browser-audio-engine.ts index 93f02c4..c1bbccd 100644 --- a/src/audio/browser-audio-engine.ts +++ b/src/audio/browser-audio-engine.ts @@ -67,6 +67,7 @@ export class BrowserAudioEngine implements AudioEngine { private readonly sampleCache = new Map(); private readonly loadingSamples = new Map>(); private readonly activeNoteVoices = new Set(); + private readonly activeSampleVoices = new Set(); private activeSamplePreview: ActiveSamplePreview | null = null; private audioContext: AudioContext | null = null; private sampleLoopUpdateToken = 0; @@ -268,7 +269,7 @@ export class BrowserAudioEngine implements AudioEngine { pauseLoop(): TransportSnapshot { this.sampleLoopUpdateToken += 1; - this.stopCachedSamplePreview(); + this.stopActiveSampleVoices(); this.stopActiveNoteVoices(); if (!this.clipLoopScheduler) { @@ -280,7 +281,7 @@ export class BrowserAudioEngine implements AudioEngine { stopLoop(): TransportSnapshot { this.sampleLoopUpdateToken += 1; - this.stopCachedSamplePreview(); + this.stopActiveSampleVoices(); this.stopActiveNoteVoices(); if (!this.clipLoopScheduler) { @@ -347,17 +348,13 @@ export class BrowserAudioEngine implements AudioEngine { return; } - const { gainNode, sourceNode } = this.activeSamplePreview; + const activeSamplePreview = this.activeSamplePreview; this.activeSamplePreview = null; - try { - sourceNode.stop(this.audioContext?.currentTime ?? 0); - } catch { - // The preview may have already ended. Disconnecting below is enough. - } - - disconnectAudioNode(sourceNode); - disconnectAudioNode(gainNode); + this.stopAndDisconnectSampleVoice( + activeSamplePreview, + this.audioContext?.currentTime ?? 0, + ); } private scheduleLoadedSample( @@ -379,6 +376,12 @@ export class BrowserAudioEngine implements AudioEngine { gainNode.gain.value = options.gain ?? DEFAULT_SAMPLE_GAIN; sourceNode.connect(gainNode); gainNode.connect(audioContext.destination); + const sampleVoice = { + gainNode, + sourceNode, + }; + + this.activeSampleVoices.add(sampleVoice); sourceNode.addEventListener( "ended", () => { @@ -386,6 +389,7 @@ export class BrowserAudioEngine implements AudioEngine { this.activeSamplePreview = null; } + this.activeSampleVoices.delete(sampleVoice); disconnectAudioNode(sourceNode); disconnectAudioNode(gainNode); }, @@ -395,10 +399,7 @@ export class BrowserAudioEngine implements AudioEngine { Math.max(options.when ?? audioContext.currentTime, audioContext.currentTime), ); - return { - gainNode, - sourceNode, - }; + return sampleVoice; } private schedulePitchedNote( @@ -628,6 +629,31 @@ export class BrowserAudioEngine implements AudioEngine { } } + private stopActiveSampleVoices(): void { + const currentTime = this.audioContext?.currentTime ?? 0; + + this.activeSamplePreview = null; + + for (const sampleVoice of this.activeSampleVoices) { + this.stopAndDisconnectSampleVoice(sampleVoice, currentTime); + } + } + + private stopAndDisconnectSampleVoice( + sampleVoice: ActiveSamplePreview, + when: number, + ): void { + try { + sampleVoice.sourceNode.stop(when); + } catch { + // The source may already have ended. Disconnecting below is enough. + } + + this.activeSampleVoices.delete(sampleVoice); + disconnectAudioNode(sampleVoice.sourceNode); + disconnectAudioNode(sampleVoice.gainNode); + } + private stopAndDisconnectVoice(noteVoice: ActiveNoteVoice, when: number): void { try { noteVoice.sourceNode.stop(when); diff --git a/src/audio/index.ts b/src/audio/index.ts index c18539e..c82c3ad 100644 --- a/src/audio/index.ts +++ b/src/audio/index.ts @@ -1,4 +1,5 @@ export { BrowserAudioEngine, createAudioEngine } from "./browser-audio-engine"; +export { expandClipInstancesForPlayback } from "./arrangement-events"; export { BUNDLED_DRUM_SAMPLES, BUNDLED_PIANO_SAMPLES, @@ -15,6 +16,9 @@ export { resolveSamplerPlaybackPlan, resolveSamplerVoiceRelease, } from "./sampler-sustain"; +export type { + ArrangementPlaybackEvents, +} from "./arrangement-events"; export type { LookaheadSchedulerOptions, ScheduledTickEvent, diff --git a/src/features/arrangement-view/ArrangementView.module.css b/src/features/arrangement-view/ArrangementView.module.css index eeeb583..1403cd7 100644 --- a/src/features/arrangement-view/ArrangementView.module.css +++ b/src/features/arrangement-view/ArrangementView.module.css @@ -37,6 +37,44 @@ align-items: center; display: flex; gap: var(--space-gutter); + min-width: 0; +} + +.errorBadge { + color: var(--color-error); + font-size: var(--font-size-label); + font-weight: var(--font-weight-label); + margin: 0; + max-width: 360px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.deleteButton { + 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); + cursor: pointer; + display: flex; + font-size: var(--font-size-label); + font-weight: var(--font-weight-label); + gap: var(--space-unit); + min-height: 28px; + padding: 0 var(--space-gutter); + text-transform: uppercase; +} + +.deleteButton:hover:not(:disabled) { + background: var(--color-surface-container-high); + color: var(--color-error); +} + +.deleteButton:disabled { + cursor: not-allowed; + opacity: 0.45; } .snapControl { @@ -252,16 +290,28 @@ } .clipBlock { + appearance: none; background: var(--color-surface-container); border: 1px solid var(--color-outline-variant); border-radius: var(--radius-sm); box-shadow: var(--panel-inset-shadow); color: var(--color-text); - cursor: default; + cursor: grab; display: grid; grid-template-rows: 14px minmax(0, 1fr); + font: inherit; overflow: hidden; + padding: 0; position: absolute; + text-align: left; +} + +.clipBlock:active { + cursor: grabbing; +} + +.clipBlock:hover { + filter: brightness(1.08); } .clipBlockPrimary { @@ -276,6 +326,15 @@ color: var(--color-on-secondary); } +.clipBlockSelected { + box-shadow: + 0 0 0 1px var(--color-primary-strong), + var(--panel-inset-shadow); + outline: 1px solid var(--color-primary-strong); + outline-offset: 1px; + z-index: 5; +} + .clipHeader { align-items: center; display: flex; @@ -316,6 +375,41 @@ fill: currentColor; } +.emptyState { + color: var(--color-text-dim); + font-size: var(--font-size-label); + font-weight: var(--font-weight-label); + left: var(--space-panel); + letter-spacing: var(--letter-spacing-label); + margin: 0; + pointer-events: none; + position: absolute; + text-transform: uppercase; + top: var(--space-panel); +} + +.playhead { + background: var(--color-error); + bottom: 0; + box-shadow: 0 0 8px color-mix(in srgb, var(--color-error) 60%, transparent); + pointer-events: none; + position: absolute; + top: calc(-1 * var(--arrangement-ruler-height)); + width: 1px; + z-index: 8; +} + +.playheadHandle { + background: var(--color-error); + display: block; + height: 10px; + left: 50%; + position: absolute; + top: 0; + transform: translateX(-50%) rotate(45deg); + width: 10px; +} + @media (max-width: 900px) { .toolbar { align-items: flex-start; diff --git a/src/features/arrangement-view/ArrangementView.tsx b/src/features/arrangement-view/ArrangementView.tsx index a52be80..aec051b 100644 --- a/src/features/arrangement-view/ArrangementView.tsx +++ b/src/features/arrangement-view/ArrangementView.tsx @@ -1,6 +1,21 @@ -import type { CSSProperties } from "react"; +import { + useRef, + type CSSProperties, + type DragEvent, + type KeyboardEvent, +} from "react"; import { Icon } from "../../components"; +import { + ARRANGEMENT_BAR_COUNT, + ARRANGEMENT_CLIP_DRAG_TYPE, + ARRANGEMENT_CLIP_INSTANCE_DRAG_TYPE, + type ArrangementTrack, + type Clip, + type ClipInstance, + isAudioClip, +} from "../../model"; +import { TICKS_PER_4_4_BAR, type Tick } from "../../utils"; import styles from "./ArrangementView.module.css"; import { MixerPanel } from "./MixerPanel"; @@ -8,71 +23,121 @@ const TRACK_HEADER_WIDTH = 192; const RULER_HEIGHT = 32; const BAR_WIDTH = 128; const BEATS_PER_BAR = 4; -const BAR_COUNT = 16; -const TRACK_COUNT = 12; const CLIP_ROW_INSET = 4; -const TIMELINE_WIDTH = BAR_WIDTH * BAR_COUNT; +const TIMELINE_WIDTH = BAR_WIDTH * ARRANGEMENT_BAR_COUNT; type ArrangementStyle = CSSProperties & Record<`--${string}`, string>; -interface ArrangementTrack { - id: string; - name: string; - active: boolean; +interface ArrangementViewProps { + clipInstances: readonly ClipInstance[]; + clips: readonly Clip[]; + errorMessage?: string | null; + onClipDrop: (placement: { + clipId: string; + startTick: Tick; + trackId: string; + }) => void; + onClipInstanceDelete: (instanceId: string) => void; + onClipInstanceMove: (placement: { + instanceId: string; + startTick: Tick; + trackId: string; + }) => void; + onClipInstanceSelect: (instanceId: string) => void; + playheadTick: Tick; + selectedClipInstanceId: string | null; + shouldShowPlayhead: boolean; + tracks: readonly ArrangementTrack[]; } -interface ArrangementClip { - id: string; - title: string; - kind: "audio" | "midi"; - startBeat: number; - trackIndex: number; - widthBeats: number; - color: "primary" | "secondary"; -} - -const arrangementTracks: ArrangementTrack[] = Array.from( - { length: TRACK_COUNT }, - (_, index) => ({ - active: index < 2, - id: `track-${index + 1}`, - name: `Track ${index + 1}`, - }), +const barNumbers = Array.from( + { length: ARRANGEMENT_BAR_COUNT }, + (_, index) => index + 1, ); -const arrangementClips: ArrangementClip[] = [ - { - color: "primary", - id: "clip-lead-synth", - kind: "midi", - startBeat: 0, - title: "CLIP 1", - trackIndex: 0, - widthBeats: 8, - }, - { - color: "secondary", - id: "clip-sub-bass", - kind: "midi", - startBeat: 4, - title: "CLIP 2", - trackIndex: 1, - widthBeats: 8, - }, -]; - -const barNumbers = Array.from({ length: BAR_COUNT }, (_, index) => index + 1); - -export function ArrangementView() { +export function ArrangementView({ + clipInstances, + clips, + errorMessage = null, + onClipDrop, + onClipInstanceDelete, + onClipInstanceMove, + onClipInstanceSelect, + playheadTick, + selectedClipInstanceId, + shouldShowPlayhead, + tracks, +}: ArrangementViewProps) { + const timelineGridRef = useRef(null); + const clipById = new Map(clips.map((clip) => [clip.id, clip])); + const trackIndexById = new Map( + tracks.map((track, index) => [track.id, index] as const), + ); + const activeTrackIds = new Set( + clipInstances.map((instance) => instance.trackId), + ); + const selectedClipInstance = + clipInstances.find((instance) => instance.id === selectedClipInstanceId) ?? + null; 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-track-count": `${tracks.length}`, "--arrangement-track-header-width": `${TRACK_HEADER_WIDTH}px`, - "--arrangement-track-count": `${TRACK_COUNT}`, }; + function handleTimelineDragOver(event: DragEvent) { + if (!hasArrangementDragPayload(event)) { + return; + } + + event.preventDefault(); + event.dataTransfer.dropEffect = event.dataTransfer.types.includes( + ARRANGEMENT_CLIP_INSTANCE_DRAG_TYPE, + ) + ? "move" + : "copy"; + } + + function handleTimelineDrop(event: DragEvent) { + if (!hasArrangementDragPayload(event)) { + return; + } + + event.preventDefault(); + + const position = getDropPosition(event, timelineGridRef.current, tracks); + + if (!position) { + return; + } + + const instanceId = event.dataTransfer.getData( + ARRANGEMENT_CLIP_INSTANCE_DRAG_TYPE, + ); + + if (instanceId) { + onClipInstanceMove({ + instanceId, + startTick: position.startTick, + trackId: position.trackId, + }); + return; + } + + const clipId = event.dataTransfer.getData(ARRANGEMENT_CLIP_DRAG_TYPE); + + if (clipId) { + onClipDrop({ + clipId, + startTick: position.startTick, + trackId: position.trackId, + }); + } + } + return (
ARRANGEMENT

+ {errorMessage ? ( +

{errorMessage}

+ ) : null} +
Snap - Line + Beat
@@ -98,25 +179,29 @@ export function ArrangementView() {
- {arrangementTracks.map((track) => ( -
-
+ {tracks.map((track) => { + const isTrackActive = activeTrackIds.has(track.id); + + return ( +
+
+ + {track.name} + +
+ />
-
- ))} + ); + })}
@@ -130,38 +215,95 @@ export function ArrangementView() { ))}
-
+
- + ({ + active: activeTrackIds.has(track.id), + id: track.id, + name: track.name, + }))} />
); } -function ClipContent({ kind }: { kind: ArrangementClip["kind"] }) { +function ClipContent({ kind }: { kind: "audio" | "midi" }) { if (kind === "audio") { return ( , + timelineGrid: HTMLDivElement | null, + tracks: readonly ArrangementTrack[], +): { startTick: Tick; trackId: string } | null { + if (!timelineGrid || tracks.length === 0) { + return null; + } + + const rect = timelineGrid.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + const trackHeight = rect.height / tracks.length; + const trackIndex = clamp(Math.floor(y / trackHeight), 0, tracks.length - 1); + + return { + startTick: pixelsToTicks(x), + trackId: tracks[trackIndex]?.id ?? tracks[0]!.id, + }; +} + +function handleClipBlockKeyDown({ + event, + instanceId, + onClipInstanceDelete, +}: { + event: KeyboardEvent; + instanceId: string; + onClipInstanceDelete: (instanceId: string) => void; +}) { + if (event.key !== "Delete" && event.key !== "Backspace") { + return; + } + + event.preventDefault(); + onClipInstanceDelete(instanceId); +} + +function hasArrangementDragPayload(event: DragEvent): boolean { + return ( + event.dataTransfer.types.includes(ARRANGEMENT_CLIP_DRAG_TYPE) || + event.dataTransfer.types.includes(ARRANGEMENT_CLIP_INSTANCE_DRAG_TYPE) + ); +} + +function tickToPixels(tick: Tick): number { + return (tick / TICKS_PER_4_4_BAR) * BAR_WIDTH; +} + +function pixelsToTicks(pixels: number): Tick { + return (Math.max(0, pixels) / BAR_WIDTH) * TICKS_PER_4_4_BAR; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} diff --git a/src/features/project-sidebar/ProjectSidebar.tsx b/src/features/project-sidebar/ProjectSidebar.tsx index 1141454..75f9fcd 100644 --- a/src/features/project-sidebar/ProjectSidebar.tsx +++ b/src/features/project-sidebar/ProjectSidebar.tsx @@ -2,12 +2,14 @@ import { useRef, useState, type ChangeEvent, + type DragEvent, type FormEvent, type KeyboardEvent, } from "react"; import { Icon } from "../../components"; import { + ARRANGEMENT_CLIP_DRAG_TYPE, type Clip, PITCHED_INSTRUMENTS, type PitchedInstrumentId, @@ -163,6 +165,14 @@ export function ProjectSidebar({ }); } + function handleClipDragStart( + event: DragEvent, + clipId: string, + ) { + event.dataTransfer.effectAllowed = "copy"; + event.dataTransfer.setData(ARRANGEMENT_CLIP_DRAG_TYPE, clipId); + } + return (
@@ -276,6 +286,8 @@ export function ProjectSidebar({