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

209 lines
6.9 KiB
TypeScript

'use client';
import { useState, useCallback, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { TransportControls } from './TransportControls';
import { Visualizer } from './Visualizer';
import { Mixer } from '@/components/mixer';
import { Timeline, ExportModal } from '@/components/timeline';
import { useAudioEngine } from '@/hooks/useAudioEngine';
import { useTimeline } from '@/hooks/useTimeline';
import { Slider } from '@/components/ui/slider';
import { Label } from '@/components/ui/label';
import { Download, ChevronDown, ChevronUp } from 'lucide-react';
import { LayerName } from '@/types/audio';
import { cn } from '@/lib/utils';
export function LofiGenerator() {
const {
state,
currentStep,
meterLevels,
pans,
soloed,
playheadPosition,
togglePlayback,
generateNewBeat,
setMasterVolume,
setLayerVolume,
toggleMute,
setPan,
toggleSolo,
setBpm,
setSwing,
seek,
setLoopRegion,
} = useAudioEngine();
const timeline = useTimeline();
const [showTimeline, setShowTimeline] = useState(false);
const [showExportModal, setShowExportModal] = useState(false);
useEffect(() => {
timeline.setPlayheadPosition(playheadPosition.bar, playheadPosition.beat);
}, [playheadPosition, timeline.setPlayheadPosition]);
const handleVolumeChange = useCallback(
(layer: LayerName | 'master', volume: number) => {
if (layer === 'master') {
setMasterVolume(volume);
} else {
setLayerVolume(layer, volume);
}
},
[setMasterVolume, setLayerVolume]
);
const handleExport = useCallback(async (): Promise<Blob> => {
return new Blob(['placeholder'], { type: 'audio/wav' });
}, []);
const handleLoopRegionChange = useCallback(
(start: number, end: number, enabled: boolean) => {
timeline.setLoopRegion(start, end, enabled);
setLoopRegion(start, end, enabled);
},
[timeline.setLoopRegion, setLoopRegion]
);
const volumes = {
master: state.volumes.master,
drums: state.volumes.drums,
chords: state.volumes.chords,
ambient: state.volumes.ambient,
};
return (
<div className="min-h-screen flex items-center justify-center p-4">
<Card className="w-full max-w-4xl bg-card/80 backdrop-blur-sm border-border/50">
<CardHeader className="text-center pb-2">
<CardTitle className="text-2xl font-light tracking-wide">
lofi generator
</CardTitle>
<p className="text-sm text-muted-foreground">beats to relax/study to</p>
</CardHeader>
<CardContent className="space-y-4">
<Visualizer currentStep={currentStep} isPlaying={state.isPlaying} />
<div className="flex justify-center items-center gap-2">
<TransportControls
isPlaying={state.isPlaying}
isInitialized={state.isInitialized}
onTogglePlayback={togglePlayback}
onGenerateNewBeat={generateNewBeat}
/>
<Button
variant={showTimeline ? 'default' : 'outline'}
size="sm"
onClick={() => setShowTimeline(!showTimeline)}
className="gap-1 h-9"
>
Timeline
{showTimeline ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowExportModal(true)}
className="h-9 w-9 p-0"
>
<Download className="h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">BPM</Label>
<span className="text-xs text-muted-foreground font-mono">
{state.bpm}
</span>
</div>
<Slider
value={[state.bpm]}
onValueChange={([v]) => setBpm(v)}
min={60}
max={100}
step={1}
/>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">Swing</Label>
<span className="text-xs text-muted-foreground font-mono">
{Math.round(state.swing * 100)}%
</span>
</div>
<Slider
value={[state.swing]}
onValueChange={([v]) => setSwing(v)}
min={0}
max={0.5}
step={0.01}
/>
</div>
</div>
<Mixer
volumes={volumes}
pans={pans}
muted={state.muted}
soloed={soloed}
levels={meterLevels}
onVolumeChange={handleVolumeChange}
onPanChange={setPan}
onMuteToggle={toggleMute}
onSoloToggle={toggleSolo}
/>
<div
className={cn(
'overflow-hidden transition-all duration-300 ease-in-out',
showTimeline ? 'max-h-[500px] opacity-100' : 'max-h-0 opacity-0'
)}
>
<Timeline
state={timeline.state}
bpm={state.bpm}
isPlaying={state.isPlaying}
onDurationChange={timeline.setDuration}
onSeek={seek}
onLoopRegionChange={handleLoopRegionChange}
onSectionAdd={timeline.addSection}
onSectionResize={timeline.resizeSection}
onSectionMove={timeline.moveSection}
onSectionDelete={timeline.deleteSection}
onDrumKeyframeAdd={timeline.addDrumKeyframe}
onDrumKeyframeUpdate={timeline.updateDrumKeyframe}
onDrumKeyframeDelete={timeline.deleteDrumKeyframe}
onChordKeyframeAdd={timeline.addChordKeyframe}
onChordKeyframeUpdate={timeline.updateChordKeyframe}
onChordKeyframeDelete={timeline.deleteChordKeyframe}
onMuteKeyframeToggle={timeline.toggleMuteKeyframe}
onAutomationPointAdd={timeline.addAutomationPoint}
onAutomationPointUpdate={timeline.updateAutomationPoint}
onAutomationPointDelete={timeline.deleteAutomationPoint}
/>
</div>
</CardContent>
</Card>
{showExportModal && (
<ExportModal
durationBars={timeline.state.durationBars}
bpm={state.bpm}
onExport={handleExport}
onClose={() => setShowExportModal(false)}
/>
)}
</div>
);
}