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
198 lines
5.9 KiB
TypeScript
198 lines
5.9 KiB
TypeScript
'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>
|
|
);
|
|
}
|