Initial commit
This commit is contained in:
350
public/components/Sequencer.tsx
Normal file
350
public/components/Sequencer.tsx
Normal file
@@ -0,0 +1,350 @@
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user