forked from averyfelts/Lofi_Generator
Compare commits
1 Commits
main
...
feat/timel
| Author | SHA1 | Date | |
|---|---|---|---|
| d3158e4c6a |
@ -123,3 +123,19 @@
|
|||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: oklch(0.45 0.05 280);
|
background: oklch(0.45 0.05 280);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Override vertical slider min-height for mixer faders */
|
||||||
|
[data-orientation="vertical"][data-slot="slider"] {
|
||||||
|
min-height: unset !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make vertical slider tracks visible */
|
||||||
|
[data-orientation="vertical"][data-slot="slider-track"] {
|
||||||
|
background: oklch(0.35 0.03 280) !important;
|
||||||
|
width: 6px !important;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-orientation="vertical"][data-slot="slider-range"] {
|
||||||
|
background: oklch(0.75 0.15 50) !important;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,67 +1,127 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { TransportControls } from './TransportControls';
|
import { TransportControls } from './TransportControls';
|
||||||
import { VolumeControl } from './VolumeControl';
|
|
||||||
import { LayerMixer } from './LayerMixer';
|
|
||||||
import { Visualizer } from './Visualizer';
|
import { Visualizer } from './Visualizer';
|
||||||
|
import { Mixer } from '@/components/mixer';
|
||||||
|
import { Timeline, ExportModal } from '@/components/timeline';
|
||||||
import { useAudioEngine } from '@/hooks/useAudioEngine';
|
import { useAudioEngine } from '@/hooks/useAudioEngine';
|
||||||
|
import { useTimeline } from '@/hooks/useTimeline';
|
||||||
import { Slider } from '@/components/ui/slider';
|
import { Slider } from '@/components/ui/slider';
|
||||||
import { Label } from '@/components/ui/label';
|
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() {
|
export function LofiGenerator() {
|
||||||
const {
|
const {
|
||||||
state,
|
state,
|
||||||
currentStep,
|
currentStep,
|
||||||
|
meterLevels,
|
||||||
|
pans,
|
||||||
|
soloed,
|
||||||
|
playheadPosition,
|
||||||
togglePlayback,
|
togglePlayback,
|
||||||
generateNewBeat,
|
generateNewBeat,
|
||||||
setMasterVolume,
|
setMasterVolume,
|
||||||
setLayerVolume,
|
setLayerVolume,
|
||||||
toggleMute,
|
toggleMute,
|
||||||
|
setPan,
|
||||||
|
toggleSolo,
|
||||||
setBpm,
|
setBpm,
|
||||||
setSwing,
|
setSwing,
|
||||||
|
seek,
|
||||||
|
setLoopRegion,
|
||||||
} = useAudioEngine();
|
} = 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 (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center p-4">
|
<div className="min-h-screen flex items-center justify-center p-4">
|
||||||
<Card className="w-full max-w-md bg-card/80 backdrop-blur-sm border-border/50">
|
<Card className="w-full max-w-4xl bg-card/80 backdrop-blur-sm border-border/50">
|
||||||
<CardHeader className="text-center pb-2">
|
<CardHeader className="text-center pb-2">
|
||||||
<CardTitle className="text-2xl font-light tracking-wide">
|
<CardTitle className="text-2xl font-light tracking-wide">
|
||||||
lofi generator
|
lofi generator
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">beats to relax/study to</p>
|
||||||
beats to relax/study to
|
|
||||||
</p>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-4">
|
||||||
{/* Visualizer */}
|
|
||||||
<Visualizer currentStep={currentStep} isPlaying={state.isPlaying} />
|
<Visualizer currentStep={currentStep} isPlaying={state.isPlaying} />
|
||||||
|
|
||||||
{/* Transport Controls */}
|
<div className="flex justify-center items-center gap-2">
|
||||||
<div className="flex justify-center">
|
|
||||||
<TransportControls
|
<TransportControls
|
||||||
isPlaying={state.isPlaying}
|
isPlaying={state.isPlaying}
|
||||||
isInitialized={state.isInitialized}
|
isInitialized={state.isInitialized}
|
||||||
onTogglePlayback={togglePlayback}
|
onTogglePlayback={togglePlayback}
|
||||||
onGenerateNewBeat={generateNewBeat}
|
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>
|
||||||
|
|
||||||
{/* Master Volume */}
|
|
||||||
<div className="pt-2">
|
|
||||||
<VolumeControl
|
|
||||||
label="Master Volume"
|
|
||||||
value={state.volumes.master}
|
|
||||||
onChange={setMasterVolume}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* BPM and Swing Controls */}
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-sm text-muted-foreground">BPM</Label>
|
<Label className="text-xs text-muted-foreground">BPM</Label>
|
||||||
<span className="text-xs text-muted-foreground font-mono">
|
<span className="text-xs text-muted-foreground font-mono">
|
||||||
{state.bpm}
|
{state.bpm}
|
||||||
</span>
|
</span>
|
||||||
@ -74,9 +134,9 @@ export function LofiGenerator() {
|
|||||||
step={1}
|
step={1}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-sm text-muted-foreground">Swing</Label>
|
<Label className="text-xs text-muted-foreground">Swing</Label>
|
||||||
<span className="text-xs text-muted-foreground font-mono">
|
<span className="text-xs text-muted-foreground font-mono">
|
||||||
{Math.round(state.swing * 100)}%
|
{Math.round(state.swing * 100)}%
|
||||||
</span>
|
</span>
|
||||||
@ -91,20 +151,58 @@ export function LofiGenerator() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Layer Mixer */}
|
<Mixer
|
||||||
<LayerMixer
|
volumes={volumes}
|
||||||
volumes={state.volumes}
|
pans={pans}
|
||||||
muted={state.muted}
|
muted={state.muted}
|
||||||
onVolumeChange={setLayerVolume}
|
soloed={soloed}
|
||||||
onToggleMute={toggleMute}
|
levels={meterLevels}
|
||||||
|
onVolumeChange={handleVolumeChange}
|
||||||
|
onPanChange={setPan}
|
||||||
|
onMuteToggle={toggleMute}
|
||||||
|
onSoloToggle={toggleSolo}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Footer */}
|
<div
|
||||||
<p className="text-center text-xs text-muted-foreground/60 pt-4">
|
className={cn(
|
||||||
Click play to start the audio engine
|
'overflow-hidden transition-all duration-300 ease-in-out',
|
||||||
</p>
|
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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{showExportModal && (
|
||||||
|
<ExportModal
|
||||||
|
durationBars={timeline.state.durationBars}
|
||||||
|
bpm={state.bpm}
|
||||||
|
onExport={handleExport}
|
||||||
|
onClose={() => setShowExportModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
102
components/mixer/ChannelStrip.tsx
Normal file
102
components/mixer/ChannelStrip.tsx
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { VerticalFader } from './VerticalFader';
|
||||||
|
import { LevelMeter } from './LevelMeter';
|
||||||
|
import { PanKnob } from './PanKnob';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface ChannelStripProps {
|
||||||
|
name: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
volume: number;
|
||||||
|
pan: number;
|
||||||
|
muted: boolean;
|
||||||
|
soloed: boolean;
|
||||||
|
level: number;
|
||||||
|
onVolumeChange: (volume: number) => void;
|
||||||
|
onPanChange: (pan: number) => void;
|
||||||
|
onMuteToggle: () => void;
|
||||||
|
onSoloToggle: () => void;
|
||||||
|
showPan?: boolean;
|
||||||
|
showSolo?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChannelStrip({
|
||||||
|
name,
|
||||||
|
icon,
|
||||||
|
volume,
|
||||||
|
pan,
|
||||||
|
muted,
|
||||||
|
soloed,
|
||||||
|
level,
|
||||||
|
onVolumeChange,
|
||||||
|
onPanChange,
|
||||||
|
onMuteToggle,
|
||||||
|
onSoloToggle,
|
||||||
|
showPan = true,
|
||||||
|
showSolo = true,
|
||||||
|
className,
|
||||||
|
}: ChannelStripProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col items-center gap-1.5 p-2 rounded-lg bg-card/50 border border-border/50',
|
||||||
|
'min-w-[64px]',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
{icon}
|
||||||
|
<span className="font-medium">{name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showPan && <PanKnob value={pan} onChange={onPanChange} size={24} />}
|
||||||
|
|
||||||
|
<div className="flex gap-1.5 items-end">
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<VerticalFader
|
||||||
|
value={volume}
|
||||||
|
onChange={onVolumeChange}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
height={64}
|
||||||
|
/>
|
||||||
|
<span className="text-[9px] font-mono text-muted-foreground">
|
||||||
|
{Math.round(volume * 100)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LevelMeter level={muted ? 0 : level} className="h-16" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{showSolo && (
|
||||||
|
<Button
|
||||||
|
variant={soloed ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
'h-6 w-6 p-0 text-[10px] font-bold',
|
||||||
|
soloed && 'bg-yellow-500 hover:bg-yellow-600 text-black'
|
||||||
|
)}
|
||||||
|
onClick={onSoloToggle}
|
||||||
|
>
|
||||||
|
S
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant={muted ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
'h-6 w-6 p-0 text-[10px] font-bold',
|
||||||
|
muted && 'bg-red-500 hover:bg-red-600'
|
||||||
|
)}
|
||||||
|
onClick={onMuteToggle}
|
||||||
|
>
|
||||||
|
M
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
components/mixer/LevelMeter.tsx
Normal file
52
components/mixer/LevelMeter.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface LevelMeterProps {
|
||||||
|
level: number;
|
||||||
|
className?: string;
|
||||||
|
orientation?: 'vertical' | 'horizontal';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LevelMeter({
|
||||||
|
level,
|
||||||
|
className,
|
||||||
|
orientation = 'vertical',
|
||||||
|
}: LevelMeterProps) {
|
||||||
|
const segments = useMemo(() => {
|
||||||
|
const count = 8;
|
||||||
|
const filled = Math.round(level * count);
|
||||||
|
return Array.from({ length: count }, (_, i) => {
|
||||||
|
const segmentLevel = (i + 1) / count;
|
||||||
|
const isActive = i < filled;
|
||||||
|
let color = 'bg-emerald-500';
|
||||||
|
if (segmentLevel > 0.85) color = 'bg-red-500';
|
||||||
|
else if (segmentLevel > 0.7) color = 'bg-yellow-500';
|
||||||
|
return { isActive, color };
|
||||||
|
});
|
||||||
|
}, [level]);
|
||||||
|
|
||||||
|
const isVertical = orientation === 'vertical';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex gap-0.5',
|
||||||
|
isVertical ? 'flex-col-reverse w-2' : 'flex-row h-2 w-16',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{segments.map((seg, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 rounded-sm transition-opacity duration-75',
|
||||||
|
seg.isActive ? seg.color : 'bg-muted/30',
|
||||||
|
seg.isActive ? 'opacity-100' : 'opacity-50'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
components/mixer/Mixer.tsx
Normal file
78
components/mixer/Mixer.tsx
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Drum, Music, Cloud, Volume2 } from 'lucide-react';
|
||||||
|
import { ChannelStrip } from './ChannelStrip';
|
||||||
|
import { LayerName, MeterLevels } from '@/types/audio';
|
||||||
|
|
||||||
|
interface MixerProps {
|
||||||
|
volumes: Record<LayerName | 'master', number>;
|
||||||
|
pans: Record<LayerName, number>;
|
||||||
|
muted: Record<LayerName, boolean>;
|
||||||
|
soloed: Record<LayerName, boolean>;
|
||||||
|
levels: MeterLevels;
|
||||||
|
onVolumeChange: (layer: LayerName | 'master', volume: number) => void;
|
||||||
|
onPanChange: (layer: LayerName, pan: number) => void;
|
||||||
|
onMuteToggle: (layer: LayerName) => void;
|
||||||
|
onSoloToggle: (layer: LayerName) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const layerConfig: {
|
||||||
|
name: LayerName;
|
||||||
|
label: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
}[] = [
|
||||||
|
{ name: 'drums', label: 'Drums', icon: <Drum className="h-3 w-3" /> },
|
||||||
|
{ name: 'chords', label: 'Chords', icon: <Music className="h-3 w-3" /> },
|
||||||
|
{ name: 'ambient', label: 'Ambient', icon: <Cloud className="h-3 w-3" /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Mixer({
|
||||||
|
volumes,
|
||||||
|
pans,
|
||||||
|
muted,
|
||||||
|
soloed,
|
||||||
|
levels,
|
||||||
|
onVolumeChange,
|
||||||
|
onPanChange,
|
||||||
|
onMuteToggle,
|
||||||
|
onSoloToggle,
|
||||||
|
}: MixerProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 overflow-x-auto pb-2">
|
||||||
|
{layerConfig.map(({ name, label, icon }) => (
|
||||||
|
<ChannelStrip
|
||||||
|
key={name}
|
||||||
|
name={label}
|
||||||
|
icon={icon}
|
||||||
|
volume={volumes[name]}
|
||||||
|
pan={pans[name]}
|
||||||
|
muted={muted[name]}
|
||||||
|
soloed={soloed[name]}
|
||||||
|
level={levels[name]}
|
||||||
|
onVolumeChange={(v) => onVolumeChange(name, v)}
|
||||||
|
onPanChange={(p) => onPanChange(name, p)}
|
||||||
|
onMuteToggle={() => onMuteToggle(name)}
|
||||||
|
onSoloToggle={() => onSoloToggle(name)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="w-px bg-border/50 mx-1" />
|
||||||
|
|
||||||
|
<ChannelStrip
|
||||||
|
name="Master"
|
||||||
|
icon={<Volume2 className="h-3 w-3" />}
|
||||||
|
volume={volumes.master}
|
||||||
|
pan={0}
|
||||||
|
muted={false}
|
||||||
|
soloed={false}
|
||||||
|
level={levels.master}
|
||||||
|
onVolumeChange={(v) => onVolumeChange('master', v)}
|
||||||
|
onPanChange={() => {}}
|
||||||
|
onMuteToggle={() => {}}
|
||||||
|
onSoloToggle={() => {}}
|
||||||
|
showPan={false}
|
||||||
|
showSolo={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
components/mixer/PanKnob.tsx
Normal file
81
components/mixer/PanKnob.tsx
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface PanKnobProps {
|
||||||
|
value: number;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
className?: string;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PanKnob({ value, onChange, className, size = 32 }: PanKnobProps) {
|
||||||
|
const knobRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const startY = useRef(0);
|
||||||
|
const startValue = useRef(0);
|
||||||
|
|
||||||
|
const rotation = value * 135;
|
||||||
|
|
||||||
|
const handleMouseDown = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(true);
|
||||||
|
startY.current = e.clientY;
|
||||||
|
startValue.current = value;
|
||||||
|
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
const delta = (startY.current - e.clientY) / 100;
|
||||||
|
const newValue = Math.max(-1, Math.min(1, startValue.current + delta));
|
||||||
|
onChange(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setIsDragging(false);
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
},
|
||||||
|
[value, onChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDoubleClick = useCallback(() => {
|
||||||
|
onChange(0);
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
|
const label = value === 0 ? 'C' : value < 0 ? 'L' : 'R';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col items-center gap-1', className)}>
|
||||||
|
<div
|
||||||
|
ref={knobRef}
|
||||||
|
className={cn(
|
||||||
|
'relative rounded-full bg-muted border-2 border-border cursor-ns-resize',
|
||||||
|
'hover:border-primary/50 transition-colors',
|
||||||
|
isDragging && 'border-primary'
|
||||||
|
)}
|
||||||
|
style={{ width: size, height: size }}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onDoubleClick={handleDoubleClick}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute top-1 left-1/2 w-0.5 h-2 bg-primary rounded-full -translate-x-1/2 origin-bottom"
|
||||||
|
style={{
|
||||||
|
transform: `translateX(-50%) rotate(${rotation}deg)`,
|
||||||
|
transformOrigin: `50% ${size / 2 - 4}px`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 flex items-center justify-center text-[8px] font-mono text-muted-foreground"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-muted-foreground uppercase">Pan</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
components/mixer/VerticalFader.tsx
Normal file
113
components/mixer/VerticalFader.tsx
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface VerticalFaderProps {
|
||||||
|
value: number;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
height?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VerticalFader({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
min = 0,
|
||||||
|
max = 1,
|
||||||
|
height = 64,
|
||||||
|
className,
|
||||||
|
}: VerticalFaderProps) {
|
||||||
|
const trackRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
|
const normalizedValue = (value - min) / (max - min);
|
||||||
|
|
||||||
|
const handleMove = useCallback(
|
||||||
|
(clientY: number) => {
|
||||||
|
if (!trackRef.current) return;
|
||||||
|
|
||||||
|
const rect = trackRef.current.getBoundingClientRect();
|
||||||
|
const y = clientY - rect.top;
|
||||||
|
const percentage = 1 - Math.max(0, Math.min(1, y / rect.height));
|
||||||
|
const newValue = min + percentage * (max - min);
|
||||||
|
onChange(newValue);
|
||||||
|
},
|
||||||
|
[min, max, onChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMouseDown = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(true);
|
||||||
|
handleMove(e.clientY);
|
||||||
|
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
handleMove(e.clientY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setIsDragging(false);
|
||||||
|
window.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
window.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', handleMouseMove);
|
||||||
|
window.addEventListener('mouseup', handleMouseUp);
|
||||||
|
},
|
||||||
|
[handleMove]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTouchStart = useCallback(
|
||||||
|
(e: React.TouchEvent) => {
|
||||||
|
setIsDragging(true);
|
||||||
|
handleMove(e.touches[0].clientY);
|
||||||
|
|
||||||
|
const handleTouchMove = (e: TouchEvent) => {
|
||||||
|
handleMove(e.touches[0].clientY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = () => {
|
||||||
|
setIsDragging(false);
|
||||||
|
window.removeEventListener('touchmove', handleTouchMove);
|
||||||
|
window.removeEventListener('touchend', handleTouchEnd);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('touchmove', handleTouchMove);
|
||||||
|
window.addEventListener('touchend', handleTouchEnd);
|
||||||
|
},
|
||||||
|
[handleMove]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={trackRef}
|
||||||
|
className={cn(
|
||||||
|
'relative w-3 rounded-full cursor-pointer select-none',
|
||||||
|
'bg-border/60',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{ height }}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
>
|
||||||
|
{/* Fill */}
|
||||||
|
<div
|
||||||
|
className="absolute bottom-0 left-0 right-0 rounded-full bg-primary transition-all duration-75"
|
||||||
|
style={{ height: `${normalizedValue * 100}%` }}
|
||||||
|
/>
|
||||||
|
{/* Thumb */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute left-1/2 -translate-x-1/2 w-4 h-4 rounded-full',
|
||||||
|
'bg-white border-2 border-primary shadow-md',
|
||||||
|
'transition-transform duration-75',
|
||||||
|
isDragging && 'scale-110'
|
||||||
|
)}
|
||||||
|
style={{ bottom: `calc(${normalizedValue * 100}% - 8px)` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
components/mixer/index.ts
Normal file
5
components/mixer/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export { Mixer } from './Mixer';
|
||||||
|
export { ChannelStrip } from './ChannelStrip';
|
||||||
|
export { LevelMeter } from './LevelMeter';
|
||||||
|
export { PanKnob } from './PanKnob';
|
||||||
|
export { VerticalFader } from './VerticalFader';
|
||||||
197
components/timeline/AutomationLane.tsx
Normal file
197
components/timeline/AutomationLane.tsx
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { AutomationPoint } from '@/types/audio';
|
||||||
|
|
||||||
|
interface AutomationLaneProps {
|
||||||
|
label: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
points: AutomationPoint[];
|
||||||
|
durationBars: number;
|
||||||
|
pixelsPerBar: number;
|
||||||
|
height?: number;
|
||||||
|
onAddPoint: (bar: number, beat: number, value: number) => void;
|
||||||
|
onUpdatePoint: (id: string, bar: number, beat: number, value: number) => void;
|
||||||
|
onDeletePoint: (id: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AutomationLane({
|
||||||
|
label,
|
||||||
|
icon,
|
||||||
|
points,
|
||||||
|
durationBars,
|
||||||
|
pixelsPerBar,
|
||||||
|
height = 48,
|
||||||
|
onAddPoint,
|
||||||
|
onUpdatePoint,
|
||||||
|
onDeletePoint,
|
||||||
|
className,
|
||||||
|
}: AutomationLaneProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [draggingId, setDraggingId] = useState<string | null>(null);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const pixelsPerBeat = pixelsPerBar / 4;
|
||||||
|
const width = durationBars * pixelsPerBar;
|
||||||
|
|
||||||
|
const sortedPoints = useMemo(() => {
|
||||||
|
return [...points].sort((a, b) => {
|
||||||
|
if (a.bar !== b.bar) return a.bar - b.bar;
|
||||||
|
return a.beat - b.beat;
|
||||||
|
});
|
||||||
|
}, [points]);
|
||||||
|
|
||||||
|
const pathD = useMemo(() => {
|
||||||
|
if (sortedPoints.length === 0) return '';
|
||||||
|
|
||||||
|
const toX = (bar: number, beat: number) => bar * pixelsPerBar + beat * pixelsPerBeat;
|
||||||
|
const toY = (value: number) => height - value * height;
|
||||||
|
|
||||||
|
let d = `M 0 ${toY(sortedPoints[0]?.value ?? 0.5)}`;
|
||||||
|
|
||||||
|
if (sortedPoints.length > 0) {
|
||||||
|
d = `M ${toX(sortedPoints[0].bar, sortedPoints[0].beat)} ${toY(sortedPoints[0].value)}`;
|
||||||
|
|
||||||
|
for (let i = 1; i < sortedPoints.length; i++) {
|
||||||
|
const p = sortedPoints[i];
|
||||||
|
d += ` L ${toX(p.bar, p.beat)} ${toY(p.value)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return d;
|
||||||
|
}, [sortedPoints, pixelsPerBar, pixelsPerBeat, height]);
|
||||||
|
|
||||||
|
const handleContainerClick = useCallback(
|
||||||
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (draggingId) return;
|
||||||
|
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
|
||||||
|
const totalBeats = x / pixelsPerBeat;
|
||||||
|
const bar = Math.floor(totalBeats / 4);
|
||||||
|
const beat = Math.floor(totalBeats % 4);
|
||||||
|
const value = Math.max(0, Math.min(1, 1 - y / height));
|
||||||
|
|
||||||
|
if (bar >= 0 && bar < durationBars) {
|
||||||
|
onAddPoint(bar, beat, value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[draggingId, pixelsPerBeat, height, durationBars, onAddPoint]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePointMouseDown = useCallback(
|
||||||
|
(point: AutomationPoint, e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setDraggingId(point.id);
|
||||||
|
setSelectedId(point.id);
|
||||||
|
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
const rect = containerRef.current.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
|
||||||
|
const totalBeats = Math.max(0, x / pixelsPerBeat);
|
||||||
|
const bar = Math.min(durationBars - 1, Math.floor(totalBeats / 4));
|
||||||
|
const beat = Math.floor(totalBeats % 4);
|
||||||
|
const value = Math.max(0, Math.min(1, 1 - y / height));
|
||||||
|
|
||||||
|
onUpdatePoint(point.id, bar, beat, value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setDraggingId(null);
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
},
|
||||||
|
[pixelsPerBeat, height, durationBars, onUpdatePoint]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePointContextMenu = useCallback(
|
||||||
|
(point: AutomationPoint, e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onDeletePoint(point.id);
|
||||||
|
},
|
||||||
|
[onDeletePoint]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('relative', className)}>
|
||||||
|
<div className="absolute left-0 top-0 bottom-0 w-20 flex items-center gap-1.5 px-2 bg-card/50 border-r border-border/50 z-10">
|
||||||
|
{icon}
|
||||||
|
<span className="text-xs font-medium text-muted-foreground truncate">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="ml-20 bg-muted/10 border-b border-border/30 cursor-crosshair relative"
|
||||||
|
style={{ width, height }}
|
||||||
|
onClick={handleContainerClick}
|
||||||
|
>
|
||||||
|
{Array.from({ length: durationBars }, (_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={cn(
|
||||||
|
'absolute top-0 bottom-0 border-l',
|
||||||
|
i % 4 === 0 ? 'border-border/30' : 'border-border/10'
|
||||||
|
)}
|
||||||
|
style={{ left: i * pixelsPerBar }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<svg
|
||||||
|
className="absolute inset-0 pointer-events-none"
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
>
|
||||||
|
{pathD && (
|
||||||
|
<path
|
||||||
|
d={pathD}
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--lofi-orange)"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{sortedPoints.map((point) => {
|
||||||
|
const x = point.bar * pixelsPerBar + point.beat * pixelsPerBeat;
|
||||||
|
const y = height - point.value * height;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={point.id}
|
||||||
|
className={cn(
|
||||||
|
'absolute w-3 h-3 rounded-full cursor-move',
|
||||||
|
'transform -translate-x-1/2 -translate-y-1/2',
|
||||||
|
'border-2 transition-transform',
|
||||||
|
selectedId === point.id
|
||||||
|
? 'bg-lofi-orange border-white scale-125'
|
||||||
|
: 'bg-lofi-orange/80 border-lofi-orange hover:scale-110',
|
||||||
|
draggingId === point.id && 'scale-125'
|
||||||
|
)}
|
||||||
|
style={{ left: x, top: y }}
|
||||||
|
onMouseDown={(e) => handlePointMouseDown(point, e)}
|
||||||
|
onContextMenu={(e) => handlePointContextMenu(point, e)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
components/timeline/DurationSelector.tsx
Normal file
32
components/timeline/DurationSelector.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface DurationSelectorProps {
|
||||||
|
value: number;
|
||||||
|
onChange: (bars: number) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const presets = [8, 16, 32, 64];
|
||||||
|
|
||||||
|
export function DurationSelector({ value, onChange, className }: DurationSelectorProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center gap-1', className)}>
|
||||||
|
<span className="text-xs text-muted-foreground mr-1">Duration:</span>
|
||||||
|
{presets.map((bars) => (
|
||||||
|
<Button
|
||||||
|
key={bars}
|
||||||
|
variant={value === bars ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-2 text-xs"
|
||||||
|
onClick={() => onChange(bars)}
|
||||||
|
>
|
||||||
|
{bars}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
<span className="text-xs text-muted-foreground ml-1">bars</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
163
components/timeline/ExportModal.tsx
Normal file
163
components/timeline/ExportModal.tsx
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Download, X, Loader2 } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface ExportModalProps {
|
||||||
|
durationBars: number;
|
||||||
|
bpm: number;
|
||||||
|
onExport: () => Promise<Blob>;
|
||||||
|
onClose: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExportModal({
|
||||||
|
durationBars,
|
||||||
|
bpm,
|
||||||
|
onExport,
|
||||||
|
onClose,
|
||||||
|
className,
|
||||||
|
}: ExportModalProps) {
|
||||||
|
const [filename, setFilename] = useState('lofi-beat');
|
||||||
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const secondsPerBar = (60 / bpm) * 4;
|
||||||
|
const totalSeconds = durationBars * secondsPerBar;
|
||||||
|
const estimatedSizeKB = Math.round(totalSeconds * 44.1 * 2 * 2);
|
||||||
|
const estimatedSizeMB = (estimatedSizeKB / 1024).toFixed(1);
|
||||||
|
|
||||||
|
const formatDuration = (seconds: number) => {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExport = useCallback(async () => {
|
||||||
|
setIsExporting(true);
|
||||||
|
setError(null);
|
||||||
|
setProgress(0);
|
||||||
|
|
||||||
|
const progressInterval = setInterval(() => {
|
||||||
|
setProgress((p) => Math.min(p + Math.random() * 10, 90));
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const blob = await onExport();
|
||||||
|
setProgress(100);
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${filename}.wav`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
setTimeout(onClose, 500);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Export failed');
|
||||||
|
} finally {
|
||||||
|
clearInterval(progressInterval);
|
||||||
|
setIsExporting(false);
|
||||||
|
}
|
||||||
|
}, [filename, onExport, onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||||
|
<Card className={cn('w-full max-w-sm', className)}>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-lg">Export Audio</CardTitle>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={onClose}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="filename">Filename</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
id="filename"
|
||||||
|
type="text"
|
||||||
|
value={filename}
|
||||||
|
onChange={(e) => setFilename(e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 px-3 py-2 text-sm rounded-md bg-muted border border-border',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-primary'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">.wav</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Duration:</span>
|
||||||
|
<span className="ml-2 font-mono">{formatDuration(totalSeconds)}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Size:</span>
|
||||||
|
<span className="ml-2 font-mono">~{estimatedSizeMB} MB</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Format:</span>
|
||||||
|
<span className="ml-2">WAV 44.1kHz 16-bit</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Bars:</span>
|
||||||
|
<span className="ml-2 font-mono">{durationBars}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExporting && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Rendering...</span>
|
||||||
|
<span className="font-mono">{Math.round(progress)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-muted rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary transition-all duration-200"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-2 text-sm text-red-500 bg-red-500/10 rounded-md">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={isExporting || !filename}
|
||||||
|
>
|
||||||
|
{isExporting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
Rendering...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
Export
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
components/timeline/KeyframeMarker.tsx
Normal file
56
components/timeline/KeyframeMarker.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface KeyframeMarkerProps {
|
||||||
|
bar: number;
|
||||||
|
pixelsPerBar: number;
|
||||||
|
color?: string;
|
||||||
|
selected?: boolean;
|
||||||
|
onSelect?: () => void;
|
||||||
|
onDelete?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KeyframeMarker({
|
||||||
|
bar,
|
||||||
|
pixelsPerBar,
|
||||||
|
color = 'bg-primary',
|
||||||
|
selected = false,
|
||||||
|
onSelect,
|
||||||
|
onDelete,
|
||||||
|
className,
|
||||||
|
}: KeyframeMarkerProps) {
|
||||||
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSelect?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContextMenu = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute top-1/2 -translate-y-1/2 cursor-pointer',
|
||||||
|
'transition-transform hover:scale-125',
|
||||||
|
selected && 'scale-125',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{ left: bar * pixelsPerBar }}
|
||||||
|
onClick={handleClick}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-3 h-3 rotate-45 border-2',
|
||||||
|
color,
|
||||||
|
selected ? 'border-white shadow-lg' : 'border-transparent'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
components/timeline/KeyframeTrack.tsx
Normal file
137
components/timeline/KeyframeTrack.tsx
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { KeyframeMarker } from './KeyframeMarker';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface Keyframe {
|
||||||
|
id: string;
|
||||||
|
bar: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KeyframeTrackProps<T extends Keyframe> {
|
||||||
|
label: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
keyframes: T[];
|
||||||
|
durationBars: number;
|
||||||
|
pixelsPerBar: number;
|
||||||
|
selectedId: string | null;
|
||||||
|
onSelect: (id: string | null) => void;
|
||||||
|
onAdd: (bar: number) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
markerColor?: string;
|
||||||
|
renderPicker?: (props: {
|
||||||
|
keyframe: T;
|
||||||
|
onClose: () => void;
|
||||||
|
position: { x: number; y: number };
|
||||||
|
}) => React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KeyframeTrack<T extends Keyframe>({
|
||||||
|
label,
|
||||||
|
icon,
|
||||||
|
keyframes,
|
||||||
|
durationBars,
|
||||||
|
pixelsPerBar,
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
onAdd,
|
||||||
|
onDelete,
|
||||||
|
markerColor = 'bg-primary',
|
||||||
|
renderPicker,
|
||||||
|
className,
|
||||||
|
}: KeyframeTrackProps<T>) {
|
||||||
|
const [pickerPosition, setPickerPosition] = useState<{ x: number; y: number } | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTrackClick = useCallback(
|
||||||
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const bar = Math.floor(x / pixelsPerBar);
|
||||||
|
|
||||||
|
if (bar >= 0 && bar < durationBars) {
|
||||||
|
const existingKeyframe = keyframes.find((kf) => kf.bar === bar);
|
||||||
|
if (existingKeyframe) {
|
||||||
|
onSelect(existingKeyframe.id);
|
||||||
|
setPickerPosition({ x: bar * pixelsPerBar, y: 0 });
|
||||||
|
} else {
|
||||||
|
onAdd(bar);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[pixelsPerBar, durationBars, keyframes, onSelect, onAdd]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMarkerSelect = useCallback(
|
||||||
|
(keyframe: T) => {
|
||||||
|
onSelect(keyframe.id);
|
||||||
|
setPickerPosition({ x: keyframe.bar * pixelsPerBar, y: 0 });
|
||||||
|
},
|
||||||
|
[onSelect, pixelsPerBar]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClosePicker = useCallback(() => {
|
||||||
|
onSelect(null);
|
||||||
|
setPickerPosition(null);
|
||||||
|
}, [onSelect]);
|
||||||
|
|
||||||
|
const selectedKeyframe = selectedId
|
||||||
|
? keyframes.find((kf) => kf.id === selectedId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('relative', className)}>
|
||||||
|
<div className="absolute left-0 top-0 bottom-0 w-20 flex items-center gap-1.5 px-2 bg-card/50 border-r border-border/50 z-10">
|
||||||
|
{icon}
|
||||||
|
<span className="text-xs font-medium text-muted-foreground truncate">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="ml-20 h-8 bg-muted/10 border-b border-border/30 cursor-pointer relative"
|
||||||
|
style={{ width: durationBars * pixelsPerBar }}
|
||||||
|
onClick={handleTrackClick}
|
||||||
|
>
|
||||||
|
{Array.from({ length: durationBars }, (_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={cn(
|
||||||
|
'absolute top-0 bottom-0 border-l',
|
||||||
|
i % 4 === 0 ? 'border-border/30' : 'border-border/10'
|
||||||
|
)}
|
||||||
|
style={{ left: i * pixelsPerBar }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{keyframes.map((kf) => (
|
||||||
|
<KeyframeMarker
|
||||||
|
key={kf.id}
|
||||||
|
bar={kf.bar}
|
||||||
|
pixelsPerBar={pixelsPerBar}
|
||||||
|
color={markerColor}
|
||||||
|
selected={kf.id === selectedId}
|
||||||
|
onSelect={() => handleMarkerSelect(kf)}
|
||||||
|
onDelete={() => onDelete(kf.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{selectedKeyframe && pickerPosition && renderPicker && (
|
||||||
|
<div
|
||||||
|
className="absolute top-full mt-1"
|
||||||
|
style={{ left: Math.min(pickerPosition.x, durationBars * pixelsPerBar - 256) }}
|
||||||
|
>
|
||||||
|
{renderPicker({
|
||||||
|
keyframe: selectedKeyframe,
|
||||||
|
onClose: handleClosePicker,
|
||||||
|
position: pickerPosition,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
components/timeline/LoopBracket.tsx
Normal file
127
components/timeline/LoopBracket.tsx
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Repeat } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { LoopRegion } from '@/types/audio';
|
||||||
|
|
||||||
|
interface LoopBracketProps {
|
||||||
|
region: LoopRegion;
|
||||||
|
durationBars: number;
|
||||||
|
pixelsPerBar: number;
|
||||||
|
onChange: (region: LoopRegion) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoopBracket({
|
||||||
|
region,
|
||||||
|
durationBars,
|
||||||
|
pixelsPerBar,
|
||||||
|
onChange,
|
||||||
|
className,
|
||||||
|
}: LoopBracketProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [dragging, setDragging] = useState<'start' | 'end' | 'region' | null>(null);
|
||||||
|
const dragStart = useRef({ x: 0, region: { ...region } });
|
||||||
|
|
||||||
|
const left = region.start * pixelsPerBar;
|
||||||
|
const width = (region.end - region.start) * pixelsPerBar;
|
||||||
|
|
||||||
|
const handleMouseDown = useCallback(
|
||||||
|
(type: 'start' | 'end' | 'region', e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragging(type);
|
||||||
|
dragStart.current = { x: e.clientX, region: { ...region } };
|
||||||
|
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
const deltaX = e.clientX - dragStart.current.x;
|
||||||
|
const deltaBars = Math.round(deltaX / pixelsPerBar);
|
||||||
|
const { start, end, enabled } = dragStart.current.region;
|
||||||
|
|
||||||
|
let newStart = start;
|
||||||
|
let newEnd = end;
|
||||||
|
|
||||||
|
if (type === 'start') {
|
||||||
|
newStart = Math.max(0, Math.min(end - 1, start + deltaBars));
|
||||||
|
} else if (type === 'end') {
|
||||||
|
newEnd = Math.max(start + 1, Math.min(durationBars, end + deltaBars));
|
||||||
|
} else if (type === 'region') {
|
||||||
|
const duration = end - start;
|
||||||
|
newStart = Math.max(0, Math.min(durationBars - duration, start + deltaBars));
|
||||||
|
newEnd = newStart + duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange({ start: newStart, end: newEnd, enabled });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setDragging(null);
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
},
|
||||||
|
[region, pixelsPerBar, durationBars, onChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleEnabled = useCallback(() => {
|
||||||
|
onChange({ ...region, enabled: !region.enabled });
|
||||||
|
}, [region, onChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={cn('relative h-5', className)}
|
||||||
|
style={{ width: durationBars * pixelsPerBar }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute top-0 h-full rounded-sm transition-colors',
|
||||||
|
region.enabled
|
||||||
|
? 'bg-lofi-orange/20 border border-lofi-orange/50'
|
||||||
|
: 'bg-muted/20 border border-border/50'
|
||||||
|
)}
|
||||||
|
style={{ left, width }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute left-0 top-0 bottom-0 w-2 cursor-ew-resize',
|
||||||
|
'hover:bg-lofi-orange/30',
|
||||||
|
dragging === 'start' && 'bg-lofi-orange/40'
|
||||||
|
)}
|
||||||
|
onMouseDown={(e) => handleMouseDown('start', e)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="absolute inset-x-2 inset-y-0 cursor-move"
|
||||||
|
onMouseDown={(e) => handleMouseDown('region', e)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute right-0 top-0 bottom-0 w-2 cursor-ew-resize',
|
||||||
|
'hover:bg-lofi-orange/30',
|
||||||
|
dragging === 'end' && 'bg-lofi-orange/40'
|
||||||
|
)}
|
||||||
|
onMouseDown={(e) => handleMouseDown('end', e)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant={region.enabled ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
'absolute -right-8 top-0 h-5 w-6 p-0',
|
||||||
|
region.enabled && 'bg-lofi-orange hover:bg-lofi-orange/80'
|
||||||
|
)}
|
||||||
|
onClick={toggleEnabled}
|
||||||
|
>
|
||||||
|
<Repeat className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
components/timeline/MuteTrack.tsx
Normal file
117
components/timeline/MuteTrack.tsx
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { MuteKeyframe } from '@/types/audio';
|
||||||
|
|
||||||
|
interface MuteTrackProps {
|
||||||
|
label: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
keyframes: MuteKeyframe[];
|
||||||
|
durationBars: number;
|
||||||
|
pixelsPerBar: number;
|
||||||
|
onToggle: (bar: number) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MuteRegion {
|
||||||
|
startBar: number;
|
||||||
|
endBar: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MuteTrack({
|
||||||
|
label,
|
||||||
|
icon,
|
||||||
|
keyframes,
|
||||||
|
durationBars,
|
||||||
|
pixelsPerBar,
|
||||||
|
onToggle,
|
||||||
|
className,
|
||||||
|
}: MuteTrackProps) {
|
||||||
|
const mutedRegions = useMemo(() => {
|
||||||
|
const sorted = [...keyframes].sort((a, b) => a.bar - b.bar);
|
||||||
|
const regions: MuteRegion[] = [];
|
||||||
|
|
||||||
|
let currentMuted = false;
|
||||||
|
let regionStart = 0;
|
||||||
|
|
||||||
|
sorted.forEach((kf) => {
|
||||||
|
if (kf.muted && !currentMuted) {
|
||||||
|
regionStart = kf.bar;
|
||||||
|
currentMuted = true;
|
||||||
|
} else if (!kf.muted && currentMuted) {
|
||||||
|
regions.push({ startBar: regionStart, endBar: kf.bar });
|
||||||
|
currentMuted = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentMuted) {
|
||||||
|
regions.push({ startBar: regionStart, endBar: durationBars });
|
||||||
|
}
|
||||||
|
|
||||||
|
return regions;
|
||||||
|
}, [keyframes, durationBars]);
|
||||||
|
|
||||||
|
const handleClick = useCallback(
|
||||||
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const bar = Math.floor(x / pixelsPerBar);
|
||||||
|
|
||||||
|
if (bar >= 0 && bar < durationBars) {
|
||||||
|
onToggle(bar);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[pixelsPerBar, durationBars, onToggle]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('relative', className)}>
|
||||||
|
<div className="absolute left-0 top-0 bottom-0 w-20 flex items-center gap-1.5 px-2 bg-card/50 border-r border-border/50 z-10">
|
||||||
|
{icon}
|
||||||
|
<span className="text-xs font-medium text-muted-foreground truncate">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="ml-20 h-6 bg-muted/10 border-b border-border/30 cursor-pointer relative"
|
||||||
|
style={{ width: durationBars * pixelsPerBar }}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
{Array.from({ length: durationBars }, (_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={cn(
|
||||||
|
'absolute top-0 bottom-0 border-l',
|
||||||
|
i % 4 === 0 ? 'border-border/30' : 'border-border/10'
|
||||||
|
)}
|
||||||
|
style={{ left: i * pixelsPerBar }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{mutedRegions.map((region, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="absolute top-1 bottom-1 bg-red-500/30 rounded-sm border border-red-500/50"
|
||||||
|
style={{
|
||||||
|
left: region.startBar * pixelsPerBar,
|
||||||
|
width: (region.endBar - region.startBar) * pixelsPerBar,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{keyframes.map((kf) => (
|
||||||
|
<div
|
||||||
|
key={kf.id}
|
||||||
|
className={cn(
|
||||||
|
'absolute top-1/2 -translate-y-1/2 w-1 h-4 rounded-full',
|
||||||
|
kf.muted ? 'bg-red-500' : 'bg-emerald-500'
|
||||||
|
)}
|
||||||
|
style={{ left: kf.bar * pixelsPerBar - 2 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
components/timeline/PatternPicker.tsx
Normal file
108
components/timeline/PatternPicker.tsx
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Shuffle } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { drumPatterns } from '@/lib/audio/patterns';
|
||||||
|
|
||||||
|
interface PatternPickerProps {
|
||||||
|
selectedIndex: number | null;
|
||||||
|
onSelect: (index: number) => void;
|
||||||
|
onRandom: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const patternNames = [
|
||||||
|
'Classic Boom Bap',
|
||||||
|
'Laid Back Groove',
|
||||||
|
'Minimal Chill',
|
||||||
|
'Jazzy Swing',
|
||||||
|
'Deep Pocket',
|
||||||
|
];
|
||||||
|
|
||||||
|
function PatternPreview({ pattern }: { pattern: typeof drumPatterns[0] }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-16 gap-px w-full h-8 bg-muted/30 rounded">
|
||||||
|
{Array.from({ length: 16 }, (_, i) => {
|
||||||
|
const hasKick = pattern.kick[i];
|
||||||
|
const hasSnare = pattern.snare[i];
|
||||||
|
const hasHihat = pattern.hihat[i];
|
||||||
|
const hasOpenhat = pattern.openhat[i];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col gap-px justify-center',
|
||||||
|
i % 4 === 0 && 'bg-muted/20'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{hasKick && <div className="w-full h-1.5 bg-lofi-orange/80 rounded-sm" />}
|
||||||
|
{hasSnare && <div className="w-full h-1.5 bg-lofi-pink/80 rounded-sm" />}
|
||||||
|
{(hasHihat || hasOpenhat) && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-full h-1 rounded-sm',
|
||||||
|
hasOpenhat ? 'bg-yellow-500/60' : 'bg-muted-foreground/40'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PatternPicker({
|
||||||
|
selectedIndex,
|
||||||
|
onSelect,
|
||||||
|
onRandom,
|
||||||
|
onClose,
|
||||||
|
className,
|
||||||
|
}: PatternPickerProps) {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
'absolute z-50 p-3 w-64 bg-card border border-border shadow-xl',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground uppercase">
|
||||||
|
Drum Patterns
|
||||||
|
</span>
|
||||||
|
<Button variant="ghost" size="sm" className="h-6 px-2" onClick={onRandom}>
|
||||||
|
<Shuffle className="h-3 w-3 mr-1" />
|
||||||
|
Random
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{drumPatterns.map((pattern, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
className={cn(
|
||||||
|
'w-full p-2 rounded-md border transition-colors text-left',
|
||||||
|
selectedIndex === i
|
||||||
|
? 'border-primary bg-primary/10'
|
||||||
|
: 'border-border/50 hover:border-border hover:bg-muted/30'
|
||||||
|
)}
|
||||||
|
onClick={() => onSelect(i)}
|
||||||
|
>
|
||||||
|
<span className="text-xs font-medium block mb-1">{patternNames[i]}</span>
|
||||||
|
<PatternPreview pattern={pattern} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="outline" size="sm" className="w-full mt-2" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
components/timeline/Playhead.tsx
Normal file
94
components/timeline/Playhead.tsx
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface PlayheadProps {
|
||||||
|
bar: number;
|
||||||
|
beat: number;
|
||||||
|
pixelsPerBar: number;
|
||||||
|
height: number;
|
||||||
|
durationBars: number;
|
||||||
|
onSeek?: (bar: number, beat: number) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Playhead({
|
||||||
|
bar,
|
||||||
|
beat,
|
||||||
|
pixelsPerBar,
|
||||||
|
height,
|
||||||
|
durationBars,
|
||||||
|
onSeek,
|
||||||
|
className,
|
||||||
|
}: PlayheadProps) {
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const pixelsPerBeat = pixelsPerBar / 4;
|
||||||
|
const position = bar * pixelsPerBar + beat * pixelsPerBeat;
|
||||||
|
|
||||||
|
const handleDrag = useCallback(
|
||||||
|
(clientX: number, containerLeft: number) => {
|
||||||
|
if (!onSeek) return;
|
||||||
|
|
||||||
|
const x = clientX - containerLeft;
|
||||||
|
const totalBeats = x / pixelsPerBeat;
|
||||||
|
const newBar = Math.floor(totalBeats / 4);
|
||||||
|
const newBeat = Math.floor(totalBeats % 4);
|
||||||
|
|
||||||
|
if (newBar >= 0 && newBar < durationBars) {
|
||||||
|
onSeek(newBar, Math.max(0, Math.min(3, newBeat)));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onSeek, pixelsPerBeat, durationBars]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMouseDown = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
if (!onSeek) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(true);
|
||||||
|
|
||||||
|
const container = (e.currentTarget as HTMLElement).parentElement;
|
||||||
|
if (!container) return;
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
handleDrag(e.clientX, containerRect.left);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setIsDragging(false);
|
||||||
|
window.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
window.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', handleMouseMove);
|
||||||
|
window.addEventListener('mouseup', handleMouseUp);
|
||||||
|
},
|
||||||
|
[onSeek, handleDrag]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute top-0 z-20',
|
||||||
|
'flex flex-col items-center',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{ left: position, height }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-4 h-3 bg-lofi-orange rounded-sm -mt-0.5 cursor-grab',
|
||||||
|
isDragging && 'cursor-grabbing scale-110'
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
clipPath: 'polygon(50% 100%, 0% 0%, 100% 0%)',
|
||||||
|
}}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
/>
|
||||||
|
<div className="w-0.5 flex-1 bg-lofi-orange shadow-[0_0_8px_var(--lofi-orange)] pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
components/timeline/ProgressionPicker.tsx
Normal file
91
components/timeline/ProgressionPicker.tsx
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Shuffle } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { chordProgressions } from '@/lib/audio/patterns';
|
||||||
|
|
||||||
|
interface ProgressionPickerProps {
|
||||||
|
selectedIndex: number | null;
|
||||||
|
onSelect: (index: number) => void;
|
||||||
|
onRandom: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chordToName(notes: string[]): string {
|
||||||
|
if (notes.length === 0) return '';
|
||||||
|
|
||||||
|
const noteMap: Record<string, string> = {
|
||||||
|
'C': 'C', 'D': 'D', 'E': 'E', 'F': 'F', 'G': 'G', 'A': 'A', 'B': 'B',
|
||||||
|
};
|
||||||
|
|
||||||
|
const root = notes[0].replace(/[0-9]/g, '');
|
||||||
|
const rootName = root.replace('#', '♯').replace('b', '♭');
|
||||||
|
|
||||||
|
if (notes.some((n) => n.includes('b') || n.includes('Eb') || n.includes('Bb'))) {
|
||||||
|
return `${rootName}m7`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${rootName}maj7`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProgressionPicker({
|
||||||
|
selectedIndex,
|
||||||
|
onSelect,
|
||||||
|
onRandom,
|
||||||
|
onClose,
|
||||||
|
className,
|
||||||
|
}: ProgressionPickerProps) {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
'absolute z-50 p-3 w-64 bg-card border border-border shadow-xl',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground uppercase">
|
||||||
|
Chord Progressions
|
||||||
|
</span>
|
||||||
|
<Button variant="ghost" size="sm" className="h-6 px-2" onClick={onRandom}>
|
||||||
|
<Shuffle className="h-3 w-3 mr-1" />
|
||||||
|
Random
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{chordProgressions.map((prog, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
className={cn(
|
||||||
|
'w-full p-2 rounded-md border transition-colors text-left',
|
||||||
|
selectedIndex === i
|
||||||
|
? 'border-primary bg-primary/10'
|
||||||
|
: 'border-border/50 hover:border-border hover:bg-muted/30'
|
||||||
|
)}
|
||||||
|
onClick={() => onSelect(i)}
|
||||||
|
>
|
||||||
|
<span className="text-xs font-medium block mb-1">{prog.name}</span>
|
||||||
|
<div className="flex gap-1 text-[10px] text-muted-foreground font-mono">
|
||||||
|
{prog.chords.map((chord, j) => (
|
||||||
|
<span
|
||||||
|
key={j}
|
||||||
|
className="px-1.5 py-0.5 bg-muted/50 rounded"
|
||||||
|
>
|
||||||
|
{chordToName(chord)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="outline" size="sm" className="w-full mt-2" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
components/timeline/SectionBlock.tsx
Normal file
130
components/timeline/SectionBlock.tsx
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Section, SECTION_COLORS } from '@/types/audio';
|
||||||
|
|
||||||
|
interface SectionBlockProps {
|
||||||
|
section: Section;
|
||||||
|
pixelsPerBar: number;
|
||||||
|
durationBars: number;
|
||||||
|
onResize: (id: string, startBar: number, endBar: number) => void;
|
||||||
|
onMove: (id: string, startBar: number) => void;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
selected?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SectionBlock({
|
||||||
|
section,
|
||||||
|
pixelsPerBar,
|
||||||
|
durationBars,
|
||||||
|
onResize,
|
||||||
|
onMove,
|
||||||
|
onSelect,
|
||||||
|
onDelete,
|
||||||
|
selected = false,
|
||||||
|
}: SectionBlockProps) {
|
||||||
|
const [dragging, setDragging] = useState<'move' | 'start' | 'end' | null>(null);
|
||||||
|
const dragStart = useRef({ x: 0, startBar: 0, endBar: 0 });
|
||||||
|
|
||||||
|
const left = section.startBar * pixelsPerBar;
|
||||||
|
const width = (section.endBar - section.startBar) * pixelsPerBar;
|
||||||
|
const color = SECTION_COLORS[section.type];
|
||||||
|
|
||||||
|
const handleMouseDown = useCallback(
|
||||||
|
(type: 'move' | 'start' | 'end', e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setDragging(type);
|
||||||
|
dragStart.current = {
|
||||||
|
x: e.clientX,
|
||||||
|
startBar: section.startBar,
|
||||||
|
endBar: section.endBar,
|
||||||
|
};
|
||||||
|
onSelect(section.id);
|
||||||
|
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
const deltaX = e.clientX - dragStart.current.x;
|
||||||
|
const deltaBars = Math.round(deltaX / pixelsPerBar);
|
||||||
|
const { startBar, endBar } = dragStart.current;
|
||||||
|
|
||||||
|
if (type === 'start') {
|
||||||
|
const newStart = Math.max(0, Math.min(endBar - 1, startBar + deltaBars));
|
||||||
|
onResize(section.id, newStart, endBar);
|
||||||
|
} else if (type === 'end') {
|
||||||
|
const newEnd = Math.max(startBar + 1, Math.min(durationBars, endBar + deltaBars));
|
||||||
|
onResize(section.id, startBar, newEnd);
|
||||||
|
} else {
|
||||||
|
const duration = endBar - startBar;
|
||||||
|
const newStart = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(durationBars - duration, startBar + deltaBars)
|
||||||
|
);
|
||||||
|
onMove(section.id, newStart);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setDragging(null);
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
},
|
||||||
|
[section, pixelsPerBar, durationBars, onResize, onMove, onSelect]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleContextMenu = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onDelete(section.id);
|
||||||
|
},
|
||||||
|
[section.id, onDelete]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute top-1 bottom-1 rounded-md cursor-move',
|
||||||
|
'transition-shadow',
|
||||||
|
selected && 'ring-2 ring-white/50 shadow-lg'
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
left,
|
||||||
|
width,
|
||||||
|
backgroundColor: color,
|
||||||
|
}}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute left-0 top-0 bottom-0 w-2 cursor-ew-resize',
|
||||||
|
'hover:bg-white/20 rounded-l-md',
|
||||||
|
dragging === 'start' && 'bg-white/30'
|
||||||
|
)}
|
||||||
|
onMouseDown={(e) => handleMouseDown('start', e)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="absolute inset-x-2 inset-y-0 flex items-center px-1 overflow-hidden"
|
||||||
|
onMouseDown={(e) => handleMouseDown('move', e)}
|
||||||
|
>
|
||||||
|
<span className="text-[10px] font-medium text-white/90 truncate">
|
||||||
|
{section.name || section.type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute right-0 top-0 bottom-0 w-2 cursor-ew-resize',
|
||||||
|
'hover:bg-white/20 rounded-r-md',
|
||||||
|
dragging === 'end' && 'bg-white/30'
|
||||||
|
)}
|
||||||
|
onMouseDown={(e) => handleMouseDown('end', e)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
components/timeline/SectionPicker.tsx
Normal file
63
components/timeline/SectionPicker.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { SectionType, SECTION_COLORS } from '@/types/audio';
|
||||||
|
|
||||||
|
interface SectionPickerProps {
|
||||||
|
onSelect: (type: SectionType) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionTypes: { type: SectionType; label: string }[] = [
|
||||||
|
{ type: 'intro', label: 'Intro' },
|
||||||
|
{ type: 'verse', label: 'Verse' },
|
||||||
|
{ type: 'chorus', label: 'Chorus' },
|
||||||
|
{ type: 'drop', label: 'Drop' },
|
||||||
|
{ type: 'bridge', label: 'Bridge' },
|
||||||
|
{ type: 'outro', label: 'Outro' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function SectionPicker({ onSelect, onClose, className }: SectionPickerProps) {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
'absolute z-50 p-3 bg-card border border-border shadow-xl min-w-[180px]',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="text-xs font-medium text-muted-foreground uppercase mb-2">
|
||||||
|
Add Section
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{sectionTypes.map(({ type, label }) => (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
className={cn(
|
||||||
|
'px-4 py-2 rounded-md text-sm font-medium text-white text-center',
|
||||||
|
'hover:brightness-110 transition-all'
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: SECTION_COLORS[type] }}
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(type);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full mt-2 h-8 text-xs"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
components/timeline/SectionTrack.tsx
Normal file
112
components/timeline/SectionTrack.tsx
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { SectionBlock } from './SectionBlock';
|
||||||
|
import { SectionPicker } from './SectionPicker';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Section, SectionType } from '@/types/audio';
|
||||||
|
|
||||||
|
interface SectionTrackProps {
|
||||||
|
sections: Section[];
|
||||||
|
durationBars: number;
|
||||||
|
pixelsPerBar: number;
|
||||||
|
selectedId: string | null;
|
||||||
|
onSelect: (id: string | null) => void;
|
||||||
|
onAdd: (type: SectionType, startBar: number, endBar: number) => void;
|
||||||
|
onResize: (id: string, startBar: number, endBar: number) => void;
|
||||||
|
onMove: (id: string, startBar: number) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SectionTrack({
|
||||||
|
sections,
|
||||||
|
durationBars,
|
||||||
|
pixelsPerBar,
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
onAdd,
|
||||||
|
onResize,
|
||||||
|
onMove,
|
||||||
|
onDelete,
|
||||||
|
className,
|
||||||
|
}: SectionTrackProps) {
|
||||||
|
const [showPicker, setShowPicker] = useState(false);
|
||||||
|
const [pickerPosition, setPickerPosition] = useState({ x: 0, bar: 0 });
|
||||||
|
|
||||||
|
const handleTrackClick = useCallback(
|
||||||
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const bar = Math.floor(x / pixelsPerBar);
|
||||||
|
|
||||||
|
if (bar >= 0 && bar < durationBars) {
|
||||||
|
setPickerPosition({ x, bar });
|
||||||
|
setShowPicker(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[pixelsPerBar, durationBars]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelectType = useCallback(
|
||||||
|
(type: SectionType) => {
|
||||||
|
const endBar = Math.min(durationBars, pickerPosition.bar + 4);
|
||||||
|
onAdd(type, pickerPosition.bar, endBar);
|
||||||
|
},
|
||||||
|
[pickerPosition.bar, durationBars, onAdd]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('relative', className)}>
|
||||||
|
<div className="absolute left-0 top-0 bottom-0 w-20 flex items-center gap-1.5 px-2 bg-card/50 border-r border-border/50 z-10">
|
||||||
|
<Plus className="h-3 w-3 text-muted-foreground" />
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">Sections</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="ml-20 h-8 bg-muted/10 border-b border-border/30 cursor-pointer relative"
|
||||||
|
style={{ width: durationBars * pixelsPerBar }}
|
||||||
|
onClick={handleTrackClick}
|
||||||
|
>
|
||||||
|
{Array.from({ length: durationBars }, (_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={cn(
|
||||||
|
'absolute top-0 bottom-0 border-l',
|
||||||
|
i % 4 === 0 ? 'border-border/30' : 'border-border/10'
|
||||||
|
)}
|
||||||
|
style={{ left: i * pixelsPerBar }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{sections.map((section) => (
|
||||||
|
<SectionBlock
|
||||||
|
key={section.id}
|
||||||
|
section={section}
|
||||||
|
pixelsPerBar={pixelsPerBar}
|
||||||
|
durationBars={durationBars}
|
||||||
|
onResize={onResize}
|
||||||
|
onMove={onMove}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onDelete={onDelete}
|
||||||
|
selected={section.id === selectedId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{showPicker && (
|
||||||
|
<div
|
||||||
|
className="absolute top-full mt-1"
|
||||||
|
style={{ left: Math.min(pickerPosition.x, durationBars * pixelsPerBar - 128) }}
|
||||||
|
>
|
||||||
|
<SectionPicker
|
||||||
|
onSelect={handleSelectType}
|
||||||
|
onClose={() => setShowPicker(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
components/timeline/TimeDisplay.tsx
Normal file
55
components/timeline/TimeDisplay.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface TimeDisplayProps {
|
||||||
|
bar: number;
|
||||||
|
beat: number;
|
||||||
|
durationBars: number;
|
||||||
|
bpm: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TimeDisplay({
|
||||||
|
bar,
|
||||||
|
beat,
|
||||||
|
durationBars,
|
||||||
|
bpm,
|
||||||
|
className,
|
||||||
|
}: TimeDisplayProps) {
|
||||||
|
const { currentTime, totalTime } = useMemo(() => {
|
||||||
|
const secondsPerBeat = 60 / bpm;
|
||||||
|
const secondsPerBar = secondsPerBeat * 4;
|
||||||
|
|
||||||
|
const currentSeconds = bar * secondsPerBar + beat * secondsPerBeat;
|
||||||
|
const totalSeconds = durationBars * secondsPerBar;
|
||||||
|
|
||||||
|
const formatTime = (seconds: number) => {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentTime: formatTime(currentSeconds),
|
||||||
|
totalTime: formatTime(totalSeconds),
|
||||||
|
};
|
||||||
|
}, [bar, beat, durationBars, bpm]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center gap-3 font-mono text-sm', className)}>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-muted-foreground">Bar</span>
|
||||||
|
<span className="text-foreground font-medium w-6 text-right">{bar + 1}</span>
|
||||||
|
<span className="text-muted-foreground/50">|</span>
|
||||||
|
<span className="text-muted-foreground">Beat</span>
|
||||||
|
<span className="text-foreground font-medium w-4">{beat + 1}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground/50">|</div>
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
{currentTime} / {totalTime}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
305
components/timeline/Timeline.tsx
Normal file
305
components/timeline/Timeline.tsx
Normal file
@ -0,0 +1,305 @@
|
|||||||
|
'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
components/timeline/TimelineRuler.tsx
Normal file
51
components/timeline/TimelineRuler.tsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface TimelineRulerProps {
|
||||||
|
durationBars: number;
|
||||||
|
pixelsPerBar: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TimelineRuler({
|
||||||
|
durationBars,
|
||||||
|
pixelsPerBar,
|
||||||
|
className,
|
||||||
|
}: TimelineRulerProps) {
|
||||||
|
const markers = useMemo(() => {
|
||||||
|
const items: { bar: number; isMajor: boolean }[] = [];
|
||||||
|
for (let bar = 0; bar <= durationBars; bar++) {
|
||||||
|
items.push({ bar, isMajor: bar % 4 === 0 });
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}, [durationBars]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('relative h-6 bg-muted/30 border-b border-border/50', className)}
|
||||||
|
style={{ width: durationBars * pixelsPerBar }}
|
||||||
|
>
|
||||||
|
{markers.map(({ bar, isMajor }) => (
|
||||||
|
<div
|
||||||
|
key={bar}
|
||||||
|
className="absolute top-0 flex flex-col items-center"
|
||||||
|
style={{ left: bar * pixelsPerBar }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-px',
|
||||||
|
isMajor ? 'h-4 bg-muted-foreground/60' : 'h-2 bg-muted-foreground/30'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{isMajor && (
|
||||||
|
<span className="text-[9px] font-mono text-muted-foreground/80 mt-0.5">
|
||||||
|
{bar + 1}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
components/timeline/index.ts
Normal file
16
components/timeline/index.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
export { Timeline } from './Timeline';
|
||||||
|
export { TimelineRuler } from './TimelineRuler';
|
||||||
|
export { Playhead } from './Playhead';
|
||||||
|
export { TimeDisplay } from './TimeDisplay';
|
||||||
|
export { DurationSelector } from './DurationSelector';
|
||||||
|
export { LoopBracket } from './LoopBracket';
|
||||||
|
export { SectionTrack } from './SectionTrack';
|
||||||
|
export { SectionBlock } from './SectionBlock';
|
||||||
|
export { SectionPicker } from './SectionPicker';
|
||||||
|
export { KeyframeTrack } from './KeyframeTrack';
|
||||||
|
export { KeyframeMarker } from './KeyframeMarker';
|
||||||
|
export { PatternPicker } from './PatternPicker';
|
||||||
|
export { ProgressionPicker } from './ProgressionPicker';
|
||||||
|
export { MuteTrack } from './MuteTrack';
|
||||||
|
export { AutomationLane } from './AutomationLane';
|
||||||
|
export { ExportModal } from './ExportModal';
|
||||||
@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { EngineState, LayerName } from '@/types/audio';
|
import { EngineState, LayerName, MeterLevels, LoopRegion } from '@/types/audio';
|
||||||
|
|
||||||
const defaultState: EngineState = {
|
const defaultState: EngineState = {
|
||||||
isPlaying: false,
|
isPlaying: false,
|
||||||
@ -22,9 +22,33 @@ const defaultState: EngineState = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const defaultMeterLevels: MeterLevels = {
|
||||||
|
drums: 0,
|
||||||
|
chords: 0,
|
||||||
|
ambient: 0,
|
||||||
|
master: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultPans: Record<LayerName, number> = {
|
||||||
|
drums: 0,
|
||||||
|
chords: 0,
|
||||||
|
ambient: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultSoloed: Record<LayerName, boolean> = {
|
||||||
|
drums: false,
|
||||||
|
chords: false,
|
||||||
|
ambient: false,
|
||||||
|
};
|
||||||
|
|
||||||
export function useAudioEngine() {
|
export function useAudioEngine() {
|
||||||
const [state, setState] = useState<EngineState>(defaultState);
|
const [state, setState] = useState<EngineState>(defaultState);
|
||||||
const [currentStep, setCurrentStep] = useState(0);
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
|
const [meterLevels, setMeterLevels] = useState<MeterLevels>(defaultMeterLevels);
|
||||||
|
const [pans, setPans] = useState<Record<LayerName, number>>(defaultPans);
|
||||||
|
const [soloed, setSoloed] = useState<Record<LayerName, boolean>>(defaultSoloed);
|
||||||
|
const [playheadPosition, setPlayheadPosition] = useState({ bar: 0, beat: 0 });
|
||||||
|
|
||||||
const engineRef = useRef<typeof import('@/lib/audio/audioEngine').default | null>(null);
|
const engineRef = useRef<typeof import('@/lib/audio/audioEngine').default | null>(null);
|
||||||
const isInitializingRef = useRef(false);
|
const isInitializingRef = useRef(false);
|
||||||
|
|
||||||
@ -55,6 +79,12 @@ export function useAudioEngine() {
|
|||||||
onStateChange: (newState) => {
|
onStateChange: (newState) => {
|
||||||
setState(newState);
|
setState(newState);
|
||||||
},
|
},
|
||||||
|
onBarChange: (bar, beat) => {
|
||||||
|
setPlayheadPosition({ bar, beat });
|
||||||
|
},
|
||||||
|
onMeterUpdate: (levels) => {
|
||||||
|
setMeterLevels(levels);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await engine.initialize();
|
await engine.initialize();
|
||||||
@ -136,6 +166,70 @@ export function useAudioEngine() {
|
|||||||
engine.toggleMute(layer);
|
engine.toggleMute(layer);
|
||||||
}, [getEngine]);
|
}, [getEngine]);
|
||||||
|
|
||||||
|
// Set muted state directly
|
||||||
|
const setMuted = useCallback(async (layer: LayerName, muted: boolean) => {
|
||||||
|
const engine = await getEngine();
|
||||||
|
if (!engine) return;
|
||||||
|
engine.setMuted(layer, muted);
|
||||||
|
}, [getEngine]);
|
||||||
|
|
||||||
|
// Set pan
|
||||||
|
const setPan = useCallback(async (layer: LayerName, value: number) => {
|
||||||
|
const engine = await getEngine();
|
||||||
|
if (!engine) return;
|
||||||
|
engine.setPan(layer, value);
|
||||||
|
setPans((prev) => ({ ...prev, [layer]: value }));
|
||||||
|
}, [getEngine]);
|
||||||
|
|
||||||
|
// Set solo
|
||||||
|
const setSolo = useCallback(async (layer: LayerName, enabled: boolean) => {
|
||||||
|
const engine = await getEngine();
|
||||||
|
if (!engine) return;
|
||||||
|
engine.setSolo(layer, enabled);
|
||||||
|
setSoloed(engine.getSoloState());
|
||||||
|
}, [getEngine]);
|
||||||
|
|
||||||
|
// Toggle solo
|
||||||
|
const toggleSolo = useCallback(async (layer: LayerName) => {
|
||||||
|
const engine = await getEngine();
|
||||||
|
if (!engine) return;
|
||||||
|
const current = engine.getSoloState();
|
||||||
|
engine.setSolo(layer, !current[layer]);
|
||||||
|
setSoloed(engine.getSoloState());
|
||||||
|
}, [getEngine]);
|
||||||
|
|
||||||
|
// Seek to position
|
||||||
|
const seek = useCallback(async (bar: number, beat: number = 0) => {
|
||||||
|
const engine = await getEngine();
|
||||||
|
if (!engine) return;
|
||||||
|
engine.seek(bar, beat);
|
||||||
|
setPlayheadPosition({ bar, beat });
|
||||||
|
}, [getEngine]);
|
||||||
|
|
||||||
|
// Set duration
|
||||||
|
const setDuration = useCallback(async (bars: number) => {
|
||||||
|
const engine = await getEngine();
|
||||||
|
if (!engine) return;
|
||||||
|
engine.setDuration(bars);
|
||||||
|
}, [getEngine]);
|
||||||
|
|
||||||
|
// Set loop region
|
||||||
|
const setLoopRegion = useCallback(
|
||||||
|
async (start: number, end: number, enabled: boolean) => {
|
||||||
|
const engine = await getEngine();
|
||||||
|
if (!engine) return;
|
||||||
|
engine.setLoopRegion(start, end, enabled);
|
||||||
|
},
|
||||||
|
[getEngine]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get meter levels on demand
|
||||||
|
const getMeterLevels = useCallback(async (): Promise<MeterLevels> => {
|
||||||
|
const engine = await getEngine();
|
||||||
|
if (!engine) return defaultMeterLevels;
|
||||||
|
return engine.getMeterLevels();
|
||||||
|
}, [getEngine]);
|
||||||
|
|
||||||
// Cleanup on unmount
|
// Cleanup on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@ -147,6 +241,10 @@ export function useAudioEngine() {
|
|||||||
return {
|
return {
|
||||||
state,
|
state,
|
||||||
currentStep,
|
currentStep,
|
||||||
|
meterLevels,
|
||||||
|
pans,
|
||||||
|
soloed,
|
||||||
|
playheadPosition,
|
||||||
initialize,
|
initialize,
|
||||||
togglePlayback,
|
togglePlayback,
|
||||||
stop,
|
stop,
|
||||||
@ -156,5 +254,13 @@ export function useAudioEngine() {
|
|||||||
setMasterVolume,
|
setMasterVolume,
|
||||||
setLayerVolume,
|
setLayerVolume,
|
||||||
toggleMute,
|
toggleMute,
|
||||||
|
setMuted,
|
||||||
|
setPan,
|
||||||
|
setSolo,
|
||||||
|
toggleSolo,
|
||||||
|
seek,
|
||||||
|
setDuration,
|
||||||
|
setLoopRegion,
|
||||||
|
getMeterLevels,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
277
hooks/useTimeline.ts
Normal file
277
hooks/useTimeline.ts
Normal file
@ -0,0 +1,277 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
TimelineState,
|
||||||
|
Section,
|
||||||
|
SectionType,
|
||||||
|
PatternKeyframe,
|
||||||
|
ChordKeyframe,
|
||||||
|
MuteKeyframe,
|
||||||
|
AutomationPoint,
|
||||||
|
LoopRegion,
|
||||||
|
LayerName,
|
||||||
|
SECTION_COLORS,
|
||||||
|
} from '@/types/audio';
|
||||||
|
|
||||||
|
function generateId(): string {
|
||||||
|
return Math.random().toString(36).substring(2, 9);
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultState: TimelineState = {
|
||||||
|
durationBars: 16,
|
||||||
|
playheadBar: 0,
|
||||||
|
playheadBeat: 0,
|
||||||
|
sections: [],
|
||||||
|
drumKeyframes: [{ id: generateId(), bar: 0, patternIndex: 0 }],
|
||||||
|
chordKeyframes: [{ id: generateId(), bar: 0, progressionIndex: 0 }],
|
||||||
|
volumeAutomation: {
|
||||||
|
drums: [],
|
||||||
|
chords: [],
|
||||||
|
ambient: [],
|
||||||
|
},
|
||||||
|
muteKeyframes: {
|
||||||
|
drums: [],
|
||||||
|
chords: [],
|
||||||
|
ambient: [],
|
||||||
|
},
|
||||||
|
loopRegion: { start: 0, end: 16, enabled: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useTimeline() {
|
||||||
|
const [state, setState] = useState<TimelineState>(defaultState);
|
||||||
|
|
||||||
|
const setDuration = useCallback((bars: number) => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
durationBars: bars,
|
||||||
|
loopRegion: {
|
||||||
|
...prev.loopRegion,
|
||||||
|
end: Math.min(prev.loopRegion.end, bars),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setPlayheadPosition = useCallback((bar: number, beat: number) => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
playheadBar: bar,
|
||||||
|
playheadBeat: beat,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setLoopRegion = useCallback((start: number, end: number, enabled: boolean) => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
loopRegion: { start, end, enabled },
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addSection = useCallback(
|
||||||
|
(type: SectionType, startBar: number, endBar: number) => {
|
||||||
|
const newSection: Section = {
|
||||||
|
id: generateId(),
|
||||||
|
type,
|
||||||
|
name: type.charAt(0).toUpperCase() + type.slice(1),
|
||||||
|
startBar,
|
||||||
|
endBar,
|
||||||
|
color: SECTION_COLORS[type],
|
||||||
|
};
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
sections: [...prev.sections, newSection],
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const resizeSection = useCallback((id: string, startBar: number, endBar: number) => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
sections: prev.sections.map((s) =>
|
||||||
|
s.id === id ? { ...s, startBar, endBar } : s
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const moveSection = useCallback((id: string, startBar: number) => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
sections: prev.sections.map((s) => {
|
||||||
|
if (s.id !== id) return s;
|
||||||
|
const duration = s.endBar - s.startBar;
|
||||||
|
return { ...s, startBar, endBar: startBar + duration };
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const deleteSection = useCallback((id: string) => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
sections: prev.sections.filter((s) => s.id !== id),
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addDrumKeyframe = useCallback((bar: number, patternIndex: number) => {
|
||||||
|
const newKeyframe: PatternKeyframe = {
|
||||||
|
id: generateId(),
|
||||||
|
bar,
|
||||||
|
patternIndex,
|
||||||
|
};
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
drumKeyframes: [...prev.drumKeyframes, newKeyframe],
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateDrumKeyframe = useCallback((id: string, patternIndex: number) => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
drumKeyframes: prev.drumKeyframes.map((kf) =>
|
||||||
|
kf.id === id ? { ...kf, patternIndex } : kf
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const deleteDrumKeyframe = useCallback((id: string) => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
drumKeyframes: prev.drumKeyframes.filter((kf) => kf.id !== id),
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addChordKeyframe = useCallback((bar: number, progressionIndex: number) => {
|
||||||
|
const newKeyframe: ChordKeyframe = {
|
||||||
|
id: generateId(),
|
||||||
|
bar,
|
||||||
|
progressionIndex,
|
||||||
|
};
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
chordKeyframes: [...prev.chordKeyframes, newKeyframe],
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateChordKeyframe = useCallback((id: string, progressionIndex: number) => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
chordKeyframes: prev.chordKeyframes.map((kf) =>
|
||||||
|
kf.id === id ? { ...kf, progressionIndex } : kf
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const deleteChordKeyframe = useCallback((id: string) => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
chordKeyframes: prev.chordKeyframes.filter((kf) => kf.id !== id),
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleMuteKeyframe = useCallback((layer: LayerName, bar: number) => {
|
||||||
|
setState((prev) => {
|
||||||
|
const keyframes = prev.muteKeyframes[layer];
|
||||||
|
const existing = keyframes.find((kf) => kf.bar === bar);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
muteKeyframes: {
|
||||||
|
...prev.muteKeyframes,
|
||||||
|
[layer]: keyframes.map((kf) =>
|
||||||
|
kf.id === existing.id ? { ...kf, muted: !kf.muted } : kf
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...keyframes].sort((a, b) => a.bar - b.bar);
|
||||||
|
const prevKeyframe = sorted.filter((kf) => kf.bar < bar).pop();
|
||||||
|
const wasMuted = prevKeyframe?.muted ?? false;
|
||||||
|
|
||||||
|
const newKeyframe: MuteKeyframe = {
|
||||||
|
id: generateId(),
|
||||||
|
bar,
|
||||||
|
muted: !wasMuted,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
muteKeyframes: {
|
||||||
|
...prev.muteKeyframes,
|
||||||
|
[layer]: [...keyframes, newKeyframe],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addAutomationPoint = useCallback(
|
||||||
|
(layer: LayerName, bar: number, beat: number, value: number) => {
|
||||||
|
const newPoint: AutomationPoint = {
|
||||||
|
id: generateId(),
|
||||||
|
bar,
|
||||||
|
beat,
|
||||||
|
value,
|
||||||
|
};
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
volumeAutomation: {
|
||||||
|
...prev.volumeAutomation,
|
||||||
|
[layer]: [...prev.volumeAutomation[layer], newPoint],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateAutomationPoint = useCallback(
|
||||||
|
(layer: LayerName, id: string, bar: number, beat: number, value: number) => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
volumeAutomation: {
|
||||||
|
...prev.volumeAutomation,
|
||||||
|
[layer]: prev.volumeAutomation[layer].map((p) =>
|
||||||
|
p.id === id ? { ...p, bar, beat, value } : p
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteAutomationPoint = useCallback((layer: LayerName, id: string) => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
volumeAutomation: {
|
||||||
|
...prev.volumeAutomation,
|
||||||
|
[layer]: prev.volumeAutomation[layer].filter((p) => p.id !== id),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetTimeline = useCallback(() => {
|
||||||
|
setState(defaultState);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
setDuration,
|
||||||
|
setPlayheadPosition,
|
||||||
|
setLoopRegion,
|
||||||
|
addSection,
|
||||||
|
resizeSection,
|
||||||
|
moveSection,
|
||||||
|
deleteSection,
|
||||||
|
addDrumKeyframe,
|
||||||
|
updateDrumKeyframe,
|
||||||
|
deleteDrumKeyframe,
|
||||||
|
addChordKeyframe,
|
||||||
|
updateChordKeyframe,
|
||||||
|
deleteChordKeyframe,
|
||||||
|
toggleMuteKeyframe,
|
||||||
|
addAutomationPoint,
|
||||||
|
updateAutomationPoint,
|
||||||
|
deleteAutomationPoint,
|
||||||
|
resetTimeline,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -2,7 +2,13 @@ import * as Tone from 'tone';
|
|||||||
import { DrumMachine } from './drumMachine';
|
import { DrumMachine } from './drumMachine';
|
||||||
import { ChordEngine } from './chordEngine';
|
import { ChordEngine } from './chordEngine';
|
||||||
import { AmbientLayer } from './ambientLayer';
|
import { AmbientLayer } from './ambientLayer';
|
||||||
import { EngineState, AudioEngineCallbacks, LayerName } from '@/types/audio';
|
import {
|
||||||
|
EngineState,
|
||||||
|
AudioEngineCallbacks,
|
||||||
|
LayerName,
|
||||||
|
MeterLevels,
|
||||||
|
LoopRegion,
|
||||||
|
} from '@/types/audio';
|
||||||
|
|
||||||
class AudioEngine {
|
class AudioEngine {
|
||||||
private static instance: AudioEngine | null = null;
|
private static instance: AudioEngine | null = null;
|
||||||
@ -16,6 +22,33 @@ class AudioEngine {
|
|||||||
private masterLimiter: Tone.Limiter | null = null;
|
private masterLimiter: Tone.Limiter | null = null;
|
||||||
private masterReverb: Tone.Reverb | null = null;
|
private masterReverb: Tone.Reverb | null = null;
|
||||||
|
|
||||||
|
// Meters for level detection
|
||||||
|
private drumMeter: Tone.Meter | null = null;
|
||||||
|
private chordMeter: Tone.Meter | null = null;
|
||||||
|
private ambientMeter: Tone.Meter | null = null;
|
||||||
|
private masterMeter: Tone.Meter | null = null;
|
||||||
|
|
||||||
|
// Panners for stereo positioning
|
||||||
|
private drumPanner: Tone.Panner | null = null;
|
||||||
|
private chordPanner: Tone.Panner | null = null;
|
||||||
|
private ambientPanner: Tone.Panner | null = null;
|
||||||
|
|
||||||
|
// Solo state tracking
|
||||||
|
private soloState: Record<LayerName, boolean> = {
|
||||||
|
drums: false,
|
||||||
|
chords: false,
|
||||||
|
ambient: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pre-solo volume states for restoration
|
||||||
|
private preSoloMuted: Record<LayerName, boolean> | null = null;
|
||||||
|
|
||||||
|
// Timeline state
|
||||||
|
private durationBars: number = 16;
|
||||||
|
private loopRegion: LoopRegion = { start: 0, end: 16, enabled: false };
|
||||||
|
private meterAnimationId: number | null = null;
|
||||||
|
private barTrackerId: number | null = null;
|
||||||
|
|
||||||
private callbacks: AudioEngineCallbacks = {};
|
private callbacks: AudioEngineCallbacks = {};
|
||||||
|
|
||||||
private state: EngineState = {
|
private state: EngineState = {
|
||||||
@ -57,6 +90,17 @@ class AudioEngine {
|
|||||||
Tone.getTransport().swing = this.state.swing;
|
Tone.getTransport().swing = this.state.swing;
|
||||||
Tone.getTransport().swingSubdivision = '16n';
|
Tone.getTransport().swingSubdivision = '16n';
|
||||||
|
|
||||||
|
// Create meters
|
||||||
|
this.drumMeter = new Tone.Meter({ smoothing: 0.8 });
|
||||||
|
this.chordMeter = new Tone.Meter({ smoothing: 0.8 });
|
||||||
|
this.ambientMeter = new Tone.Meter({ smoothing: 0.8 });
|
||||||
|
this.masterMeter = new Tone.Meter({ smoothing: 0.8 });
|
||||||
|
|
||||||
|
// Create panners
|
||||||
|
this.drumPanner = new Tone.Panner(0);
|
||||||
|
this.chordPanner = new Tone.Panner(0);
|
||||||
|
this.ambientPanner = new Tone.Panner(0);
|
||||||
|
|
||||||
// Master chain
|
// Master chain
|
||||||
this.masterGain = new Tone.Gain(this.state.volumes.master);
|
this.masterGain = new Tone.Gain(this.state.volumes.master);
|
||||||
this.masterCompressor = new Tone.Compressor({
|
this.masterCompressor = new Tone.Compressor({
|
||||||
@ -71,16 +115,27 @@ class AudioEngine {
|
|||||||
wet: 0.15,
|
wet: 0.15,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Chain: gain -> reverb -> compressor -> limiter -> destination
|
// Chain: gain -> reverb -> compressor -> limiter -> meter -> destination
|
||||||
this.masterGain.connect(this.masterReverb);
|
this.masterGain.connect(this.masterReverb);
|
||||||
this.masterReverb.connect(this.masterCompressor);
|
this.masterReverb.connect(this.masterCompressor);
|
||||||
this.masterCompressor.connect(this.masterLimiter);
|
this.masterCompressor.connect(this.masterLimiter);
|
||||||
this.masterLimiter.toDestination();
|
this.masterLimiter.connect(this.masterMeter);
|
||||||
|
this.masterMeter.toDestination();
|
||||||
|
|
||||||
// Initialize layers
|
// Initialize layers with panners and meters in chain
|
||||||
this.drumMachine = new DrumMachine(this.masterGain);
|
this.drumMachine = new DrumMachine(this.drumPanner);
|
||||||
this.chordEngine = new ChordEngine(this.masterGain);
|
this.chordEngine = new ChordEngine(this.chordPanner);
|
||||||
this.ambientLayer = new AmbientLayer(this.masterGain);
|
this.ambientLayer = new AmbientLayer(this.ambientPanner);
|
||||||
|
|
||||||
|
// Connect panners -> meters -> master
|
||||||
|
this.drumPanner.connect(this.drumMeter);
|
||||||
|
this.drumMeter.connect(this.masterGain);
|
||||||
|
|
||||||
|
this.chordPanner.connect(this.chordMeter);
|
||||||
|
this.chordMeter.connect(this.masterGain);
|
||||||
|
|
||||||
|
this.ambientPanner.connect(this.ambientMeter);
|
||||||
|
this.ambientMeter.connect(this.masterGain);
|
||||||
|
|
||||||
// Create sequences
|
// Create sequences
|
||||||
this.drumMachine.createSequence((step) => {
|
this.drumMachine.createSequence((step) => {
|
||||||
@ -89,6 +144,12 @@ class AudioEngine {
|
|||||||
});
|
});
|
||||||
this.chordEngine.createSequence();
|
this.chordEngine.createSequence();
|
||||||
|
|
||||||
|
// Start meter animation loop
|
||||||
|
this.startMeterLoop();
|
||||||
|
|
||||||
|
// Start bar tracking
|
||||||
|
this.startBarTracking();
|
||||||
|
|
||||||
this.state.isInitialized = true;
|
this.state.isInitialized = true;
|
||||||
this.notifyStateChange();
|
this.notifyStateChange();
|
||||||
}
|
}
|
||||||
@ -210,8 +271,164 @@ class AudioEngine {
|
|||||||
return { ...this.state };
|
return { ...this.state };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Meter methods
|
||||||
|
private startMeterLoop(): void {
|
||||||
|
const updateMeters = () => {
|
||||||
|
if (this.state.isPlaying && this.callbacks.onMeterUpdate) {
|
||||||
|
this.callbacks.onMeterUpdate(this.getMeterLevels());
|
||||||
|
}
|
||||||
|
this.meterAnimationId = requestAnimationFrame(updateMeters);
|
||||||
|
};
|
||||||
|
this.meterAnimationId = requestAnimationFrame(updateMeters);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopMeterLoop(): void {
|
||||||
|
if (this.meterAnimationId !== null) {
|
||||||
|
cancelAnimationFrame(this.meterAnimationId);
|
||||||
|
this.meterAnimationId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getMeterLevels(): MeterLevels {
|
||||||
|
const normalize = (val: number | number[]): number => {
|
||||||
|
const v = typeof val === 'number' ? val : val[0] ?? -Infinity;
|
||||||
|
const db = Math.max(-60, Math.min(0, v));
|
||||||
|
return (db + 60) / 60;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
drums: normalize(this.drumMeter?.getValue() ?? -Infinity),
|
||||||
|
chords: normalize(this.chordMeter?.getValue() ?? -Infinity),
|
||||||
|
ambient: normalize(this.ambientMeter?.getValue() ?? -Infinity),
|
||||||
|
master: normalize(this.masterMeter?.getValue() ?? -Infinity),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Panner methods
|
||||||
|
setPan(layer: LayerName, value: number): void {
|
||||||
|
const normalizedPan = Math.max(-1, Math.min(1, value));
|
||||||
|
|
||||||
|
switch (layer) {
|
||||||
|
case 'drums':
|
||||||
|
this.drumPanner?.pan.rampTo(normalizedPan, 0.1);
|
||||||
|
break;
|
||||||
|
case 'chords':
|
||||||
|
this.chordPanner?.pan.rampTo(normalizedPan, 0.1);
|
||||||
|
break;
|
||||||
|
case 'ambient':
|
||||||
|
this.ambientPanner?.pan.rampTo(normalizedPan, 0.1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getPan(layer: LayerName): number {
|
||||||
|
switch (layer) {
|
||||||
|
case 'drums':
|
||||||
|
return this.drumPanner?.pan.value ?? 0;
|
||||||
|
case 'chords':
|
||||||
|
return this.chordPanner?.pan.value ?? 0;
|
||||||
|
case 'ambient':
|
||||||
|
return this.ambientPanner?.pan.value ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solo methods
|
||||||
|
setSolo(layer: LayerName, enabled: boolean): void {
|
||||||
|
const hadAnySolo = Object.values(this.soloState).some(Boolean);
|
||||||
|
this.soloState[layer] = enabled;
|
||||||
|
const hasAnySolo = Object.values(this.soloState).some(Boolean);
|
||||||
|
|
||||||
|
if (!hadAnySolo && hasAnySolo) {
|
||||||
|
this.preSoloMuted = { ...this.state.muted };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasAnySolo) {
|
||||||
|
const layers: LayerName[] = ['drums', 'chords', 'ambient'];
|
||||||
|
layers.forEach((l) => {
|
||||||
|
const shouldMute = !this.soloState[l];
|
||||||
|
this.setMuted(l, shouldMute);
|
||||||
|
});
|
||||||
|
} else if (hadAnySolo && !hasAnySolo && this.preSoloMuted) {
|
||||||
|
const layers: LayerName[] = ['drums', 'chords', 'ambient'];
|
||||||
|
layers.forEach((l) => {
|
||||||
|
this.setMuted(l, this.preSoloMuted![l]);
|
||||||
|
});
|
||||||
|
this.preSoloMuted = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getSoloState(): Record<LayerName, boolean> {
|
||||||
|
return { ...this.soloState };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeline methods
|
||||||
|
private startBarTracking(): void {
|
||||||
|
if (this.barTrackerId !== null) {
|
||||||
|
Tone.getTransport().clear(this.barTrackerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.barTrackerId = Tone.getTransport().scheduleRepeat((time) => {
|
||||||
|
const position = Tone.getTransport().position;
|
||||||
|
const [bars, beats] = String(position).split(':').map(Number);
|
||||||
|
Tone.getDraw().schedule(() => {
|
||||||
|
this.callbacks.onBarChange?.(bars, beats);
|
||||||
|
}, time);
|
||||||
|
}, '4n');
|
||||||
|
}
|
||||||
|
|
||||||
|
seek(bar: number, beat: number = 0): void {
|
||||||
|
const position = `${bar}:${beat}:0`;
|
||||||
|
Tone.getTransport().position = position;
|
||||||
|
this.callbacks.onBarChange?.(bar, beat);
|
||||||
|
}
|
||||||
|
|
||||||
|
setDuration(bars: number): void {
|
||||||
|
this.durationBars = Math.max(4, Math.min(128, bars));
|
||||||
|
}
|
||||||
|
|
||||||
|
getDuration(): number {
|
||||||
|
return this.durationBars;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoopRegion(start: number, end: number, enabled: boolean): void {
|
||||||
|
this.loopRegion = { start, end, enabled };
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
Tone.getTransport().setLoopPoints(`${start}:0:0`, `${end}:0:0`);
|
||||||
|
Tone.getTransport().loop = true;
|
||||||
|
} else {
|
||||||
|
Tone.getTransport().loop = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getLoopRegion(): LoopRegion {
|
||||||
|
return { ...this.loopRegion };
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlaybackPosition(): { bar: number; beat: number; sixteenth: number } {
|
||||||
|
const position = String(Tone.getTransport().position);
|
||||||
|
const [bars, beats, sixteenths] = position.split(':').map(Number);
|
||||||
|
return { bar: bars || 0, beat: beats || 0, sixteenth: sixteenths || 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern access methods
|
||||||
|
getDrumMachine(): DrumMachine | null {
|
||||||
|
return this.drumMachine;
|
||||||
|
}
|
||||||
|
|
||||||
|
getChordEngine(): ChordEngine | null {
|
||||||
|
return this.chordEngine;
|
||||||
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
this.stop();
|
this.stop();
|
||||||
|
this.stopMeterLoop();
|
||||||
|
|
||||||
|
if (this.barTrackerId !== null) {
|
||||||
|
Tone.getTransport().clear(this.barTrackerId);
|
||||||
|
this.barTrackerId = null;
|
||||||
|
}
|
||||||
|
|
||||||
this.drumMachine?.dispose();
|
this.drumMachine?.dispose();
|
||||||
this.chordEngine?.dispose();
|
this.chordEngine?.dispose();
|
||||||
this.ambientLayer?.dispose();
|
this.ambientLayer?.dispose();
|
||||||
@ -220,6 +437,15 @@ class AudioEngine {
|
|||||||
this.masterLimiter?.dispose();
|
this.masterLimiter?.dispose();
|
||||||
this.masterReverb?.dispose();
|
this.masterReverb?.dispose();
|
||||||
|
|
||||||
|
// Dispose new components
|
||||||
|
this.drumMeter?.dispose();
|
||||||
|
this.chordMeter?.dispose();
|
||||||
|
this.ambientMeter?.dispose();
|
||||||
|
this.masterMeter?.dispose();
|
||||||
|
this.drumPanner?.dispose();
|
||||||
|
this.chordPanner?.dispose();
|
||||||
|
this.ambientPanner?.dispose();
|
||||||
|
|
||||||
this.drumMachine = null;
|
this.drumMachine = null;
|
||||||
this.chordEngine = null;
|
this.chordEngine = null;
|
||||||
this.ambientLayer = null;
|
this.ambientLayer = null;
|
||||||
@ -227,6 +453,13 @@ class AudioEngine {
|
|||||||
this.masterCompressor = null;
|
this.masterCompressor = null;
|
||||||
this.masterLimiter = null;
|
this.masterLimiter = null;
|
||||||
this.masterReverb = null;
|
this.masterReverb = null;
|
||||||
|
this.drumMeter = null;
|
||||||
|
this.chordMeter = null;
|
||||||
|
this.ambientMeter = null;
|
||||||
|
this.masterMeter = null;
|
||||||
|
this.drumPanner = null;
|
||||||
|
this.chordPanner = null;
|
||||||
|
this.ambientPanner = null;
|
||||||
|
|
||||||
this.state.isInitialized = false;
|
this.state.isInitialized = false;
|
||||||
this.state.isPlaying = false;
|
this.state.isPlaying = false;
|
||||||
|
|||||||
176
lib/audio/timelineScheduler.ts
Normal file
176
lib/audio/timelineScheduler.ts
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
import * as Tone from 'tone';
|
||||||
|
import {
|
||||||
|
PatternKeyframe,
|
||||||
|
ChordKeyframe,
|
||||||
|
MuteKeyframe,
|
||||||
|
AutomationPoint,
|
||||||
|
LayerName,
|
||||||
|
} from '@/types/audio';
|
||||||
|
import { DrumMachine } from './drumMachine';
|
||||||
|
import { ChordEngine } from './chordEngine';
|
||||||
|
import { drumPatterns, chordProgressions } from './patterns';
|
||||||
|
|
||||||
|
type ScheduledEventId = number;
|
||||||
|
|
||||||
|
interface ScheduledEvents {
|
||||||
|
drumPatterns: ScheduledEventId[];
|
||||||
|
chordProgressions: ScheduledEventId[];
|
||||||
|
muteEvents: Record<LayerName, ScheduledEventId[]>;
|
||||||
|
automationEvents: Record<LayerName, ScheduledEventId[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TimelineScheduler {
|
||||||
|
private transport = Tone.getTransport();
|
||||||
|
private events: ScheduledEvents = {
|
||||||
|
drumPatterns: [],
|
||||||
|
chordProgressions: [],
|
||||||
|
muteEvents: { drums: [], chords: [], ambient: [] },
|
||||||
|
automationEvents: { drums: [], chords: [], ambient: [] },
|
||||||
|
};
|
||||||
|
|
||||||
|
private drumMachine: DrumMachine | null = null;
|
||||||
|
private chordEngine: ChordEngine | null = null;
|
||||||
|
private volumeCallback: ((layer: LayerName, value: number) => void) | null = null;
|
||||||
|
private muteCallback: ((layer: LayerName, muted: boolean) => void) | null = null;
|
||||||
|
|
||||||
|
setDrumMachine(dm: DrumMachine | null): void {
|
||||||
|
this.drumMachine = dm;
|
||||||
|
}
|
||||||
|
|
||||||
|
setChordEngine(ce: ChordEngine | null): void {
|
||||||
|
this.chordEngine = ce;
|
||||||
|
}
|
||||||
|
|
||||||
|
setVolumeCallback(cb: (layer: LayerName, value: number) => void): void {
|
||||||
|
this.volumeCallback = cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMuteCallback(cb: (layer: LayerName, muted: boolean) => void): void {
|
||||||
|
this.muteCallback = cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleDrumKeyframes(keyframes: PatternKeyframe[]): void {
|
||||||
|
this.clearDrumEvents();
|
||||||
|
|
||||||
|
const sorted = [...keyframes].sort((a, b) => a.bar - b.bar);
|
||||||
|
|
||||||
|
sorted.forEach((kf) => {
|
||||||
|
const eventId = this.transport.schedule((time) => {
|
||||||
|
if (this.drumMachine && drumPatterns[kf.patternIndex]) {
|
||||||
|
this.drumMachine.setPattern(drumPatterns[kf.patternIndex]);
|
||||||
|
}
|
||||||
|
}, `${kf.bar}:0:0`);
|
||||||
|
|
||||||
|
this.events.drumPatterns.push(eventId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleChordKeyframes(keyframes: ChordKeyframe[]): void {
|
||||||
|
this.clearChordEvents();
|
||||||
|
|
||||||
|
const sorted = [...keyframes].sort((a, b) => a.bar - b.bar);
|
||||||
|
|
||||||
|
sorted.forEach((kf) => {
|
||||||
|
const eventId = this.transport.schedule((time) => {
|
||||||
|
if (this.chordEngine && chordProgressions[kf.progressionIndex]) {
|
||||||
|
this.chordEngine.setProgression(chordProgressions[kf.progressionIndex]);
|
||||||
|
}
|
||||||
|
}, `${kf.bar}:0:0`);
|
||||||
|
|
||||||
|
this.events.chordProgressions.push(eventId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleMuteKeyframes(layer: LayerName, keyframes: MuteKeyframe[]): void {
|
||||||
|
this.clearMuteEvents(layer);
|
||||||
|
|
||||||
|
const sorted = [...keyframes].sort((a, b) => a.bar - b.bar);
|
||||||
|
|
||||||
|
sorted.forEach((kf) => {
|
||||||
|
const eventId = this.transport.schedule((time) => {
|
||||||
|
this.muteCallback?.(layer, kf.muted);
|
||||||
|
}, `${kf.bar}:0:0`);
|
||||||
|
|
||||||
|
this.events.muteEvents[layer].push(eventId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleVolumeAutomation(layer: LayerName, points: AutomationPoint[]): void {
|
||||||
|
this.clearAutomationEvents(layer);
|
||||||
|
|
||||||
|
if (points.length === 0) return;
|
||||||
|
|
||||||
|
const sorted = [...points].sort((a, b) => {
|
||||||
|
if (a.bar !== b.bar) return a.bar - b.bar;
|
||||||
|
return a.beat - b.beat;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < sorted.length; i++) {
|
||||||
|
const current = sorted[i];
|
||||||
|
const next = sorted[i + 1];
|
||||||
|
|
||||||
|
if (!next) {
|
||||||
|
const eventId = this.transport.schedule((time) => {
|
||||||
|
this.volumeCallback?.(layer, current.value);
|
||||||
|
}, `${current.bar}:${current.beat}:0`);
|
||||||
|
this.events.automationEvents[layer].push(eventId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = this.transport.toSeconds(`${current.bar}:${current.beat}:0`);
|
||||||
|
const endTime = this.transport.toSeconds(`${next.bar}:${next.beat}:0`);
|
||||||
|
const duration = endTime - startTime;
|
||||||
|
const steps = Math.max(1, Math.floor(duration * 16));
|
||||||
|
const stepDuration = duration / steps;
|
||||||
|
|
||||||
|
for (let step = 0; step <= steps; step++) {
|
||||||
|
const t = step / steps;
|
||||||
|
const value = current.value + (next.value - current.value) * t;
|
||||||
|
const scheduleTime = startTime + step * stepDuration;
|
||||||
|
|
||||||
|
const eventId = this.transport.schedule((time) => {
|
||||||
|
this.volumeCallback?.(layer, value);
|
||||||
|
}, scheduleTime);
|
||||||
|
this.events.automationEvents[layer].push(eventId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearDrumEvents(): void {
|
||||||
|
this.events.drumPatterns.forEach((id) => this.transport.clear(id));
|
||||||
|
this.events.drumPatterns = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearChordEvents(): void {
|
||||||
|
this.events.chordProgressions.forEach((id) => this.transport.clear(id));
|
||||||
|
this.events.chordProgressions = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearMuteEvents(layer: LayerName): void {
|
||||||
|
this.events.muteEvents[layer].forEach((id) => this.transport.clear(id));
|
||||||
|
this.events.muteEvents[layer] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearAutomationEvents(layer: LayerName): void {
|
||||||
|
this.events.automationEvents[layer].forEach((id) => this.transport.clear(id));
|
||||||
|
this.events.automationEvents[layer] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAll(): void {
|
||||||
|
this.clearDrumEvents();
|
||||||
|
this.clearChordEvents();
|
||||||
|
const layers: LayerName[] = ['drums', 'chords', 'ambient'];
|
||||||
|
layers.forEach((l) => {
|
||||||
|
this.clearMuteEvents(l);
|
||||||
|
this.clearAutomationEvents(l);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.clearAll();
|
||||||
|
this.drumMachine = null;
|
||||||
|
this.chordEngine = null;
|
||||||
|
this.volumeCallback = null;
|
||||||
|
this.muteCallback = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -35,4 +35,94 @@ export type LayerName = 'drums' | 'chords' | 'ambient';
|
|||||||
export interface AudioEngineCallbacks {
|
export interface AudioEngineCallbacks {
|
||||||
onStepChange?: (step: number) => void;
|
onStepChange?: (step: number) => void;
|
||||||
onStateChange?: (state: EngineState) => void;
|
onStateChange?: (state: EngineState) => void;
|
||||||
|
onBarChange?: (bar: number, beat: number) => void;
|
||||||
|
onMeterUpdate?: (levels: MeterLevels) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mixer types
|
||||||
|
export interface MeterLevels {
|
||||||
|
drums: number;
|
||||||
|
chords: number;
|
||||||
|
ambient: number;
|
||||||
|
master: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MixerState {
|
||||||
|
volumes: Record<LayerName | 'master', number>;
|
||||||
|
pans: Record<LayerName, number>;
|
||||||
|
muted: Record<LayerName, boolean>;
|
||||||
|
soloed: Record<LayerName, boolean>;
|
||||||
|
levels: MeterLevels;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeline types
|
||||||
|
export type SectionType = 'intro' | 'verse' | 'chorus' | 'drop' | 'bridge' | 'outro';
|
||||||
|
|
||||||
|
export interface Section {
|
||||||
|
id: string;
|
||||||
|
type: SectionType;
|
||||||
|
name: string;
|
||||||
|
startBar: number;
|
||||||
|
endBar: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PatternKeyframe {
|
||||||
|
id: string;
|
||||||
|
bar: number;
|
||||||
|
patternIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChordKeyframe {
|
||||||
|
id: string;
|
||||||
|
bar: number;
|
||||||
|
progressionIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutomationPoint {
|
||||||
|
id: string;
|
||||||
|
bar: number;
|
||||||
|
beat: number;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MuteKeyframe {
|
||||||
|
id: string;
|
||||||
|
bar: number;
|
||||||
|
muted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoopRegion {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineState {
|
||||||
|
durationBars: number;
|
||||||
|
playheadBar: number;
|
||||||
|
playheadBeat: number;
|
||||||
|
sections: Section[];
|
||||||
|
drumKeyframes: PatternKeyframe[];
|
||||||
|
chordKeyframes: ChordKeyframe[];
|
||||||
|
volumeAutomation: Record<LayerName, AutomationPoint[]>;
|
||||||
|
muteKeyframes: Record<LayerName, MuteKeyframe[]>;
|
||||||
|
loopRegion: LoopRegion;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaybackPosition {
|
||||||
|
bar: number;
|
||||||
|
beat: number;
|
||||||
|
sixteenth: number;
|
||||||
|
totalSeconds: number;
|
||||||
|
totalBars: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SECTION_COLORS: Record<SectionType, string> = {
|
||||||
|
intro: 'oklch(0.6 0.15 230)',
|
||||||
|
verse: 'oklch(0.6 0.12 180)',
|
||||||
|
chorus: 'oklch(0.65 0.15 50)',
|
||||||
|
drop: 'oklch(0.6 0.18 25)',
|
||||||
|
bridge: 'oklch(0.55 0.15 280)',
|
||||||
|
outro: 'oklch(0.5 0.1 230)',
|
||||||
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user