350 lines
20 KiB
TypeScript
350 lines
20 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, MuteIcon, UnmuteIcon } 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 SequencerButton: React.FC<{ onClick?: () => void; children: React.ReactNode; className?: string; disabled?: boolean }> = ({ onClick, children, className, disabled }) => (
|
|
<button
|
|
onClick={onClick}
|
|
disabled={disabled}
|
|
className={`flex items-center justify-center gap-2 px-4 py-2 text-sm font-semibold text-slate-700 bg-white border border-slate-300 rounded-md shadow-sm hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-all ${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, toggleMute,
|
|
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 handleImportClick = () => {
|
|
fileInputRef.current?.click();
|
|
};
|
|
|
|
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 handleTempoScroll = (e: React.WheelEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
if (e.deltaY < 0) {
|
|
decrementTempo();
|
|
} else {
|
|
incrementTempo();
|
|
}
|
|
};
|
|
|
|
const incrementSteps = () => {
|
|
const newSteps = steps + 4;
|
|
if (newSteps <= MAX_STEPS) {
|
|
handleStepsChange(newSteps);
|
|
}
|
|
};
|
|
|
|
const decrementSteps = () => {
|
|
const newSteps = steps - 4;
|
|
if (newSteps >= MIN_STEPS) {
|
|
handleStepsChange(newSteps);
|
|
}
|
|
};
|
|
|
|
const handleClearConfirm = () => {
|
|
clearPattern();
|
|
setIsClearModalOpen(false);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Modal
|
|
isOpen={isClearModalOpen}
|
|
onClose={() => setIsClearModalOpen(false)}
|
|
onConfirm={handleClearConfirm}
|
|
title="Clear Session"
|
|
>
|
|
<p>Are you sure you want to clear the session? This will erase all current progress.</p>
|
|
</Modal>
|
|
<div className={`bg-white p-4 sm:p-6 rounded-xl shadow-lg border border-slate-200 ${isDragging || isBassDragging ? 'select-none' : ''}`}>
|
|
<div className="flex flex-wrap items-center justify-between gap-4 mb-6">
|
|
<div className="flex items-center gap-2">
|
|
{!isPlaying ? (
|
|
<SequencerButton onClick={startPlayback} title="Play">
|
|
<PlayIcon className="w-5 h-5" /> <span className="hidden lg:inline">Play</span>
|
|
</SequencerButton>
|
|
) : (
|
|
<SequencerButton onClick={stopPlayback} title="Stop">
|
|
<StopIcon className="w-5 h-5" /> <span className="hidden lg:inline">Stop</span>
|
|
</SequencerButton>
|
|
)}
|
|
<SequencerButton onClick={() => setIsClearModalOpen(true)} title="Clear">
|
|
<ClearIcon className="w-5 h-5" /> <span className="hidden lg:inline">Clear</span>
|
|
</SequencerButton>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-x-6 gap-y-2 flex-wrap justify-center">
|
|
<div className="flex items-center gap-3">
|
|
<label htmlFor="tempo-display" className="text-sm font-medium text-slate-600">Tempo</label>
|
|
<div
|
|
className="flex items-center border border-slate-300 rounded-md shadow-sm"
|
|
onWheel={handleTempoScroll}
|
|
title="Scroll to adjust tempo"
|
|
>
|
|
<button
|
|
onClick={decrementTempo}
|
|
disabled={tempo <= MIN_TEMPO}
|
|
className="p-2 text-slate-700 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed rounded-l-md focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-1"
|
|
aria-label="Decrease tempo"
|
|
>
|
|
<MinusIcon className="w-5 h-5" />
|
|
</button>
|
|
<span
|
|
id="tempo-display"
|
|
className="px-3 py-2 text-sm font-mono text-slate-700 w-24 text-center border-l border-r border-slate-300 bg-white"
|
|
>
|
|
{tempo} BPM
|
|
</span>
|
|
<button
|
|
onClick={incrementTempo}
|
|
disabled={tempo >= MAX_TEMPO}
|
|
className="p-2 text-slate-700 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed rounded-r-md focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-1"
|
|
aria-label="Increase tempo"
|
|
>
|
|
<PlusIcon className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<label htmlFor="steps-display" className="text-sm font-medium text-slate-600">Steps</label>
|
|
<div className="flex items-center border border-slate-300 rounded-md shadow-sm">
|
|
<button
|
|
onClick={decrementSteps}
|
|
disabled={steps <= MIN_STEPS}
|
|
className="p-2 text-slate-700 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed rounded-l-md focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-1"
|
|
aria-label="Decrease steps by one tact (4 steps)"
|
|
>
|
|
<MinusIcon className="w-5 h-5" />
|
|
</button>
|
|
<span
|
|
id="steps-display"
|
|
className="px-3 py-2 text-sm font-mono text-slate-700 w-16 text-center border-l border-r border-slate-300 bg-white"
|
|
>
|
|
{steps}
|
|
</span>
|
|
<button
|
|
onClick={incrementSteps}
|
|
disabled={steps >= MAX_STEPS}
|
|
className="p-2 text-slate-700 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed rounded-r-md focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-1"
|
|
aria-label="Increase steps by one tact (4 steps)"
|
|
>
|
|
<PlusIcon className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<SequencerButton onClick={handleImportClick} title="Import">
|
|
<UploadIcon className="w-5 h-5" /> <span className="hidden lg:inline">Import</span>
|
|
</SequencerButton>
|
|
<input type="file" ref={fileInputRef} onChange={importBeat} accept=".json" className="hidden" />
|
|
<SequencerButton onClick={exportBeat} title="Export">
|
|
<DownloadIcon className="w-5 h-5" /> <span className="hidden lg:inline">Export</span>
|
|
</SequencerButton>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-start gap-4">
|
|
<div className="flex flex-col items-center gap-2 pt-10">
|
|
<input
|
|
id="drum-volume"
|
|
type="range" min="0" max="1.5" step="0.01" value={drumVolume}
|
|
onChange={e => handleDrumVolumeChange(Number(e.target.value))}
|
|
className="w-4 h-48 appearance-none cursor-pointer bg-slate-200 rounded-lg [writing-mode:vertical-lr] [direction:rtl] accent-orange-500"
|
|
aria-orientation="vertical"
|
|
/>
|
|
<label htmlFor="drum-volume" className="text-xs font-medium text-slate-500 tracking-wider">DRUMS</label>
|
|
</div>
|
|
<div className="flex-1 overflow-x-auto pb-2">
|
|
<div className="grid gap-1" style={{ gridTemplateColumns: `100px repeat(${steps}, 1fr)` }}>
|
|
{/* Header */}
|
|
<div className="sticky left-0 bg-white z-10"></div>
|
|
{Array.from({ length: steps }, (_, i) => (
|
|
<div key={`header-${i}`} className="text-center text-xs text-slate-400">
|
|
{(i % 4 === 0) ? (i/4 + 1) : ''}
|
|
</div>
|
|
))}
|
|
{/* Grid Rows */}
|
|
{INSTRUMENTS.map((instrument, instIndex) => (
|
|
<div className="contents group" key={instrument.name}>
|
|
<div className={`sticky left-0 bg-white flex items-center justify-end pr-2 py-1 transition-opacity z-10 ${mutes[instIndex] ? 'opacity-60' : ''}`}>
|
|
<button
|
|
onClick={() => toggleMute(instIndex)}
|
|
className="mr-1 p-1 rounded-full text-slate-400 hover:bg-slate-200 hover:text-slate-600 focus:ring-2 focus:ring-orange-500 transition-opacity"
|
|
aria-label={mutes[instIndex] ? `Unmute ${instrument.name}` : `Mute ${instrument.name}`}
|
|
>
|
|
{mutes[instIndex] ? <MuteIcon className="w-4 h-4" /> : <UnmuteIcon className="w-4 h-4 opacity-0 group-hover:opacity-100 focus:opacity-100" />}
|
|
</button>
|
|
<span className="text-xs sm:text-sm font-bold text-slate-600 tracking-wider text-right">{instrument.name}</span>
|
|
</div>
|
|
{grid[instIndex]?.map((isActive, stepIndex) => {
|
|
const isCurrent = currentStep === stepIndex;
|
|
const isFourth = stepIndex % 4 === 0;
|
|
return (
|
|
<div
|
|
key={`${instrument.name}-${stepIndex}`}
|
|
className={`w-full aspect-square rounded-md cursor-pointer transition-all duration-100 border ${isFourth ? 'border-l-slate-300' : 'border-l-transparent'} min-w-0
|
|
${isActive ? 'bg-orange-500 border-orange-600' : 'bg-slate-200 hover:bg-slate-300 border-slate-300'}
|
|
${isCurrent ? 'ring-2 ring-blue-500 ring-offset-1' : ''}
|
|
${mutes[instIndex] ? 'opacity-60' : ''}`}
|
|
onMouseDown={() => handleCellMouseDown(instIndex, stepIndex)}
|
|
onMouseEnter={() => handleCellMouseEnter(instIndex, stepIndex)}
|
|
onDragStart={(e) => e.preventDefault()}
|
|
/>
|
|
)
|
|
})}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/* Bass Sequencer */}
|
|
<div className="mt-8 pt-6 border-t border-slate-200 flex items-start gap-4">
|
|
<div className="flex flex-col items-center gap-2 pt-10">
|
|
<input
|
|
id="bass-volume"
|
|
type="range" min="0" max="1" step="0.01" value={bassVolume}
|
|
onChange={e => handleBassVolumeChange(Number(e.target.value))}
|
|
className="w-4 h-48 appearance-none cursor-pointer bg-slate-200 rounded-lg [writing-mode:vertical-lr] [direction:rtl] accent-purple-500"
|
|
aria-orientation="vertical"
|
|
/>
|
|
<label htmlFor="bass-volume" className="text-xs font-medium text-slate-500 tracking-wider">BASS</label>
|
|
</div>
|
|
<div className="flex-1 overflow-x-auto pb-2">
|
|
<div className="grid gap-px" style={{ gridTemplateColumns: `100px repeat(${steps}, 1fr)` }}>
|
|
{/* Header */}
|
|
<div className="sticky left-0 bg-white z-10"></div>
|
|
{Array.from({ length: steps }, (_, i) => (
|
|
<div key={`bass-header-${i}`} className="text-center text-xs text-slate-400 h-4 flex items-end justify-center">
|
|
{(i % 4 === 0) ? <span className="mb-1">{(i/4 + 1)}</span> : ''}
|
|
</div>
|
|
))}
|
|
{/* Bass Grid Rows (reversed to have low notes at the bottom) */}
|
|
{[...BASS_NOTES].reverse().map((note) => (
|
|
<div className="contents" key={note.name}>
|
|
<div className={`sticky left-0 bg-white flex items-center justify-end pr-2 py-0 z-10 ${note.isSharp ? 'bg-slate-100' : 'bg-white'}`}>
|
|
<span className={`text-xs font-mono tracking-wider text-right ${note.isSharp ? 'text-slate-500' : 'text-slate-700 font-bold'}`}>{note.name}</span>
|
|
</div>
|
|
{Array.from({ length: steps }).map((_, stepIndex) => {
|
|
const isSelected = bassLine[stepIndex]?.includes(note.name);
|
|
const isCurrent = currentStep === stepIndex;
|
|
return (
|
|
<div
|
|
key={`${note.name}-${stepIndex}`}
|
|
className={`w-full h-5 cursor-pointer transition-colors duration-100 relative
|
|
${isSelected ? 'bg-purple-500' : note.isSharp ? 'bg-slate-200 hover:bg-slate-300' : 'bg-slate-100 hover:bg-slate-300'}
|
|
`}
|
|
onMouseDown={() => handleBassCellMouseDown(note.name, stepIndex)}
|
|
onMouseEnter={() => handleBassCellMouseEnter(note.name, stepIndex)}
|
|
onDragStart={(e) => e.preventDefault()}
|
|
>
|
|
{isCurrent && <div className="absolute inset-0 bg-blue-500/30"></div>}
|
|
{stepIndex > 0 && stepIndex % 4 === 0 && <div className="absolute inset-y-0 left-0 w-px bg-slate-300"></div>}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}; |