From d3158e4c6a1d0b7344691743bcda1740d99de08a Mon Sep 17 00:00:00 2001 From: Nicholai Date: Tue, 20 Jan 2026 18:22:10 -0700 Subject: [PATCH] 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 --- app/globals.css | 16 + components/lofi-generator/LofiGenerator.tsx | 164 ++++++++--- components/mixer/ChannelStrip.tsx | 102 +++++++ components/mixer/LevelMeter.tsx | 52 ++++ components/mixer/Mixer.tsx | 78 +++++ components/mixer/PanKnob.tsx | 81 ++++++ components/mixer/VerticalFader.tsx | 113 ++++++++ components/mixer/index.ts | 5 + components/timeline/AutomationLane.tsx | 197 +++++++++++++ components/timeline/DurationSelector.tsx | 32 ++ components/timeline/ExportModal.tsx | 163 +++++++++++ components/timeline/KeyframeMarker.tsx | 56 ++++ components/timeline/KeyframeTrack.tsx | 137 +++++++++ components/timeline/LoopBracket.tsx | 127 ++++++++ components/timeline/MuteTrack.tsx | 117 ++++++++ components/timeline/PatternPicker.tsx | 108 +++++++ components/timeline/Playhead.tsx | 94 ++++++ components/timeline/ProgressionPicker.tsx | 91 ++++++ components/timeline/SectionBlock.tsx | 130 +++++++++ components/timeline/SectionPicker.tsx | 63 ++++ components/timeline/SectionTrack.tsx | 112 +++++++ components/timeline/TimeDisplay.tsx | 55 ++++ components/timeline/Timeline.tsx | 305 ++++++++++++++++++++ components/timeline/TimelineRuler.tsx | 51 ++++ components/timeline/index.ts | 16 + hooks/useAudioEngine.ts | 108 ++++++- hooks/useTimeline.ts | 277 ++++++++++++++++++ lib/audio/audioEngine.ts | 247 +++++++++++++++- lib/audio/timelineScheduler.ts | 176 +++++++++++ types/audio.ts | 90 ++++++ 30 files changed, 3322 insertions(+), 41 deletions(-) create mode 100644 components/mixer/ChannelStrip.tsx create mode 100644 components/mixer/LevelMeter.tsx create mode 100644 components/mixer/Mixer.tsx create mode 100644 components/mixer/PanKnob.tsx create mode 100644 components/mixer/VerticalFader.tsx create mode 100644 components/mixer/index.ts create mode 100644 components/timeline/AutomationLane.tsx create mode 100644 components/timeline/DurationSelector.tsx create mode 100644 components/timeline/ExportModal.tsx create mode 100644 components/timeline/KeyframeMarker.tsx create mode 100644 components/timeline/KeyframeTrack.tsx create mode 100644 components/timeline/LoopBracket.tsx create mode 100644 components/timeline/MuteTrack.tsx create mode 100644 components/timeline/PatternPicker.tsx create mode 100644 components/timeline/Playhead.tsx create mode 100644 components/timeline/ProgressionPicker.tsx create mode 100644 components/timeline/SectionBlock.tsx create mode 100644 components/timeline/SectionPicker.tsx create mode 100644 components/timeline/SectionTrack.tsx create mode 100644 components/timeline/TimeDisplay.tsx create mode 100644 components/timeline/Timeline.tsx create mode 100644 components/timeline/TimelineRuler.tsx create mode 100644 components/timeline/index.ts create mode 100644 hooks/useTimeline.ts create mode 100644 lib/audio/timelineScheduler.ts diff --git a/app/globals.css b/app/globals.css index 3c0816e..355d399 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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; +} diff --git a/components/lofi-generator/LofiGenerator.tsx b/components/lofi-generator/LofiGenerator.tsx index cc71a92..d0a1ceb 100644 --- a/components/lofi-generator/LofiGenerator.tsx +++ b/components/lofi-generator/LofiGenerator.tsx @@ -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 => { + 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 (
- + lofi generator -

- beats to relax/study to -

+

beats to relax/study to

- - {/* Visualizer */} + - {/* Transport Controls */} -
+
+ +
- {/* Master Volume */} -
- -
- - {/* BPM and Swing Controls */}
-
+
- + {state.bpm} @@ -74,9 +134,9 @@ export function LofiGenerator() { step={1} />
-
+
- + {Math.round(state.swing * 100)}% @@ -91,20 +151,58 @@ export function LofiGenerator() {
- {/* Layer Mixer */} - - {/* Footer */} -

- Click play to start the audio engine -

+
+ +
+ + {showExportModal && ( + setShowExportModal(false)} + /> + )}
); } diff --git a/components/mixer/ChannelStrip.tsx b/components/mixer/ChannelStrip.tsx new file mode 100644 index 0000000..176dd80 --- /dev/null +++ b/components/mixer/ChannelStrip.tsx @@ -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 ( +
+
+ {icon} + {name} +
+ + {showPan && } + +
+
+ + + {Math.round(volume * 100)} + +
+ + +
+ +
+ {showSolo && ( + + )} + +
+
+ ); +} diff --git a/components/mixer/LevelMeter.tsx b/components/mixer/LevelMeter.tsx new file mode 100644 index 0000000..18cfac2 --- /dev/null +++ b/components/mixer/LevelMeter.tsx @@ -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 ( +
+ {segments.map((seg, i) => ( +
+ ))} +
+ ); +} diff --git a/components/mixer/Mixer.tsx b/components/mixer/Mixer.tsx new file mode 100644 index 0000000..393bf3a --- /dev/null +++ b/components/mixer/Mixer.tsx @@ -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; + pans: Record; + muted: Record; + soloed: Record; + 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: }, + { name: 'chords', label: 'Chords', icon: }, + { name: 'ambient', label: 'Ambient', icon: }, +]; + +export function Mixer({ + volumes, + pans, + muted, + soloed, + levels, + onVolumeChange, + onPanChange, + onMuteToggle, + onSoloToggle, +}: MixerProps) { + return ( +
+ {layerConfig.map(({ name, label, icon }) => ( + onVolumeChange(name, v)} + onPanChange={(p) => onPanChange(name, p)} + onMuteToggle={() => onMuteToggle(name)} + onSoloToggle={() => onSoloToggle(name)} + /> + ))} + +
+ + } + volume={volumes.master} + pan={0} + muted={false} + soloed={false} + level={levels.master} + onVolumeChange={(v) => onVolumeChange('master', v)} + onPanChange={() => {}} + onMuteToggle={() => {}} + onSoloToggle={() => {}} + showPan={false} + showSolo={false} + /> +
+ ); +} diff --git a/components/mixer/PanKnob.tsx b/components/mixer/PanKnob.tsx new file mode 100644 index 0000000..87f212b --- /dev/null +++ b/components/mixer/PanKnob.tsx @@ -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(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 ( +
+
+
+
+ {label} +
+
+ Pan +
+ ); +} diff --git a/components/mixer/VerticalFader.tsx b/components/mixer/VerticalFader.tsx new file mode 100644 index 0000000..f45dfdf --- /dev/null +++ b/components/mixer/VerticalFader.tsx @@ -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(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 ( +
+ {/* Fill */} +
+ {/* Thumb */} +
+
+ ); +} diff --git a/components/mixer/index.ts b/components/mixer/index.ts new file mode 100644 index 0000000..ef1f6ac --- /dev/null +++ b/components/mixer/index.ts @@ -0,0 +1,5 @@ +export { Mixer } from './Mixer'; +export { ChannelStrip } from './ChannelStrip'; +export { LevelMeter } from './LevelMeter'; +export { PanKnob } from './PanKnob'; +export { VerticalFader } from './VerticalFader'; diff --git a/components/timeline/AutomationLane.tsx b/components/timeline/AutomationLane.tsx new file mode 100644 index 0000000..a9d559b --- /dev/null +++ b/components/timeline/AutomationLane.tsx @@ -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(null); + const [draggingId, setDraggingId] = useState(null); + const [selectedId, setSelectedId] = useState(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) => { + 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 ( +
+
+ {icon} + + {label} + +
+ +
+ {Array.from({ length: durationBars }, (_, i) => ( +
+ ))} + + + {pathD && ( + + )} + + + {sortedPoints.map((point) => { + const x = point.bar * pixelsPerBar + point.beat * pixelsPerBeat; + const y = height - point.value * height; + + return ( +
handlePointMouseDown(point, e)} + onContextMenu={(e) => handlePointContextMenu(point, e)} + /> + ); + })} +
+
+ ); +} diff --git a/components/timeline/DurationSelector.tsx b/components/timeline/DurationSelector.tsx new file mode 100644 index 0000000..e6500d8 --- /dev/null +++ b/components/timeline/DurationSelector.tsx @@ -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 ( +
+ Duration: + {presets.map((bars) => ( + + ))} + bars +
+ ); +} diff --git a/components/timeline/ExportModal.tsx b/components/timeline/ExportModal.tsx new file mode 100644 index 0000000..c7b15d8 --- /dev/null +++ b/components/timeline/ExportModal.tsx @@ -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; + 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(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 ( +
+ + +
+ Export Audio + +
+
+ + +
+ +
+ 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' + )} + /> + .wav +
+
+ +
+
+ Duration: + {formatDuration(totalSeconds)} +
+
+ Size: + ~{estimatedSizeMB} MB +
+
+ Format: + WAV 44.1kHz 16-bit +
+
+ Bars: + {durationBars} +
+
+ + {isExporting && ( +
+
+ Rendering... + {Math.round(progress)}% +
+
+
+
+
+ )} + + {error && ( +
+ {error} +
+ )} + + + + +
+ ); +} diff --git a/components/timeline/KeyframeMarker.tsx b/components/timeline/KeyframeMarker.tsx new file mode 100644 index 0000000..73847f5 --- /dev/null +++ b/components/timeline/KeyframeMarker.tsx @@ -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 ( +
+
+
+ ); +} diff --git a/components/timeline/KeyframeTrack.tsx b/components/timeline/KeyframeTrack.tsx new file mode 100644 index 0000000..8d7ef4b --- /dev/null +++ b/components/timeline/KeyframeTrack.tsx @@ -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 { + 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({ + label, + icon, + keyframes, + durationBars, + pixelsPerBar, + selectedId, + onSelect, + onAdd, + onDelete, + markerColor = 'bg-primary', + renderPicker, + className, +}: KeyframeTrackProps) { + const [pickerPosition, setPickerPosition] = useState<{ x: number; y: number } | null>( + null + ); + + const handleTrackClick = useCallback( + (e: React.MouseEvent) => { + 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 ( +
+
+ {icon} + + {label} + +
+ +
+ {Array.from({ length: durationBars }, (_, i) => ( +
+ ))} + + {keyframes.map((kf) => ( + handleMarkerSelect(kf)} + onDelete={() => onDelete(kf.id)} + /> + ))} + + {selectedKeyframe && pickerPosition && renderPicker && ( +
+ {renderPicker({ + keyframe: selectedKeyframe, + onClose: handleClosePicker, + position: pickerPosition, + })} +
+ )} +
+
+ ); +} diff --git a/components/timeline/LoopBracket.tsx b/components/timeline/LoopBracket.tsx new file mode 100644 index 0000000..e2950e7 --- /dev/null +++ b/components/timeline/LoopBracket.tsx @@ -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(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 ( +
+
+
handleMouseDown('start', e)} + /> + +
handleMouseDown('region', e)} + /> + +
handleMouseDown('end', e)} + /> +
+ + +
+ ); +} diff --git a/components/timeline/MuteTrack.tsx b/components/timeline/MuteTrack.tsx new file mode 100644 index 0000000..f5851c0 --- /dev/null +++ b/components/timeline/MuteTrack.tsx @@ -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) => { + 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 ( +
+
+ {icon} + + {label} + +
+ +
+ {Array.from({ length: durationBars }, (_, i) => ( +
+ ))} + + {mutedRegions.map((region, i) => ( +
+ ))} + + {keyframes.map((kf) => ( +
+ ))} +
+
+ ); +} diff --git a/components/timeline/PatternPicker.tsx b/components/timeline/PatternPicker.tsx new file mode 100644 index 0000000..8e5a466 --- /dev/null +++ b/components/timeline/PatternPicker.tsx @@ -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 ( +
+ {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 ( +
+ {hasKick &&
} + {hasSnare &&
} + {(hasHihat || hasOpenhat) && ( +
+ )} +
+ ); + })} +
+ ); +} + +export function PatternPicker({ + selectedIndex, + onSelect, + onRandom, + onClose, + className, +}: PatternPickerProps) { + return ( + e.stopPropagation()} + > +
+ + Drum Patterns + + +
+ +
+ {drumPatterns.map((pattern, i) => ( + + ))} +
+ + +
+ ); +} diff --git a/components/timeline/Playhead.tsx b/components/timeline/Playhead.tsx new file mode 100644 index 0000000..d10038a --- /dev/null +++ b/components/timeline/Playhead.tsx @@ -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 ( +
+
+
+
+ ); +} diff --git a/components/timeline/ProgressionPicker.tsx b/components/timeline/ProgressionPicker.tsx new file mode 100644 index 0000000..5f9c68b --- /dev/null +++ b/components/timeline/ProgressionPicker.tsx @@ -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 = { + '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 ( + e.stopPropagation()} + > +
+ + Chord Progressions + + +
+ +
+ {chordProgressions.map((prog, i) => ( + + ))} +
+ + +
+ ); +} diff --git a/components/timeline/SectionBlock.tsx b/components/timeline/SectionBlock.tsx new file mode 100644 index 0000000..ec3aff2 --- /dev/null +++ b/components/timeline/SectionBlock.tsx @@ -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 ( +
+
handleMouseDown('start', e)} + /> + +
handleMouseDown('move', e)} + > + + {section.name || section.type} + +
+ +
handleMouseDown('end', e)} + /> +
+ ); +} diff --git a/components/timeline/SectionPicker.tsx b/components/timeline/SectionPicker.tsx new file mode 100644 index 0000000..6b0533d --- /dev/null +++ b/components/timeline/SectionPicker.tsx @@ -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 ( + e.stopPropagation()} + > +
+ Add Section +
+
+ {sectionTypes.map(({ type, label }) => ( + + ))} +
+ +
+ ); +} diff --git a/components/timeline/SectionTrack.tsx b/components/timeline/SectionTrack.tsx new file mode 100644 index 0000000..9054e76 --- /dev/null +++ b/components/timeline/SectionTrack.tsx @@ -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) => { + 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 ( +
+
+ + Sections +
+ +
+ {Array.from({ length: durationBars }, (_, i) => ( +
+ ))} + + {sections.map((section) => ( + + ))} + + {showPicker && ( +
+ setShowPicker(false)} + /> +
+ )} +
+
+ ); +} diff --git a/components/timeline/TimeDisplay.tsx b/components/timeline/TimeDisplay.tsx new file mode 100644 index 0000000..49bf666 --- /dev/null +++ b/components/timeline/TimeDisplay.tsx @@ -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 ( +
+
+ Bar + {bar + 1} + | + Beat + {beat + 1} +
+
|
+
+ {currentTime} / {totalTime} +
+
+ ); +} diff --git a/components/timeline/Timeline.tsx b/components/timeline/Timeline.tsx new file mode 100644 index 0000000..621964a --- /dev/null +++ b/components/timeline/Timeline.tsx @@ -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(null); + const [selectedSectionId, setSelectedSectionId] = useState(null); + const [selectedDrumKeyframeId, setSelectedDrumKeyframeId] = useState(null); + const [selectedChordKeyframeId, setSelectedChordKeyframeId] = useState(null); + const [expandedLayers, setExpandedLayers] = useState>({ + drums: false, + chords: false, + ambient: false, + }); + + const toggleLayerExpand = (layer: LayerName) => { + setExpandedLayers((prev) => ({ ...prev, [layer]: !prev[layer] })); + }; + + const handleRulerClick = useCallback( + (e: React.MouseEvent) => { + 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 ( +
+
+ + +
+ +
+
+
+ +
+ +
+ onLoopRegionChange(r.start, r.end, r.enabled)} + /> +
+ + + + + label="Drums" + icon={} + 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 }) => ( + onDrumKeyframeUpdate(keyframe.id, idx)} + onRandom={() => onDrumKeyframeUpdate(keyframe.id, Math.floor(Math.random() * 5))} + onClose={onClose} + /> + )} + /> + +
+ toggleLayerExpand('drums')} + size="sm" + className="h-4 px-1 text-[9px]" + > + {expandedLayers.drums ? 'Hide' : 'Auto'} + +
+ + {expandedLayers.drums && ( + } + 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)} + /> + )} + + + label="Chords" + icon={} + 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 }) => ( + onChordKeyframeUpdate(keyframe.id, idx)} + onRandom={() => onChordKeyframeUpdate(keyframe.id, Math.floor(Math.random() * 5))} + onClose={onClose} + /> + )} + /> + +
+ toggleLayerExpand('chords')} + size="sm" + className="h-4 px-1 text-[9px]" + > + {expandedLayers.chords ? 'Hide' : 'Auto'} + +
+ + {expandedLayers.chords && ( + } + 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)} + /> + )} + + } + keyframes={state.muteKeyframes.ambient} + durationBars={state.durationBars} + pixelsPerBar={pixelsPerBar} + onToggle={(bar) => onMuteKeyframeToggle('ambient', bar)} + /> + +
+ toggleLayerExpand('ambient')} + size="sm" + className="h-4 px-1 text-[9px]" + > + {expandedLayers.ambient ? 'Hide' : 'Auto'} + +
+ + {expandedLayers.ambient && ( + } + 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)} + /> + )} + + +
+
+
+ ); +} diff --git a/components/timeline/TimelineRuler.tsx b/components/timeline/TimelineRuler.tsx new file mode 100644 index 0000000..fc53425 --- /dev/null +++ b/components/timeline/TimelineRuler.tsx @@ -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 ( +
+ {markers.map(({ bar, isMajor }) => ( +
+
+ {isMajor && ( + + {bar + 1} + + )} +
+ ))} +
+ ); +} diff --git a/components/timeline/index.ts b/components/timeline/index.ts new file mode 100644 index 0000000..e312107 --- /dev/null +++ b/components/timeline/index.ts @@ -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'; diff --git a/hooks/useAudioEngine.ts b/hooks/useAudioEngine.ts index 557f210..ecfb2b8 100644 --- a/hooks/useAudioEngine.ts +++ b/hooks/useAudioEngine.ts @@ -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 = { + drums: 0, + chords: 0, + ambient: 0, +}; + +const defaultSoloed: Record = { + drums: false, + chords: false, + ambient: false, +}; + export function useAudioEngine() { const [state, setState] = useState(defaultState); const [currentStep, setCurrentStep] = useState(0); + const [meterLevels, setMeterLevels] = useState(defaultMeterLevels); + const [pans, setPans] = useState>(defaultPans); + const [soloed, setSoloed] = useState>(defaultSoloed); + const [playheadPosition, setPlayheadPosition] = useState({ bar: 0, beat: 0 }); + const engineRef = useRef(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 => { + 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, }; } diff --git a/hooks/useTimeline.ts b/hooks/useTimeline.ts new file mode 100644 index 0000000..36a6be7 --- /dev/null +++ b/hooks/useTimeline.ts @@ -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(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, + }; +} diff --git a/lib/audio/audioEngine.ts b/lib/audio/audioEngine.ts index 06c5770..c7f03cd 100644 --- a/lib/audio/audioEngine.ts +++ b/lib/audio/audioEngine.ts @@ -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 = { + drums: false, + chords: false, + ambient: false, + }; + + // Pre-solo volume states for restoration + private preSoloMuted: Record | 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 { + 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; diff --git a/lib/audio/timelineScheduler.ts b/lib/audio/timelineScheduler.ts new file mode 100644 index 0000000..d176309 --- /dev/null +++ b/lib/audio/timelineScheduler.ts @@ -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; + automationEvents: Record; +} + +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; + } +} diff --git a/types/audio.ts b/types/audio.ts index 78c1af3..78fc8e3 100644 --- a/types/audio.ts +++ b/types/audio.ts @@ -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; + pans: Record; + muted: Record; + soloed: Record; + 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; + muteKeyframes: Record; + loopRegion: LoopRegion; +} + +export interface PlaybackPosition { + bar: number; + beat: number; + sixteenth: number; + totalSeconds: number; + totalBars: number; +} + +export const SECTION_COLORS: Record = { + 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)', +};