308 lines
17 KiB
TypeScript
308 lines
17 KiB
TypeScript
import React, { useRef, useState, useEffect } from 'react';
|
|
import { INSTRUMENTS, BASS_NOTES, MIN_TEMPO, MAX_TEMPO, MIN_STEPS, MAX_STEPS } from '../constants';
|
|
import { PlayIcon, StopIcon, ClearIcon, UploadIcon, DownloadIcon, PlusIcon, MinusIcon } from './icons';
|
|
import { Grid, BassLineGrid } from '../types';
|
|
import { Modal } from './Modal';
|
|
|
|
interface SequencerProps {
|
|
steps: number;
|
|
grid: Grid;
|
|
bassLine: BassLineGrid;
|
|
isPlaying: boolean;
|
|
tempo: number;
|
|
currentStep: number | null;
|
|
mutes: boolean[];
|
|
drumVolume: number;
|
|
bassVolume: number;
|
|
setStep: (instrumentIndex: number, stepIndex: number, isActive: boolean) => void;
|
|
setBassNote: (stepIndex: number, note: string) => void;
|
|
clearPattern: () => void;
|
|
setTempo: React.Dispatch<React.SetStateAction<number>>;
|
|
handleStepsChange: (newSteps: number) => void;
|
|
startPlayback: () => Promise<void>;
|
|
stopPlayback: () => void;
|
|
exportBeat: () => void;
|
|
importBeat: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
|
toggleMute: (instrumentIndex: number) => void;
|
|
handleDrumVolumeChange: (newVolume: number) => void;
|
|
handleBassVolumeChange: (newVolume: number) => void;
|
|
triggerSound: (instrumentName: string) => void;
|
|
triggerBassNote: (note: string) => void;
|
|
}
|
|
|
|
const ControlButton: React.FC<{ onClick?: () => void; children: React.ReactNode; className?: string; disabled?: boolean; title?: string }> = ({ onClick, children, className, disabled, title }) => (
|
|
<button
|
|
onClick={onClick}
|
|
disabled={disabled}
|
|
title={title}
|
|
className={`flex items-center justify-center gap-2 px-3 py-1.5 text-[11px] font-black uppercase tracking-widest text-slate-600 bg-white border border-slate-200 rounded shadow-sm hover:bg-slate-50 hover:border-slate-300 transition-all active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed ${className}`}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
|
|
const IconButton: React.FC<{ onClick?: () => void; children: React.ReactNode; className?: string; disabled?: boolean }> = ({ onClick, children, className, disabled }) => (
|
|
<button
|
|
onClick={onClick}
|
|
disabled={disabled}
|
|
className={`p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded transition-colors disabled:opacity-30 ${className}`}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
|
|
export const Sequencer: React.FC<SequencerProps> = ({
|
|
steps, grid, isPlaying, tempo, currentStep, mutes, bassLine,
|
|
drumVolume, bassVolume,
|
|
clearPattern, setTempo, handleStepsChange, setStep, setBassNote,
|
|
startPlayback, stopPlayback, exportBeat, importBeat,
|
|
handleDrumVolumeChange, handleBassVolumeChange, triggerSound, triggerBassNote
|
|
}) => {
|
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const [dragActivationState, setDragActivationState] = useState(false);
|
|
const [dragStartRow, setDragStartRow] = useState<number | null>(null);
|
|
|
|
const [isBassDragging, setIsBassDragging] = useState(false);
|
|
const [bassDragActivationState, setBassDragActivationState] = useState(false);
|
|
const [isClearModalOpen, setIsClearModalOpen] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const handleMouseUp = () => {
|
|
setIsDragging(false);
|
|
setDragStartRow(null);
|
|
setIsBassDragging(false);
|
|
};
|
|
window.addEventListener('mouseup', handleMouseUp);
|
|
return () => window.removeEventListener('mouseup', handleMouseUp);
|
|
}, []);
|
|
|
|
const handleCellMouseDown = (instIndex: number, stepIndex: number) => {
|
|
setIsDragging(true);
|
|
setDragStartRow(instIndex);
|
|
const newState = !grid[instIndex]?.[stepIndex];
|
|
setDragActivationState(newState);
|
|
setStep(instIndex, stepIndex, newState);
|
|
if (newState && !isPlaying) {
|
|
triggerSound(INSTRUMENTS[instIndex].name);
|
|
}
|
|
};
|
|
|
|
const handleCellMouseEnter = (instIndex: number, stepIndex: number) => {
|
|
if (isDragging && instIndex === dragStartRow) {
|
|
setStep(instIndex, stepIndex, dragActivationState);
|
|
}
|
|
};
|
|
|
|
const handleBassCellMouseDown = (noteName: string, stepIndex: number) => {
|
|
const isCurrentlyActive = bassLine[stepIndex]?.includes(noteName);
|
|
const newState = !isCurrentlyActive;
|
|
setBassNote(stepIndex, noteName);
|
|
setIsBassDragging(true);
|
|
setBassDragActivationState(newState);
|
|
if (newState && !isPlaying) {
|
|
triggerBassNote(noteName);
|
|
}
|
|
};
|
|
|
|
const handleBassCellMouseEnter = (noteName: string, stepIndex: number) => {
|
|
if (isBassDragging) {
|
|
const isCurrentlyActive = bassLine[stepIndex]?.includes(noteName);
|
|
if (bassDragActivationState !== isCurrentlyActive) {
|
|
setBassNote(stepIndex, noteName);
|
|
}
|
|
}
|
|
};
|
|
|
|
const incrementTempo = () => setTempo(t => Math.min(MAX_TEMPO, t + 1));
|
|
const decrementTempo = () => setTempo(t => Math.max(MIN_TEMPO, t - 1));
|
|
|
|
const incrementSteps = () => {
|
|
const newSteps = steps + 4;
|
|
if (newSteps <= MAX_STEPS) handleStepsChange(newSteps);
|
|
};
|
|
|
|
const decrementSteps = () => {
|
|
const newSteps = steps - 4;
|
|
if (newSteps >= MIN_STEPS) handleStepsChange(newSteps);
|
|
};
|
|
|
|
return (
|
|
<div className={`p-8 bg-white ${isDragging || isBassDragging ? 'select-none' : ''}`}>
|
|
<Modal
|
|
isOpen={isClearModalOpen}
|
|
onClose={() => setIsClearModalOpen(false)}
|
|
onConfirm={() => { clearPattern(); setIsClearModalOpen(false); }}
|
|
title="Clear Session"
|
|
>
|
|
<p className="text-sm text-slate-500">Are you sure you want to clear the session? This will erase all current progress.</p>
|
|
</Modal>
|
|
|
|
{/* Toolbar */}
|
|
<div className="flex items-center justify-between mb-10 pb-6 border-b border-slate-50">
|
|
<div className="flex items-center gap-3">
|
|
<ControlButton onClick={isPlaying ? stopPlayback : startPlayback} className="min-w-[80px]">
|
|
{isPlaying ? <><StopIcon className="w-3.5 h-3.5" /> Stop</> : <><PlayIcon className="w-3.5 h-3.5" /> Play</>}
|
|
</ControlButton>
|
|
<ControlButton onClick={() => setIsClearModalOpen(true)}>
|
|
<ClearIcon className="w-3.5 h-3.5" /> Clear
|
|
</ControlButton>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-8">
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Tempo</span>
|
|
<div className="flex items-center bg-slate-50 rounded-lg p-0.5 border border-slate-100">
|
|
<IconButton onClick={decrementTempo} disabled={tempo <= MIN_TEMPO}><MinusIcon className="w-3.5 h-3.5" /></IconButton>
|
|
<span className="px-3 text-xs font-black text-slate-700 min-w-[70px] text-center">{tempo} BPM</span>
|
|
<IconButton onClick={incrementTempo} disabled={tempo >= MAX_TEMPO}><PlusIcon className="w-3.5 h-3.5" /></IconButton>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Steps</span>
|
|
<div className="flex items-center bg-slate-50 rounded-lg p-0.5 border border-slate-100">
|
|
<IconButton onClick={decrementSteps} disabled={steps <= MIN_STEPS}><MinusIcon className="w-3.5 h-3.5" /></IconButton>
|
|
<span className="px-3 text-xs font-black text-slate-700 min-w-[40px] text-center">{steps}</span>
|
|
<IconButton onClick={incrementSteps} disabled={steps >= MAX_STEPS}><PlusIcon className="w-3.5 h-3.5" /></IconButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<ControlButton onClick={() => fileInputRef.current?.click()}>
|
|
<UploadIcon className="w-3.5 h-3.5" /> Import
|
|
</ControlButton>
|
|
<input type="file" ref={fileInputRef} onChange={importBeat} accept=".json" className="hidden" />
|
|
<ControlButton onClick={exportBeat}>
|
|
<DownloadIcon className="w-3.5 h-3.5" /> Export
|
|
</ControlButton>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Drum Machine Section */}
|
|
<div className="flex gap-10 mb-16">
|
|
<div className="flex flex-col items-center gap-3 shrink-0">
|
|
<div className="relative h-48 w-6 bg-slate-100 rounded-full border border-slate-200 p-1 flex items-end overflow-hidden">
|
|
<div
|
|
className="bg-orange-500 rounded-full w-full transition-all duration-300 shadow-lg shadow-orange-500/30"
|
|
style={{ height: `${(drumVolume / 1.5) * 100}%` }}
|
|
></div>
|
|
<input
|
|
type="range" min="0" max="1.5" step="0.01" value={drumVolume}
|
|
onChange={e => handleDrumVolumeChange(Number(e.target.value))}
|
|
className="absolute inset-0 opacity-0 cursor-pointer [writing-mode:vertical-lr] [direction:rtl]"
|
|
/>
|
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none">
|
|
<div className="w-2.5 h-2.5 bg-white border-2 border-orange-500 rounded-full shadow-md transition-all duration-300" style={{ transform: `translateY(${50 - (drumVolume / 1.5) * 100}%)` }}></div>
|
|
</div>
|
|
</div>
|
|
<span className="text-[9px] font-black text-slate-400 uppercase tracking-[0.2em] [writing-mode:vertical-lr] rotate-180">Drums</span>
|
|
</div>
|
|
|
|
<div className="flex-1">
|
|
<div className="grid gap-x-1.5 gap-y-2" style={{ gridTemplateColumns: `80px repeat(${steps}, 1fr)` }}>
|
|
{/* Header helper */}
|
|
<div className="h-6"></div>
|
|
{Array.from({ length: steps }, (_, i) => (
|
|
<div key={`d-head-${i}`} className="text-[9px] font-bold text-slate-300 text-center flex items-center justify-center">
|
|
{i % 4 === 0 ? (i / 4 + 1) : ''}
|
|
</div>
|
|
))}
|
|
|
|
{INSTRUMENTS.map((instrument, instIndex) => (
|
|
<React.Fragment key={instrument.name}>
|
|
<div className="flex items-center justify-end pr-4 h-9">
|
|
<span className={`text-[11px] font-bold uppercase tracking-widest ${mutes[instIndex] ? 'text-slate-300 line-through' : 'text-slate-500'}`}>
|
|
{instrument.name}
|
|
</span>
|
|
</div>
|
|
{Array.from({ length: steps }).map((_, stepIndex) => {
|
|
const isActive = grid[instIndex]?.[stepIndex];
|
|
const isCurrent = currentStep === stepIndex;
|
|
const isFourth = stepIndex % 4 === 0;
|
|
return (
|
|
<div
|
|
key={`${instrument.name}-${stepIndex}`}
|
|
onMouseDown={() => handleCellMouseDown(instIndex, stepIndex)}
|
|
onMouseEnter={() => handleCellMouseEnter(instIndex, stepIndex)}
|
|
className={`h-9 rounded-sm cursor-pointer transition-all duration-75 relative group overflow-hidden
|
|
${isActive ? 'bg-orange-500 shadow-md shadow-orange-200' : 'bg-slate-100 hover:bg-slate-200'}
|
|
${isCurrent ? 'ring-2 ring-blue-400 ring-offset-1 z-10' : ''}
|
|
${isFourth && stepIndex !== 0 ? 'border-l-2 border-slate-200/50' : ''}
|
|
`}
|
|
>
|
|
{isCurrent && <div className="absolute inset-0 bg-white/20 animate-pulse"></div>}
|
|
</div>
|
|
)
|
|
})}
|
|
</React.Fragment>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bass Section */}
|
|
<div className="flex gap-10">
|
|
<div className="flex flex-col items-center gap-3 shrink-0">
|
|
<div className="relative h-[400px] w-6 bg-slate-100 rounded-full border border-slate-200 p-1 flex items-end overflow-hidden">
|
|
<div
|
|
className="bg-purple-500 rounded-full w-full transition-all duration-300 shadow-lg shadow-purple-500/30"
|
|
style={{ height: `${(bassVolume / 1) * 100}%` }}
|
|
></div>
|
|
<input
|
|
type="range" min="0" max="1" step="0.01" value={bassVolume}
|
|
onChange={e => handleBassVolumeChange(Number(e.target.value))}
|
|
className="absolute inset-0 opacity-0 cursor-pointer [writing-mode:vertical-lr] [direction:rtl]"
|
|
/>
|
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none">
|
|
<div className="w-2.5 h-2.5 bg-white border-2 border-purple-500 rounded-full shadow-md transition-all duration-300" style={{ transform: `translateY(${50 - (bassVolume / 1) * 100}%)` }}></div>
|
|
</div>
|
|
</div>
|
|
<span className="text-[9px] font-black text-slate-400 uppercase tracking-[0.2em] [writing-mode:vertical-lr] rotate-180">Bass</span>
|
|
</div>
|
|
|
|
<div className="flex-1">
|
|
<div className="grid gap-px" style={{ gridTemplateColumns: `80px repeat(${steps}, 1fr)` }}>
|
|
{/* Header helper */}
|
|
<div className="h-6"></div>
|
|
{Array.from({ length: steps }, (_, i) => (
|
|
<div key={`b-head-${i}`} className="text-[9px] font-bold text-slate-300 text-center flex items-center justify-center">
|
|
{i % 4 === 0 ? (i / 4 + 1) : ''}
|
|
</div>
|
|
))}
|
|
|
|
{[...BASS_NOTES].reverse().map((note) => (
|
|
<React.Fragment key={note.name}>
|
|
<div className={`flex items-center justify-end pr-4 h-5 border-b border-slate-50 ${note.isSharp ? 'bg-slate-50/30' : ''}`}>
|
|
<span className={`text-[10px] font-mono ${note.isSharp ? 'text-slate-300 pt-0.5' : 'text-slate-500 font-bold'}`}>
|
|
{note.name}
|
|
</span>
|
|
</div>
|
|
{Array.from({ length: steps }).map((_, stepIndex) => {
|
|
const isSelected = bassLine[stepIndex]?.includes(note.name);
|
|
const isCurrent = currentStep === stepIndex;
|
|
const isFourth = stepIndex % 4 === 0;
|
|
return (
|
|
<div
|
|
key={`${note.name}-${stepIndex}`}
|
|
onMouseDown={() => handleBassCellMouseDown(note.name, stepIndex)}
|
|
onMouseEnter={() => handleBassCellMouseEnter(note.name, stepIndex)}
|
|
className={`h-5 border-b border-r border-slate-50 cursor-pointer transition-all duration-75 relative
|
|
${isSelected ? 'bg-purple-500 shadow-inner' : note.isSharp ? 'bg-slate-50/50 hover:bg-slate-100' : 'bg-white hover:bg-slate-50'}
|
|
${isCurrent ? 'z-10 bg-blue-400/20' : ''}
|
|
${isFourth && stepIndex !== 0 ? 'border-l-2 border-slate-100' : ''}
|
|
`}
|
|
>
|
|
{isCurrent && <div className="absolute inset-0 border-x-2 border-blue-400/50 pointer-events-none"></div>}
|
|
</div>
|
|
)
|
|
})}
|
|
</React.Fragment>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}; |