feat(timeline-mixer): WIP timeline and mixer components
work in progress implementation of: - mixer with channel strips, faders, pan knobs, level meters - timeline with ruler, playhead, sections, keyframe tracks - pattern and progression pickers for drums/chords - automation lanes and mute tracks - loop bracket for loop region selection - export modal placeholder known issues: - drum pattern changes don't update audio engine - timeline keyframes not connected to scheduler - some UI bugs remain this is a checkpoint commit for further iteration
This commit is contained in:
parent
5ed84192d5
commit
d3158e4c6a
@ -123,3 +123,19 @@
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
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';
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TransportControls } from './TransportControls';
|
||||
import { VolumeControl } from './VolumeControl';
|
||||
import { LayerMixer } from './LayerMixer';
|
||||
import { Visualizer } from './Visualizer';
|
||||
import { Mixer } from '@/components/mixer';
|
||||
import { Timeline, ExportModal } from '@/components/timeline';
|
||||
import { useAudioEngine } from '@/hooks/useAudioEngine';
|
||||
import { useTimeline } from '@/hooks/useTimeline';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Download, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { LayerName } from '@/types/audio';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function LofiGenerator() {
|
||||
const {
|
||||
state,
|
||||
currentStep,
|
||||
meterLevels,
|
||||
pans,
|
||||
soloed,
|
||||
playheadPosition,
|
||||
togglePlayback,
|
||||
generateNewBeat,
|
||||
setMasterVolume,
|
||||
setLayerVolume,
|
||||
toggleMute,
|
||||
setPan,
|
||||
toggleSolo,
|
||||
setBpm,
|
||||
setSwing,
|
||||
seek,
|
||||
setLoopRegion,
|
||||
} = useAudioEngine();
|
||||
|
||||
const timeline = useTimeline();
|
||||
|
||||
const [showTimeline, setShowTimeline] = useState(false);
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
timeline.setPlayheadPosition(playheadPosition.bar, playheadPosition.beat);
|
||||
}, [playheadPosition, timeline.setPlayheadPosition]);
|
||||
|
||||
const handleVolumeChange = useCallback(
|
||||
(layer: LayerName | 'master', volume: number) => {
|
||||
if (layer === 'master') {
|
||||
setMasterVolume(volume);
|
||||
} else {
|
||||
setLayerVolume(layer, volume);
|
||||
}
|
||||
},
|
||||
[setMasterVolume, setLayerVolume]
|
||||
);
|
||||
|
||||
const handleExport = useCallback(async (): Promise<Blob> => {
|
||||
return new Blob(['placeholder'], { type: 'audio/wav' });
|
||||
}, []);
|
||||
|
||||
const handleLoopRegionChange = useCallback(
|
||||
(start: number, end: number, enabled: boolean) => {
|
||||
timeline.setLoopRegion(start, end, enabled);
|
||||
setLoopRegion(start, end, enabled);
|
||||
},
|
||||
[timeline.setLoopRegion, setLoopRegion]
|
||||
);
|
||||
|
||||
const volumes = {
|
||||
master: state.volumes.master,
|
||||
drums: state.volumes.drums,
|
||||
chords: state.volumes.chords,
|
||||
ambient: state.volumes.ambient,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-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">
|
||||
<CardTitle className="text-2xl font-light tracking-wide">
|
||||
lofi generator
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
beats to relax/study to
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">beats to relax/study to</p>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* Visualizer */}
|
||||
<CardContent className="space-y-4">
|
||||
<Visualizer currentStep={currentStep} isPlaying={state.isPlaying} />
|
||||
|
||||
{/* Transport Controls */}
|
||||
<div className="flex justify-center">
|
||||
<div className="flex justify-center items-center gap-2">
|
||||
<TransportControls
|
||||
isPlaying={state.isPlaying}
|
||||
isInitialized={state.isInitialized}
|
||||
onTogglePlayback={togglePlayback}
|
||||
onGenerateNewBeat={generateNewBeat}
|
||||
/>
|
||||
<Button
|
||||
variant={showTimeline ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setShowTimeline(!showTimeline)}
|
||||
className="gap-1 h-9"
|
||||
>
|
||||
Timeline
|
||||
{showTimeline ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowExportModal(true)}
|
||||
className="h-9 w-9 p-0"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 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="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<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">
|
||||
{state.bpm}
|
||||
</span>
|
||||
@ -74,9 +134,9 @@ export function LofiGenerator() {
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<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">
|
||||
{Math.round(state.swing * 100)}%
|
||||
</span>
|
||||
@ -91,20 +151,58 @@ export function LofiGenerator() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Layer Mixer */}
|
||||
<LayerMixer
|
||||
volumes={state.volumes}
|
||||
<Mixer
|
||||
volumes={volumes}
|
||||
pans={pans}
|
||||
muted={state.muted}
|
||||
onVolumeChange={setLayerVolume}
|
||||
onToggleMute={toggleMute}
|
||||
soloed={soloed}
|
||||
levels={meterLevels}
|
||||
onVolumeChange={handleVolumeChange}
|
||||
onPanChange={setPan}
|
||||
onMuteToggle={toggleMute}
|
||||
onSoloToggle={toggleSolo}
|
||||
/>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-center text-xs text-muted-foreground/60 pt-4">
|
||||
Click play to start the audio engine
|
||||
</p>
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden transition-all duration-300 ease-in-out',
|
||||
showTimeline ? 'max-h-[500px] opacity-100' : 'max-h-0 opacity-0'
|
||||
)}
|
||||
>
|
||||
<Timeline
|
||||
state={timeline.state}
|
||||
bpm={state.bpm}
|
||||
isPlaying={state.isPlaying}
|
||||
onDurationChange={timeline.setDuration}
|
||||
onSeek={seek}
|
||||
onLoopRegionChange={handleLoopRegionChange}
|
||||
onSectionAdd={timeline.addSection}
|
||||
onSectionResize={timeline.resizeSection}
|
||||
onSectionMove={timeline.moveSection}
|
||||
onSectionDelete={timeline.deleteSection}
|
||||
onDrumKeyframeAdd={timeline.addDrumKeyframe}
|
||||
onDrumKeyframeUpdate={timeline.updateDrumKeyframe}
|
||||
onDrumKeyframeDelete={timeline.deleteDrumKeyframe}
|
||||
onChordKeyframeAdd={timeline.addChordKeyframe}
|
||||
onChordKeyframeUpdate={timeline.updateChordKeyframe}
|
||||
onChordKeyframeDelete={timeline.deleteChordKeyframe}
|
||||
onMuteKeyframeToggle={timeline.toggleMuteKeyframe}
|
||||
onAutomationPointAdd={timeline.addAutomationPoint}
|
||||
onAutomationPointUpdate={timeline.updateAutomationPoint}
|
||||
onAutomationPointDelete={timeline.deleteAutomationPoint}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{showExportModal && (
|
||||
<ExportModal
|
||||
durationBars={timeline.state.durationBars}
|
||||
bpm={state.bpm}
|
||||
onExport={handleExport}
|
||||
onClose={() => setShowExportModal(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { EngineState, LayerName } from '@/types/audio';
|
||||
import { EngineState, LayerName, MeterLevels, LoopRegion } from '@/types/audio';
|
||||
|
||||
const defaultState: EngineState = {
|
||||
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() {
|
||||
const [state, setState] = useState<EngineState>(defaultState);
|
||||
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 isInitializingRef = useRef(false);
|
||||
|
||||
@ -55,6 +79,12 @@ export function useAudioEngine() {
|
||||
onStateChange: (newState) => {
|
||||
setState(newState);
|
||||
},
|
||||
onBarChange: (bar, beat) => {
|
||||
setPlayheadPosition({ bar, beat });
|
||||
},
|
||||
onMeterUpdate: (levels) => {
|
||||
setMeterLevels(levels);
|
||||
},
|
||||
});
|
||||
|
||||
await engine.initialize();
|
||||
@ -136,6 +166,70 @@ export function useAudioEngine() {
|
||||
engine.toggleMute(layer);
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@ -147,6 +241,10 @@ export function useAudioEngine() {
|
||||
return {
|
||||
state,
|
||||
currentStep,
|
||||
meterLevels,
|
||||
pans,
|
||||
soloed,
|
||||
playheadPosition,
|
||||
initialize,
|
||||
togglePlayback,
|
||||
stop,
|
||||
@ -156,5 +254,13 @@ export function useAudioEngine() {
|
||||
setMasterVolume,
|
||||
setLayerVolume,
|
||||
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 { ChordEngine } from './chordEngine';
|
||||
import { AmbientLayer } from './ambientLayer';
|
||||
import { EngineState, AudioEngineCallbacks, LayerName } from '@/types/audio';
|
||||
import {
|
||||
EngineState,
|
||||
AudioEngineCallbacks,
|
||||
LayerName,
|
||||
MeterLevels,
|
||||
LoopRegion,
|
||||
} from '@/types/audio';
|
||||
|
||||
class AudioEngine {
|
||||
private static instance: AudioEngine | null = null;
|
||||
@ -16,6 +22,33 @@ class AudioEngine {
|
||||
private masterLimiter: Tone.Limiter | 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 state: EngineState = {
|
||||
@ -57,6 +90,17 @@ class AudioEngine {
|
||||
Tone.getTransport().swing = this.state.swing;
|
||||
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
|
||||
this.masterGain = new Tone.Gain(this.state.volumes.master);
|
||||
this.masterCompressor = new Tone.Compressor({
|
||||
@ -71,16 +115,27 @@ class AudioEngine {
|
||||
wet: 0.15,
|
||||
});
|
||||
|
||||
// Chain: gain -> reverb -> compressor -> limiter -> destination
|
||||
// Chain: gain -> reverb -> compressor -> limiter -> meter -> destination
|
||||
this.masterGain.connect(this.masterReverb);
|
||||
this.masterReverb.connect(this.masterCompressor);
|
||||
this.masterCompressor.connect(this.masterLimiter);
|
||||
this.masterLimiter.toDestination();
|
||||
this.masterLimiter.connect(this.masterMeter);
|
||||
this.masterMeter.toDestination();
|
||||
|
||||
// Initialize layers
|
||||
this.drumMachine = new DrumMachine(this.masterGain);
|
||||
this.chordEngine = new ChordEngine(this.masterGain);
|
||||
this.ambientLayer = new AmbientLayer(this.masterGain);
|
||||
// Initialize layers with panners and meters in chain
|
||||
this.drumMachine = new DrumMachine(this.drumPanner);
|
||||
this.chordEngine = new ChordEngine(this.chordPanner);
|
||||
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
|
||||
this.drumMachine.createSequence((step) => {
|
||||
@ -89,6 +144,12 @@ class AudioEngine {
|
||||
});
|
||||
this.chordEngine.createSequence();
|
||||
|
||||
// Start meter animation loop
|
||||
this.startMeterLoop();
|
||||
|
||||
// Start bar tracking
|
||||
this.startBarTracking();
|
||||
|
||||
this.state.isInitialized = true;
|
||||
this.notifyStateChange();
|
||||
}
|
||||
@ -210,8 +271,164 @@ class AudioEngine {
|
||||
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 {
|
||||
this.stop();
|
||||
this.stopMeterLoop();
|
||||
|
||||
if (this.barTrackerId !== null) {
|
||||
Tone.getTransport().clear(this.barTrackerId);
|
||||
this.barTrackerId = null;
|
||||
}
|
||||
|
||||
this.drumMachine?.dispose();
|
||||
this.chordEngine?.dispose();
|
||||
this.ambientLayer?.dispose();
|
||||
@ -220,6 +437,15 @@ class AudioEngine {
|
||||
this.masterLimiter?.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.chordEngine = null;
|
||||
this.ambientLayer = null;
|
||||
@ -227,6 +453,13 @@ class AudioEngine {
|
||||
this.masterCompressor = null;
|
||||
this.masterLimiter = 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.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 {
|
||||
onStepChange?: (step: number) => 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