Nicholai d3158e4c6a feat(timeline-mixer): WIP timeline and mixer components
work in progress implementation of:
- mixer with channel strips, faders, pan knobs, level meters
- timeline with ruler, playhead, sections, keyframe tracks
- pattern and progression pickers for drums/chords
- automation lanes and mute tracks
- loop bracket for loop region selection
- export modal placeholder

known issues:
- drum pattern changes don't update audio engine
- timeline keyframes not connected to scheduler
- some UI bugs remain

this is a checkpoint commit for further iteration
2026-01-20 18:22:10 -07:00

306 lines
11 KiB
TypeScript

'use client';
import { useCallback, useRef, useState } from 'react';
import { Drum, Music, Cloud, Volume2, VolumeX } from 'lucide-react';
import { Toggle } from '@/components/ui/toggle';
import { TimelineRuler } from './TimelineRuler';
import { Playhead } from './Playhead';
import { TimeDisplay } from './TimeDisplay';
import { DurationSelector } from './DurationSelector';
import { LoopBracket } from './LoopBracket';
import { SectionTrack } from './SectionTrack';
import { KeyframeTrack } from './KeyframeTrack';
import { PatternPicker } from './PatternPicker';
import { ProgressionPicker } from './ProgressionPicker';
import { MuteTrack } from './MuteTrack';
import { AutomationLane } from './AutomationLane';
import { cn } from '@/lib/utils';
import {
TimelineState,
LayerName,
PatternKeyframe,
ChordKeyframe,
SectionType,
} from '@/types/audio';
interface TimelineProps {
state: TimelineState;
bpm: number;
isPlaying: boolean;
pixelsPerBar?: number;
onDurationChange: (bars: number) => void;
onSeek: (bar: number, beat: number) => void;
onLoopRegionChange: (start: number, end: number, enabled: boolean) => void;
onSectionAdd: (type: SectionType, startBar: number, endBar: number) => void;
onSectionResize: (id: string, startBar: number, endBar: number) => void;
onSectionMove: (id: string, startBar: number) => void;
onSectionDelete: (id: string) => void;
onDrumKeyframeAdd: (bar: number, patternIndex: number) => void;
onDrumKeyframeUpdate: (id: string, patternIndex: number) => void;
onDrumKeyframeDelete: (id: string) => void;
onChordKeyframeAdd: (bar: number, progressionIndex: number) => void;
onChordKeyframeUpdate: (id: string, progressionIndex: number) => void;
onChordKeyframeDelete: (id: string) => void;
onMuteKeyframeToggle: (layer: LayerName, bar: number) => void;
onAutomationPointAdd: (
layer: LayerName,
bar: number,
beat: number,
value: number
) => void;
onAutomationPointUpdate: (
layer: LayerName,
id: string,
bar: number,
beat: number,
value: number
) => void;
onAutomationPointDelete: (layer: LayerName, id: string) => void;
className?: string;
}
const PIXELS_PER_BAR = 48;
export function Timeline({
state,
bpm,
isPlaying,
pixelsPerBar = PIXELS_PER_BAR,
onDurationChange,
onSeek,
onLoopRegionChange,
onSectionAdd,
onSectionResize,
onSectionMove,
onSectionDelete,
onDrumKeyframeAdd,
onDrumKeyframeUpdate,
onDrumKeyframeDelete,
onChordKeyframeAdd,
onChordKeyframeUpdate,
onChordKeyframeDelete,
onMuteKeyframeToggle,
onAutomationPointAdd,
onAutomationPointUpdate,
onAutomationPointDelete,
className,
}: TimelineProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [selectedSectionId, setSelectedSectionId] = useState<string | null>(null);
const [selectedDrumKeyframeId, setSelectedDrumKeyframeId] = useState<string | null>(null);
const [selectedChordKeyframeId, setSelectedChordKeyframeId] = useState<string | null>(null);
const [expandedLayers, setExpandedLayers] = useState<Record<LayerName, boolean>>({
drums: false,
chords: false,
ambient: false,
});
const toggleLayerExpand = (layer: LayerName) => {
setExpandedLayers((prev) => ({ ...prev, [layer]: !prev[layer] }));
};
const handleRulerClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left - 80;
const totalBeats = x / (pixelsPerBar / 4);
const bar = Math.floor(totalBeats / 4);
const beat = Math.floor(totalBeats % 4);
if (bar >= 0 && bar < state.durationBars) {
onSeek(bar, beat);
}
},
[pixelsPerBar, state.durationBars, onSeek]
);
const totalHeight =
24 + 20 + 32 + 32 + 32 +
(expandedLayers.drums ? 48 : 0) +
(expandedLayers.chords ? 48 : 0) +
(expandedLayers.ambient ? 48 : 0) +
24 + 24 + 24;
return (
<div className={cn('flex flex-col bg-card/30 rounded-lg border border-border/50', className)}>
<div className="flex items-center justify-between p-2 border-b border-border/50">
<TimeDisplay
bar={state.playheadBar}
beat={state.playheadBeat}
durationBars={state.durationBars}
bpm={bpm}
/>
<DurationSelector value={state.durationBars} onChange={onDurationChange} />
</div>
<div ref={containerRef} className="relative overflow-x-auto overflow-y-visible">
<div className="relative" style={{ minWidth: state.durationBars * pixelsPerBar + 80 }}>
<div className="ml-20 cursor-pointer" onClick={handleRulerClick}>
<TimelineRuler durationBars={state.durationBars} pixelsPerBar={pixelsPerBar} />
</div>
<div className="ml-20">
<LoopBracket
region={state.loopRegion}
durationBars={state.durationBars}
pixelsPerBar={pixelsPerBar}
onChange={(r) => onLoopRegionChange(r.start, r.end, r.enabled)}
/>
</div>
<SectionTrack
sections={state.sections}
durationBars={state.durationBars}
pixelsPerBar={pixelsPerBar}
selectedId={selectedSectionId}
onSelect={setSelectedSectionId}
onAdd={onSectionAdd}
onResize={onSectionResize}
onMove={onSectionMove}
onDelete={onSectionDelete}
/>
<KeyframeTrack<PatternKeyframe>
label="Drums"
icon={<Drum className="h-3 w-3" />}
keyframes={state.drumKeyframes}
durationBars={state.durationBars}
pixelsPerBar={pixelsPerBar}
selectedId={selectedDrumKeyframeId}
onSelect={setSelectedDrumKeyframeId}
onAdd={(bar) => onDrumKeyframeAdd(bar, 0)}
onDelete={onDrumKeyframeDelete}
markerColor="bg-lofi-orange"
renderPicker={({ keyframe, onClose }) => (
<PatternPicker
selectedIndex={keyframe.patternIndex}
onSelect={(idx) => onDrumKeyframeUpdate(keyframe.id, idx)}
onRandom={() => onDrumKeyframeUpdate(keyframe.id, Math.floor(Math.random() * 5))}
onClose={onClose}
/>
)}
/>
<div className="flex items-center h-5 px-2 ml-20 bg-muted/5">
<Toggle
pressed={expandedLayers.drums}
onPressedChange={() => toggleLayerExpand('drums')}
size="sm"
className="h-4 px-1 text-[9px]"
>
{expandedLayers.drums ? 'Hide' : 'Auto'}
</Toggle>
</div>
{expandedLayers.drums && (
<AutomationLane
label="Vol"
icon={<Volume2 className="h-3 w-3" />}
points={state.volumeAutomation.drums}
durationBars={state.durationBars}
pixelsPerBar={pixelsPerBar}
onAddPoint={(bar, beat, value) => onAutomationPointAdd('drums', bar, beat, value)}
onUpdatePoint={(id, bar, beat, value) =>
onAutomationPointUpdate('drums', id, bar, beat, value)
}
onDeletePoint={(id) => onAutomationPointDelete('drums', id)}
/>
)}
<KeyframeTrack<ChordKeyframe>
label="Chords"
icon={<Music className="h-3 w-3" />}
keyframes={state.chordKeyframes}
durationBars={state.durationBars}
pixelsPerBar={pixelsPerBar}
selectedId={selectedChordKeyframeId}
onSelect={setSelectedChordKeyframeId}
onAdd={(bar) => onChordKeyframeAdd(bar, 0)}
onDelete={onChordKeyframeDelete}
markerColor="bg-lofi-pink"
renderPicker={({ keyframe, onClose }) => (
<ProgressionPicker
selectedIndex={keyframe.progressionIndex}
onSelect={(idx) => onChordKeyframeUpdate(keyframe.id, idx)}
onRandom={() => onChordKeyframeUpdate(keyframe.id, Math.floor(Math.random() * 5))}
onClose={onClose}
/>
)}
/>
<div className="flex items-center h-5 px-2 ml-20 bg-muted/5">
<Toggle
pressed={expandedLayers.chords}
onPressedChange={() => toggleLayerExpand('chords')}
size="sm"
className="h-4 px-1 text-[9px]"
>
{expandedLayers.chords ? 'Hide' : 'Auto'}
</Toggle>
</div>
{expandedLayers.chords && (
<AutomationLane
label="Vol"
icon={<Volume2 className="h-3 w-3" />}
points={state.volumeAutomation.chords}
durationBars={state.durationBars}
pixelsPerBar={pixelsPerBar}
onAddPoint={(bar, beat, value) => onAutomationPointAdd('chords', bar, beat, value)}
onUpdatePoint={(id, bar, beat, value) =>
onAutomationPointUpdate('chords', id, bar, beat, value)
}
onDeletePoint={(id) => onAutomationPointDelete('chords', id)}
/>
)}
<MuteTrack
label="Ambient"
icon={<Cloud className="h-3 w-3" />}
keyframes={state.muteKeyframes.ambient}
durationBars={state.durationBars}
pixelsPerBar={pixelsPerBar}
onToggle={(bar) => onMuteKeyframeToggle('ambient', bar)}
/>
<div className="flex items-center h-5 px-2 ml-20 bg-muted/5">
<Toggle
pressed={expandedLayers.ambient}
onPressedChange={() => toggleLayerExpand('ambient')}
size="sm"
className="h-4 px-1 text-[9px]"
>
{expandedLayers.ambient ? 'Hide' : 'Auto'}
</Toggle>
</div>
{expandedLayers.ambient && (
<AutomationLane
label="Vol"
icon={<Volume2 className="h-3 w-3" />}
points={state.volumeAutomation.ambient}
durationBars={state.durationBars}
pixelsPerBar={pixelsPerBar}
onAddPoint={(bar, beat, value) => onAutomationPointAdd('ambient', bar, beat, value)}
onUpdatePoint={(id, bar, beat, value) =>
onAutomationPointUpdate('ambient', id, bar, beat, value)
}
onDeletePoint={(id) => onAutomationPointDelete('ambient', id)}
/>
)}
<Playhead
bar={state.playheadBar}
beat={state.playheadBeat}
pixelsPerBar={pixelsPerBar}
height={totalHeight}
durationBars={state.durationBars}
onSeek={onSeek}
className="ml-20"
/>
</div>
</div>
</div>
);
}