Total refactoring performed

This commit is contained in:
AG
2025-12-20 18:53:49 +02:00
parent e41bb6b588
commit 5877ca3544
29 changed files with 3888 additions and 344 deletions

View File

@@ -1,24 +0,0 @@
const INITIAL_STEPS = 16;
const INITIAL_TEMPO = 76;
const INSTRUMENTS_LENGTH = 5;
const createEmptyGrid = (steps) => {
return Array.from({ length: INSTRUMENTS_LENGTH }, () => Array(steps).fill(false));
};
const createEmptyBassLine = (steps) => {
return Array.from({ length: steps }, () => []);
}
const defaultState = {
grid: createEmptyGrid(INITIAL_STEPS),
bassLine: createEmptyBassLine(INITIAL_STEPS),
tempo: INITIAL_TEMPO,
steps: INITIAL_STEPS,
mutes: Array(INSTRUMENTS_LENGTH).fill(false),
drumVolume: 1,
bassVolume: 0.4,
isPlaying: false
};
module.exports = { defaultState };

2412
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,26 +2,40 @@
"name": "ag-beats",
"version": "1.0.0",
"description": "A web-based drum machine.",
"main": "server.js",
"type": "commonjs",
"main": "server/index.ts",
"type": "module",
"scripts": {
"start": "node server.js",
"start": "tsx server/index.ts",
"build": "vite build",
"postinstall": "npm run build",
"dev": "node server.dev.js"
"dev": "tsx --watch server/index.ts",
"clean": "rm -rf dist"
},
"dependencies": {
"@tailwindcss/vite": "^4.0.0-beta.8",
"better-sqlite3": "^12.5.0",
"express": "^4.19.2",
"react": "^19.1.0",
"react-dom": "^19.1.0"
"react-dom": "^19.1.0",
"zustand": "^5.0.9"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/express": "^5.0.6",
"@types/node": "^25.0.3",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"@types/uuid": "^10.0.0",
"@types/ws": "^8.18.1",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.23",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"ts-node": "^10.9.2",
"tsx": "^4.21.0",
"typescript": "^5.5.3",
"vite": "^5.3.3",
"ws": "^8.18.0",
"uuid": "^9.0.1"
"uuid": "^9.0.1",
"vite": "^6.0.5",
"ws": "^8.18.0"
}
}

24
server/db.ts Normal file
View File

@@ -0,0 +1,24 @@
import Database from 'better-sqlite3';
const db = new Database('session_state.db');
// Initialize tables
db.exec(`
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
state TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
const getSessionStmt = db.prepare('SELECT state FROM sessions WHERE id = ?');
const upsertSessionStmt = db.prepare('INSERT INTO sessions (id, state) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET state = excluded.state, updated_at = CURRENT_TIMESTAMP');
export function getPersistedSession(id: string) {
const row = getSessionStmt.get(id) as { state: string } | undefined;
return row ? JSON.parse(row.state) : null;
}
export function persistSession(id: string, state: any) {
upsertSessionStmt.run(id, JSON.stringify(state));
}

103
server/index.ts Normal file
View File

@@ -0,0 +1,103 @@
import express from 'express';
import path from 'path';
import http from 'http';
import { WebSocketServer, WebSocket } from 'ws';
import { v4 as uuidv4 } from 'uuid';
import { defaultState } from '../src/defaultState.js';
import { getPersistedSession, persistSession } from './db.js';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const app = express();
const port = process.env.PORT || 3001;
const subfolder = '/ag-beats';
const distPath = path.join(__dirname, '../dist');
const COLORS = ['#F94144', '#F3722C', '#F8961E', '#F9C74F', '#90BE6D', '#43AA8B', '#4D908E', '#577590', '#277DA1'];
const getRandomColor = () => COLORS[Math.floor(Math.random() * COLORS.length)];
app.use(subfolder, express.static(distPath));
app.get(subfolder + '/*', (req, res) => res.sendFile(path.join(distPath, 'index.html')));
app.get('/', (req, res) => res.redirect(subfolder));
const httpServer = http.createServer(app);
const wss = new WebSocketServer({ path: '/ag-beats', server: httpServer });
interface ClientInfo {
ws: WebSocket;
color: string;
}
interface Session {
clients: Map<string, ClientInfo>;
state: any;
}
const sessions = new Map<string, Session>();
wss.on('connection', (ws: WebSocket, req) => {
const url = new URL(req.url || '', `http://${req.headers.host}`);
const sessionId = url.searchParams.get('sessionId');
const clientId = uuidv4();
const clientColor = getRandomColor();
if (!sessionId) return ws.close(1008, 'Session ID required');
if (!sessions.has(sessionId)) {
const persisted = getPersistedSession(sessionId);
const initialState = persisted || JSON.parse(JSON.stringify(defaultState));
sessions.set(sessionId, { clients: new Map(), state: initialState });
}
const session = sessions.get(sessionId)!;
session.clients.set(clientId, { ws, color: clientColor });
console.log(`Client ${clientId} connected to session ${sessionId}`);
ws.send(JSON.stringify({ type: 'welcome', payload: { clientId } }));
const broadcastUserUpdate = () => {
const userList = Array.from(session.clients.entries()).map(([id, { color }]) => ({ id, color }));
const msg = JSON.stringify({ type: 'user-update', payload: { users: userList } });
session.clients.forEach(({ ws: c }) => c.readyState === WebSocket.OPEN && c.send(msg));
};
broadcastUserUpdate();
ws.on('message', (messageBuffer) => {
const message = JSON.parse(messageBuffer.toString());
if (message.type === 'get_state') {
ws.send(JSON.stringify({ type: 'session_state', payload: session.state }));
return;
}
const messageToSend = JSON.stringify({ ...message, senderId: clientId });
if (message.type !== 'cursor-move') {
session.state = { ...session.state, ...message.payload };
persistSession(sessionId, session.state);
}
session.clients.forEach(({ ws: c }) => {
if (c.readyState === WebSocket.OPEN) c.send(messageToSend);
});
});
ws.on('close', () => {
console.log(`Client ${clientId} disconnected from session ${sessionId}`);
session.clients.delete(clientId);
if (session.clients.size === 0) {
// keep session in DB but remove from memory
sessions.delete(sessionId);
} else {
broadcastUserUpdate();
}
});
});
httpServer.listen(port, () => console.log(`AG Beats (TS) started on port ${port}`));

BIN
session_state.db Normal file

Binary file not shown.

104
src/App.tsx Normal file
View File

@@ -0,0 +1,104 @@
import React, { useRef } from 'react';
import { useStore } from './store/useStore';
import { useCursors } from './hooks/useCursors';
import { useWebSocket } from './hooks/useWebSocket';
import { useDrumMachine } from './hooks/useDrumMachine';
import { useSession } from './hooks/useSession';
import { Sequencer } from './components/Sequencer';
import { ShareIcon, CursorIcon } from './components/icons';
const App: React.FC = () => {
const sessionId = useSession();
const mainRef = useRef<HTMLElement>(null);
const { isConnected, isSynchronized, clientId } = useStore();
const { sendMessage } = useWebSocket(sessionId);
// We still need to pass messages to cursors, but maybe cursors should use the store too?
// For now, let's keep it simple and get what we need.
const drumMachine = useDrumMachine(null, sendMessage);
const cursors = useCursors(sendMessage, null, clientId, mainRef);
const [localCopyMessage, setLocalCopyMessage] = React.useState(false);
const handleShareSession = () => {
navigator.clipboard.writeText(window.location.href).then(() => {
setLocalCopyMessage(true);
setTimeout(() => setLocalCopyMessage(false), 3000);
});
};
return (
<div className="min-h-screen flex flex-col items-center justify-center p-4 font-sans bg-slate-50 text-slate-900">
<div className="w-full max-w-6xl mx-auto">
<header className="mb-8 text-center relative">
<div className="absolute top-0 right-0 flex flex-col items-end gap-1">
<div className={`flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider ${isConnected ? 'text-green-500' : 'text-slate-400'}`}>
<span className={`w-1.5 h-1.5 rounded-full ${isConnected ? 'bg-green-500 animate-pulse' : 'bg-slate-300'}`}></span>
{isConnected ? 'Connected' : 'Disconnected'}
</div>
<div className="relative mt-1">
<button
onClick={handleShareSession}
className="flex items-center gap-2 px-3 py-1.5 text-xs font-bold 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"
>
<ShareIcon className="w-3.5 h-3.5" />
Share Session
</button>
{localCopyMessage && (
<div className="absolute top-full right-0 mt-2 py-1 px-3 text-[10px] font-bold text-white bg-slate-900 rounded shadow-lg z-50 whitespace-nowrap animate-in fade-in slide-in-from-top-1">
Link copied!
</div>
)}
</div>
</div>
<h1 className="text-3xl font-black text-slate-800 tracking-tightest uppercase mb-1">AG Beats</h1>
<p className="text-slate-400 text-sm font-medium">Craft your beats and bass lines with this interactive step sequencer.</p>
</header>
<main ref={mainRef} className="relative bg-white rounded-2xl shadow-xl shadow-slate-200/50 border border-slate-100 overflow-hidden">
{/* Cursors Overlay */}
<div className="pointer-events-none absolute inset-0 z-50 overflow-hidden">
{Object.values(cursors).map((cursor: any) => (
<div
key={cursor.id}
className="absolute transition-all duration-200 ease-linear shadow-xl"
style={{
left: `${cursor.x}px`,
top: `${cursor.y}px`,
}}
>
<CursorIcon className="w-5 h-5 drop-shadow-sm" style={{ color: cursor.color }} />
<span className="absolute left-4 top-4 px-1.5 py-0.5 bg-slate-900/80 backdrop-blur-sm text-white text-[9px] font-bold rounded-sm uppercase tracking-tighter">
{cursor.id.split('-')[0]}
</span>
</div>
))}
</div>
{isSynchronized ? (
<Sequencer {...drumMachine} />
) : (
<div className="flex flex-col items-center justify-center h-[600px] bg-slate-50/50">
<div className="relative w-12 h-12">
<div className="absolute inset-0 border-4 border-slate-200 rounded-full"></div>
<div className="absolute inset-0 border-4 border-orange-500 rounded-full border-t-transparent animate-spin"></div>
</div>
<p className="text-slate-400 font-bold text-xs uppercase tracking-widest mt-6 animate-pulse">Synchronizing Session...</p>
</div>
)}
</main>
<footer className="text-center mt-8 py-4">
<p className="text-[10px] font-bold text-slate-300 uppercase tracking-widest leading-loose">
Built with React, TypeScript, and Tailwind CSS. <br />
Powered by the Web Audio API and WebSockets.
</p>
</footer>
</div>
</div>
);
};
export default App;

261
src/audio/Engine.ts Normal file
View File

@@ -0,0 +1,261 @@
import { INSTRUMENTS, NOTE_FREQ_MAP } from '../constants';
export class AudioEngine {
private audioContext: AudioContext | null = null;
private drumMasterGain: GainNode | null = null;
private bassMasterGain: GainNode | null = null;
private audioBuffers: Map<string, AudioBuffer> = new Map();
private activeBassOscillators: Map<string, { osc: OscillatorNode; gain: GainNode }> = new Map();
constructor() { }
async init() {
if (!this.audioContext) {
this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
this.drumMasterGain = this.audioContext.createGain();
this.drumMasterGain.connect(this.audioContext.destination);
this.bassMasterGain = this.audioContext.createGain();
this.bassMasterGain.connect(this.audioContext.destination);
await this.loadSamples();
}
if (this.audioContext.state === 'suspended') {
await this.audioContext.resume();
}
}
private async loadSamples() {
if (!this.audioContext) return;
for (const instrument of INSTRUMENTS) {
if (!instrument.sampleUrl) continue;
try {
const response = await fetch(instrument.sampleUrl);
const arrayBuffer = await response.arrayBuffer();
const decodedData = await this.audioContext.decodeAudioData(arrayBuffer);
this.audioBuffers.set(instrument.name, decodedData);
} catch (error) {
console.error(`Error loading sample for ${instrument.name}:`, error);
}
}
}
setDrumVolume(volume: number) {
if (this.drumMasterGain && this.audioContext) {
this.drumMasterGain.gain.setTargetAtTime(volume, this.audioContext.currentTime, 0.01);
}
}
setBassVolume(volume: number) {
if (this.bassMasterGain && this.audioContext) {
this.bassMasterGain.gain.setTargetAtTime(volume, this.audioContext.currentTime, 0.01);
}
}
playKick(time: number) {
if (!this.audioContext || !this.drumMasterGain) return;
const ctx = this.audioContext;
const dest = this.drumMasterGain;
const osc = ctx.createOscillator();
const subOsc = ctx.createOscillator();
const gain = ctx.createGain();
const subGain = ctx.createGain();
const shaper = ctx.createWaveShaper();
shaper.curve = new Float32Array(65536).map((_, i) => {
const x = (i / 32768) - 1;
return Math.tanh(3 * x);
});
shaper.oversample = '4x';
osc.connect(gain);
subOsc.connect(subGain);
gain.connect(shaper);
subGain.connect(shaper);
shaper.connect(dest);
const duration = 0.6;
osc.frequency.setValueAtTime(180, time);
osc.frequency.exponentialRampToValueAtTime(30, time + 0.1);
gain.gain.setValueAtTime(1.8, time);
gain.gain.setTargetAtTime(0, time, 0.1);
subOsc.frequency.setValueAtTime(50, time);
subGain.gain.setValueAtTime(0.35, time);
subGain.gain.exponentialRampToValueAtTime(0.001, time + 0.2);
osc.start(time);
subOsc.start(time);
osc.stop(time + duration);
subOsc.stop(time + duration);
}
playSnare(time: number) {
if (!this.audioContext || !this.drumMasterGain) return;
const ctx = this.audioContext;
const dest = this.drumMasterGain;
const noiseGain = ctx.createGain();
const noiseFilter = ctx.createBiquadFilter();
const osc = ctx.createOscillator();
const oscGain = ctx.createGain();
const noiseBuffer = ctx.createBuffer(1, ctx.sampleRate * 0.5, ctx.sampleRate);
const output = noiseBuffer.getChannelData(0);
for (let i = 0; i < output.length; i++) output[i] = Math.random() * 2 - 1;
const noiseSource = ctx.createBufferSource();
noiseSource.buffer = noiseBuffer;
noiseFilter.type = 'highpass';
noiseFilter.frequency.value = 1000;
noiseSource.connect(noiseFilter);
noiseFilter.connect(noiseGain);
noiseGain.connect(dest);
osc.type = 'triangle';
osc.frequency.setValueAtTime(200, time);
osc.connect(oscGain);
oscGain.connect(dest);
const duration = 0.2;
noiseGain.gain.setValueAtTime(1, time);
noiseGain.gain.exponentialRampToValueAtTime(0.01, time + duration);
oscGain.gain.setValueAtTime(0.7, time);
oscGain.gain.exponentialRampToValueAtTime(0.01, time + duration / 2);
noiseSource.start(time);
osc.start(time);
noiseSource.stop(time + duration);
osc.stop(time + duration);
}
private createHiHatSound(time: number, duration: number) {
if (!this.audioContext || !this.drumMasterGain) return;
const ctx = this.audioContext;
const dest = this.drumMasterGain;
const fundamental = 40;
const ratios = [2, 3, 4.16, 5.43, 6.79, 8.21];
const gain = ctx.createGain();
const bandpass = ctx.createBiquadFilter();
const highpass = ctx.createBiquadFilter();
bandpass.type = 'bandpass';
bandpass.frequency.value = 10000;
bandpass.Q.value = 0.5;
highpass.type = 'highpass';
highpass.frequency.value = 7000;
gain.connect(bandpass);
bandpass.connect(highpass);
highpass.connect(dest);
ratios.forEach(ratio => {
const osc = ctx.createOscillator();
osc.type = 'square';
osc.frequency.value = (fundamental * ratio) + (Math.random() * fundamental * 0.1);
osc.connect(gain);
osc.start(time);
osc.stop(time + duration);
});
gain.gain.setValueAtTime(0.00001, time);
gain.gain.exponentialRampToValueAtTime(0.4, time + 0.02);
gain.gain.exponentialRampToValueAtTime(0.00001, time + duration);
}
playHiHat(time: number) { this.createHiHatSound(time, 0.08); }
playOpenHat(time: number) { this.createHiHatSound(time, 0.8); }
playRide(time: number) {
if (!this.audioContext || !this.drumMasterGain) return;
const ctx = this.audioContext;
const dest = this.drumMasterGain;
const masterGain = ctx.createGain();
const highpass = ctx.createBiquadFilter();
highpass.type = 'highpass';
highpass.frequency.setValueAtTime(800, time);
highpass.Q.value = 0.8;
masterGain.connect(highpass);
highpass.connect(dest);
const tickOsc = ctx.createOscillator();
const tickGain = ctx.createGain();
tickOsc.type = 'square';
tickOsc.frequency.setValueAtTime(1200, time);
tickGain.gain.setValueAtTime(0.5, time);
tickGain.gain.exponentialRampToValueAtTime(0.0001, time + 0.02);
tickOsc.connect(tickGain);
tickGain.connect(masterGain);
const fundamental = 120;
const ratios = [1.00, 1.41, 2.23, 2.77, 3.14, 4.01];
ratios.forEach(ratio => {
const osc = ctx.createOscillator();
osc.type = 'square';
osc.frequency.value = fundamental * ratio + (Math.random() - 0.5) * 5;
osc.connect(masterGain);
osc.start(time);
osc.stop(time + 1.2);
});
masterGain.gain.setValueAtTime(0.0001, time);
masterGain.gain.exponentialRampToValueAtTime(0.6, time + 0.005);
masterGain.gain.exponentialRampToValueAtTime(0.2, time + 0.1);
masterGain.gain.exponentialRampToValueAtTime(0.0001, time + 1.2);
tickOsc.start(time);
tickOsc.stop(time + 0.03);
}
startBassNote(note: string, time: number) {
if (!this.audioContext || !this.bassMasterGain) return;
const freq = NOTE_FREQ_MAP[note];
if (!freq) return;
const osc = this.audioContext.createOscillator();
const gain = this.audioContext.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(freq, time);
gain.connect(this.bassMasterGain);
gain.gain.setValueAtTime(0, time);
gain.gain.linearRampToValueAtTime(0.3, time + 0.01);
osc.connect(gain);
osc.start(time);
this.activeBassOscillators.set(note, { osc, gain });
}
stopBassNote(note: string, time: number) {
const active = this.activeBassOscillators.get(note);
if (active) {
active.gain.gain.cancelScheduledValues(time);
active.gain.gain.setValueAtTime(active.gain.gain.value, time);
active.gain.gain.linearRampToValueAtTime(0, time + 0.02);
active.osc.stop(time + 0.02);
this.activeBassOscillators.delete(note);
}
}
stopAllBassNotes() {
if (!this.audioContext) return;
const now = this.audioContext.currentTime;
this.activeBassOscillators.forEach((_, note) => this.stopBassNote(note, now));
}
getCurrentTime() {
return this.audioContext?.currentTime || 0;
}
close() {
if (this.audioContext && this.audioContext.state !== 'closed') {
this.audioContext.close();
}
}
}
export const audioEngine = new AudioEngine();

37
src/components/Modal.tsx Normal file
View File

@@ -0,0 +1,37 @@
import React from 'react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
children: React.ReactNode;
}
export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, onConfirm, title, children }) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex justify-center items-center">
<div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-md">
<h2 className="text-xl font-bold text-slate-800 mb-4">{title}</h2>
<div className="text-slate-600 mb-6">{children}</div>
<div className="flex justify-end gap-4">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-semibold text-slate-700 bg-slate-100 border border-slate-300 rounded-md hover:bg-slate-200 focus:outline-none focus:ring-2 focus:ring-slate-400"
>
Cancel
</button>
<button
onClick={onConfirm}
className="px-4 py-2 text-sm font-semibold text-white bg-red-600 border border-red-700 rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500"
>
Yes, clear
</button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,308 @@
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>
);
};

70
src/components/icons.tsx Normal file
View File

@@ -0,0 +1,70 @@
import React from 'react';
interface IconProps extends React.SVGProps<SVGSVGElement> {}
export const PlayIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg className={className} viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M8 5v14l11-7z" />
</svg>
);
export const StopIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg className={className} viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M6 6h12v12H6z" />
</svg>
);
export const ClearIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
);
export const UploadIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
);
export const DownloadIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
);
export const PlusIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v12m6-6H6" />
</svg>
);
export const MinusIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M18 12H6" />
</svg>
);
export const MuteIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M17 14l-4-4m0 4l4-4" />
</svg>
);
export const UnmuteIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
</svg>
);
export const CursorIcon: React.FC<IconProps> = (props) => (
<svg {...props} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path fill="currentColor" transform="translate(24, 0) scale(-1, 1)" d="M21.4,2.6a2,2,0,0,0-2.27-.42h0L3.2,9.4A2,2,0,0,0,2,11.52a2.26,2.26,0,0,0,1.8,2l5.58,1.13,1.13,5.58a2.26,2.26,0,0,0,2,1.8h.25a2,2,0,0,0,1.87-1.2L21.82,4.87A2,2,0,0,0,21.4,2.6Z"/>
</svg>
);
export const ShareIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M11 2.5a2.5 2.5 0 1 1 .603 1.628l-6.718 3.12a2.499 2.499 0 0 1 0 1.504l6.718 3.12a2.5 2.5 0 1 1-.488.876l-6.718-3.12a2.5 2.5 0 1 1 0-3.256l6.718-3.12A2.5 2.5 0 0 1 11 2.5z" fill="#334155" transform="translate(4, 4)"></path>
</svg>
);

39
src/constants.ts Normal file
View File

@@ -0,0 +1,39 @@
import { Instrument } from './types';
export const INSTRUMENTS: Instrument[] = [
{ name: 'Kick' },
{ name: 'Snare' },
{ name: 'Hi-Hat' },
{ name: 'Open Hat' },
{ name: 'Ride' },
];
export const INITIAL_TEMPO = 80;
export const INITIAL_STEPS = 16;
export const MIN_TEMPO = 40;
export const MAX_TEMPO = 240;
export const MIN_STEPS = 4;
export const MAX_STEPS = 64;
// --- BASS CONSTANTS ---
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
const getFrequency = (midiNote: number): number => {
// A4 (MIDI 69) is 440 Hz
return 440 * Math.pow(2, (midiNote - 69) / 12);
};
export const BASS_NOTES: { name: string; isSharp: boolean }[] = [];
export const NOTE_FREQ_MAP: { [key: string]: number } = {};
// Generate notes for 2 octaves, starting from C2 (MIDI 36) up to B3
for (let octave = 2; octave < 4; octave++) {
for (let i = 0; i < 12; i++) {
const noteName = `${NOTE_NAMES[i]}${octave}`;
const midiNote = 36 + (octave - 2) * 12 + i;
BASS_NOTES.push({ name: noteName, isSharp: NOTE_NAMES[i].includes('#') });
NOTE_FREQ_MAP[noteName] = getFrequency(midiNote);
}
}

9
src/defaultState.ts Normal file
View File

@@ -0,0 +1,9 @@
export const defaultState = {
tempo: 80,
steps: 16,
grid: Array.from({ length: 6 }, () => Array(16).fill(false)), // Adjusted to match INSTRUMENTS length if needed
mutes: Array(6).fill(false),
bassLine: Array.from({ length: 16 }, () => []),
drumVolume: 1,
bassVolume: 0.4
};

106
src/hooks/useCursors.ts Normal file
View File

@@ -0,0 +1,106 @@
import { useState, useEffect, useCallback, RefObject } from 'react';
import { useStore } from '../store/useStore';
const COLORS = [
'#F94144', '#F3722C', '#F8961E', '#F9C74F', '#90BE6D',
'#43AA8B', '#4D908E', '#577590', '#277DA1', '#F94144'
];
function getCursorColor(id: string) {
let hash = 0;
for (let i = 0; i < id.length; i++) {
hash = id.charCodeAt(i) + ((hash << 5) - hash);
}
return COLORS[Math.abs(hash) % COLORS.length];
}
export function useCursors(sendMessage: (message: any) => void, lastMessage: any, clientId: string | null, mainRef: RefObject<HTMLElement>) {
const [normalizedCursors, setNormalizedCursors] = useState<any>({});
const [cursors, setCursors] = useState<any>({});
// We should probably get lastMessage from a callback or the store
// For now, let's keep it as is but we'll need to fix the message flow
const handleMouseMove = useCallback((e: MouseEvent) => {
if (!mainRef.current) return;
const mainRect = mainRef.current.getBoundingClientRect();
const x = e.clientX - mainRect.left;
const y = e.clientY - mainRect.top;
if (x < 0 || y < 0 || x > mainRect.width || y > mainRect.height) return;
const normalizedX = x / mainRect.width;
const normalizedY = y / mainRect.height;
sendMessage({
type: 'cursor-move',
payload: { x: normalizedX, y: normalizedY }
});
}, [sendMessage, mainRef]);
useEffect(() => {
window.addEventListener('mousemove', handleMouseMove);
return () => window.removeEventListener('mousemove', handleMouseMove);
}, [handleMouseMove]);
const updateCursorPositions = useCallback(() => {
if (!mainRef.current) return;
const mainRect = mainRef.current.getBoundingClientRect();
const newCursors: any = {};
for (const id in normalizedCursors) {
const nc = normalizedCursors[id];
newCursors[id] = {
...nc,
x: nc.x * mainRect.width,
y: nc.y * mainRect.height
};
}
setCursors(newCursors);
}, [normalizedCursors, mainRef]);
useEffect(() => {
updateCursorPositions();
window.addEventListener('resize', updateCursorPositions);
return () => window.removeEventListener('resize', updateCursorPositions);
}, [updateCursorPositions]);
// This needs to be hooked up to the store or a global event bus
// Since useWebSocket updates the store, maybe cursors should listen to the store?
// But we don't want to put cursor positions in the main store to avoid re-renders.
// Let's use a custom event for cursors.
useEffect(() => {
const handleCursorUpdate = (e: any) => {
const { type, payload, senderId } = e.detail;
if (senderId === clientId) return;
if (type === 'user-update') {
const remoteUsers = payload.users.filter((user: any) => user.id !== clientId);
setNormalizedCursors((prev: any) => {
const newCursors: any = {};
remoteUsers.forEach((user: any) => {
const existing = prev[user.id];
newCursors[user.id] = {
id: user.id,
color: getCursorColor(user.id),
x: existing?.x || 0,
y: existing?.y || 0
};
});
return newCursors;
});
} else if (type === 'cursor-move') {
setNormalizedCursors((prev: any) => ({
...prev,
[senderId]: { ...prev[senderId], ...payload, id: senderId, color: getCursorColor(senderId) }
}));
}
};
window.addEventListener('cursor-update', handleCursorUpdate);
return () => window.removeEventListener('cursor-update', handleCursorUpdate);
}, [clientId]);
return cursors;
}

237
src/hooks/useDrumMachine.ts Normal file
View File

@@ -0,0 +1,237 @@
import { useEffect, useRef, useCallback } from 'react';
import { useStore } from '../store/useStore';
import { audioEngine } from '../audio/Engine';
import { INSTRUMENTS, MIN_STEPS, MAX_STEPS } from '../constants';
import { BeatData } from '../types';
export const useDrumMachine = (_lastMessage: any, sendMessage: (message: any) => void) => {
const {
steps, grid, bassLine, isPlaying, tempo, currentStep, mutes, drumVolume, bassVolume,
setSteps, setGrid, setBassLine, setIsPlaying, setTempo, setCurrentStep, setMutes, setDrumVolume, setBassVolume,
resetPattern, updateFromMessage
} = useStore();
const timerRef = useRef<number | null>(null);
const lookahead = 25.0;
const scheduleAheadTime = 0.1;
const nextNoteTimeRef = useRef<number>(0.0);
const sequenceStepRef = useRef<number>(0);
const scheduleNote = useCallback((beatNumber: number, time: number) => {
// Drum Notes
for (let i = 0; i < INSTRUMENTS.length; i++) {
const instrumentName = INSTRUMENTS[i].name;
if (grid[i]?.[beatNumber] && !mutes[i]) {
if (instrumentName === 'Kick') audioEngine.playKick(time);
else if (instrumentName === 'Snare') audioEngine.playSnare(time);
else if (instrumentName === 'Hi-Hat') audioEngine.playHiHat(time);
else if (instrumentName === 'Open Hat') audioEngine.playOpenHat(time);
else if (instrumentName === 'Ride') audioEngine.playRide(time);
}
}
// Bass Notes
const previousBeatNumber = (beatNumber - 1 + steps) % steps;
const currentNotes = bassLine[beatNumber] || [];
const previousNotes = bassLine[previousBeatNumber] || [];
previousNotes.forEach(note => {
if (!currentNotes.includes(note)) {
audioEngine.stopBassNote(note, time);
}
});
currentNotes.forEach(note => {
if (!previousNotes.includes(note)) {
audioEngine.startBassNote(note, time);
}
});
}, [grid, mutes, bassLine, steps]);
const scheduler = useCallback(() => {
while (nextNoteTimeRef.current < audioEngine.getCurrentTime() + scheduleAheadTime) {
scheduleNote(sequenceStepRef.current, nextNoteTimeRef.current);
setCurrentStep(sequenceStepRef.current);
const secondsPerBeat = 60.0 / tempo;
const secondsPerStep = secondsPerBeat / 4;
nextNoteTimeRef.current += secondsPerStep;
sequenceStepRef.current = (sequenceStepRef.current + 1) % steps;
}
timerRef.current = window.setTimeout(scheduler, lookahead);
}, [tempo, steps, scheduleNote, setCurrentStep]);
const startPlayback = async () => {
await audioEngine.init();
audioEngine.setBassVolume(bassVolume);
audioEngine.setDrumVolume(drumVolume);
sequenceStepRef.current = 0;
setCurrentStep(null);
nextNoteTimeRef.current = audioEngine.getCurrentTime();
setIsPlaying(true);
sendMessage({ type: 'playback', payload: { isPlaying: true } });
};
const stopPlayback = useCallback(() => {
setIsPlaying(false);
sendMessage({ type: 'playback', payload: { isPlaying: false } });
}, [setIsPlaying, sendMessage]);
const clearPattern = () => {
if (timerRef.current) clearTimeout(timerRef.current);
resetPattern();
audioEngine.stopAllBassNotes();
sendMessage({ type: 'clear', payload: { steps } });
};
const handleStepsChange = (newSteps: number) => {
const clampedSteps = Math.max(MIN_STEPS, Math.min(MAX_STEPS, newSteps));
if (clampedSteps === steps) return;
setSteps(clampedSteps);
// After resizing in the store, we need to get the new state to broadcast it
// But since setSteps is asynchronous in terms of state updates, we can just compute it here
// or better, rely on the fact that the store update will happen.
// Actually, sending just 'steps' might cause other clients to resize their own local state.
// However, to be extra safe and ensure everyone is perfectly in sync:
const newState = useStore.getState();
sendMessage({
type: 'steps',
payload: {
steps: clampedSteps,
grid: newState.grid,
bassLine: newState.bassLine
}
});
};
const setStepAction = (instrumentIndex: number, stepIndex: number, isActive: boolean) => {
const newGrid = grid.map(row => [...row]);
if (newGrid[instrumentIndex]) {
newGrid[instrumentIndex][stepIndex] = isActive;
}
setGrid(newGrid);
sendMessage({ type: 'grid', payload: { grid: newGrid } });
};
const setBassNoteAction = (stepIndex: number, note: string) => {
const newBassLine = bassLine.map(stepNotes => [...stepNotes]);
const stepNotes = newBassLine[stepIndex];
const noteIndex = stepNotes.indexOf(note);
if (noteIndex > -1) {
stepNotes.splice(noteIndex, 1);
} else {
stepNotes.push(note);
}
setBassLine(newBassLine);
sendMessage({ type: 'bassLine', payload: { bassLine: newBassLine } });
};
const toggleMute = (instrumentIndex: number) => {
const newMutes = [...mutes];
newMutes[instrumentIndex] = !newMutes[instrumentIndex];
setMutes(newMutes);
sendMessage({ type: 'mutes', payload: { mutes: newMutes } });
};
const handleDrumVolumeChange = (newVolume: number) => {
setDrumVolume(newVolume);
audioEngine.setDrumVolume(newVolume);
sendMessage({ type: 'drumVolume', payload: { drumVolume: newVolume } });
};
const handleBassVolumeChange = (newVolume: number) => {
setBassVolume(newVolume);
audioEngine.setBassVolume(newVolume);
sendMessage({ type: 'bassVolume', payload: { bassVolume: newVolume } });
};
const triggerSound = async (instrumentName: string) => {
await audioEngine.init();
const time = audioEngine.getCurrentTime();
if (instrumentName === 'Kick') audioEngine.playKick(time);
else if (instrumentName === 'Snare') audioEngine.playSnare(time);
else if (instrumentName === 'Hi-Hat') audioEngine.playHiHat(time);
else if (instrumentName === 'Open Hat') audioEngine.playOpenHat(time);
else if (instrumentName === 'Ride') audioEngine.playRide(time);
};
const triggerBassNote = async (note: string) => {
await audioEngine.init();
audioEngine.startBassNote(note, audioEngine.getCurrentTime());
// Stop after 1s for preview
setTimeout(() => audioEngine.stopBassNote(note, audioEngine.getCurrentTime()), 1000);
};
const exportBeat = () => {
const beatData: BeatData = { tempo, steps, grid, mutes, bassLine };
const blob = new Blob([JSON.stringify(beatData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'my-beat.json';
a.click();
URL.revokeObjectURL(url);
};
const importBeat = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const data: BeatData = JSON.parse(e.target?.result as string);
updateFromMessage('import', data);
sendMessage({ type: 'import', payload: data });
} catch (err) {
alert('Failed to import beat.');
}
};
reader.readAsText(file);
event.target.value = '';
};
useEffect(() => {
if (isPlaying) {
if (timerRef.current) clearTimeout(timerRef.current);
scheduler();
} else {
if (timerRef.current) clearTimeout(timerRef.current);
audioEngine.stopAllBassNotes();
setCurrentStep(null);
}
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [isPlaying, scheduler, setCurrentStep]);
useEffect(() => {
return () => {
audioEngine.close();
};
}, []);
return {
steps, grid, bassLine, isPlaying, tempo, currentStep, mutes, drumVolume, bassVolume,
setStep: setStepAction,
setBassNote: setBassNoteAction,
clearPattern,
setTempo: (val: any) => {
const newTempo = typeof val === 'function' ? val(tempo) : val;
setTempo(newTempo);
sendMessage({ type: 'tempo', payload: { tempo: newTempo } });
},
handleStepsChange,
startPlayback,
stopPlayback,
exportBeat,
importBeat,
toggleMute,
handleDrumVolumeChange,
handleBassVolumeChange,
triggerSound,
triggerBassNote,
};
};

24
src/hooks/useSession.ts Normal file
View File

@@ -0,0 +1,24 @@
import { useState, useEffect } from 'react';
function generateSessionId() {
return Math.random().toString(36).substring(2, 15);
}
export function useSession() {
const [sessionId, setSessionId] = useState<string | null>(null);
useEffect(() => {
const url = new URL(window.location.href);
let id = url.searchParams.get('sessionId');
if (!id) {
id = generateSessionId();
url.searchParams.set('sessionId', id);
window.history.replaceState({}, '', url.toString());
}
setSessionId(id);
}, []);
return sessionId;
}

80
src/hooks/useWebSocket.ts Normal file
View File

@@ -0,0 +1,80 @@
import { useEffect, useRef, useCallback } from 'react';
import { useStore } from '../store/useStore';
export function useWebSocket(sessionId: string | null) {
const {
setIsConnected,
setIsSynchronized,
setClientId,
updateFromMessage
} = useStore();
const ws = useRef<WebSocket | null>(null);
const connect = useCallback(() => {
if (!sessionId || ws.current) return;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ag-beats?sessionId=${sessionId}`;
const socket = new WebSocket(wsUrl);
socket.onopen = () => {
console.log('WebSocket connected');
setIsConnected(true);
socket.send(JSON.stringify({ type: 'get_state' }));
};
socket.onmessage = (event) => {
const message = JSON.parse(event.data);
// Handle cursors and user updates via custom events for performance
if (message.type === 'cursor-move' || message.type === 'user-update') {
window.dispatchEvent(new CustomEvent('cursor-update', { detail: message }));
if (message.type === 'welcome') {
setClientId(message.payload.clientId);
}
return;
}
if (message.type === 'welcome') {
setClientId(message.payload.clientId);
} else if (message.type === 'session_state') {
console.log('Received session state', message.payload);
updateFromMessage('state', message.payload);
setIsSynchronized(true);
} else {
updateFromMessage(message.type, message.payload);
}
};
socket.onclose = () => {
console.log('WebSocket disconnected');
setIsConnected(false);
setIsSynchronized(false);
ws.current = null;
// Retry connection
setTimeout(connect, 3000);
};
socket.onerror = (error) => {
console.error('WebSocket error:', error);
};
ws.current = socket;
}, [sessionId, setIsConnected, setIsSynchronized, setClientId, updateFromMessage]);
useEffect(() => {
connect();
return () => {
ws.current?.close();
};
}, [connect]);
const sendMessage = useCallback((message: any) => {
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify(message));
}
}, []);
return { sendMessage };
}

1
src/index.css Normal file
View File

@@ -0,0 +1 @@
@import "tailwindcss";

14
src/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/ag-beats/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Gemini Rhythm Machine</title>
</head>
<body class="bg-slate-100">
<div id="root"></div>
<script type="module" src="./index.tsx"></script>
</body>
</html>

17
src/index.tsx Normal file
View File

@@ -0,0 +1,17 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

146
src/store/useStore.ts Normal file
View File

@@ -0,0 +1,146 @@
import { create } from 'zustand';
import { Grid, BassLineGrid } from '../types';
import { INSTRUMENTS, INITIAL_STEPS, INITIAL_TEMPO } from '../constants';
interface AppState {
// Sequencer State
steps: number;
grid: Grid;
bassLine: BassLineGrid;
isPlaying: boolean;
tempo: number;
currentStep: number | null;
mutes: boolean[];
drumVolume: number;
bassVolume: number;
// Presence State
clientId: string | null;
isConnected: boolean;
isSynchronized: boolean;
// Actions
setSteps: (steps: number) => void;
setGrid: (grid: Grid) => void;
setBassLine: (bassLine: BassLineGrid) => void;
setIsPlaying: (isPlaying: boolean) => void;
setTempo: (tempo: number) => void;
setCurrentStep: (step: number | null) => void;
setMutes: (mutes: boolean[]) => void;
setDrumVolume: (volume: number) => void;
setBassVolume: (volume: number) => void;
setClientId: (id: string | null) => void;
setIsConnected: (connected: boolean) => void;
setIsSynchronized: (synchronized: boolean) => void;
updateFromMessage: (type: string, payload: any) => void;
resetPattern: () => void;
}
const createEmptyGrid = (steps: number): Grid =>
Array.from({ length: INSTRUMENTS.length }, () => Array(steps).fill(false));
const createEmptyBassLine = (steps: number): BassLineGrid =>
Array.from({ length: steps }, () => []);
export const useStore = create<AppState>((set, get) => ({
steps: INITIAL_STEPS,
grid: createEmptyGrid(INITIAL_STEPS),
bassLine: createEmptyBassLine(INITIAL_STEPS),
isPlaying: false,
tempo: INITIAL_TEMPO,
currentStep: null,
mutes: Array(INSTRUMENTS.length).fill(false),
drumVolume: 1,
bassVolume: 0.4,
clientId: null,
isConnected: false,
isSynchronized: false,
setSteps: (steps) => {
const { grid, bassLine } = get();
// Resize drum grid
const newGrid = grid.map(row => {
const newRow = [...row];
if (newRow.length < steps) {
while (newRow.length < steps) newRow.push(false);
} else if (newRow.length > steps) {
newRow.length = steps;
}
return newRow;
});
// Resize bass line
const newBassLine = [...bassLine];
if (newBassLine.length < steps) {
while (newBassLine.length < steps) newBassLine.push([]);
} else if (newBassLine.length > steps) {
newBassLine.length = steps;
}
set({ steps, grid: newGrid, bassLine: newBassLine });
},
setGrid: (grid) => set({ grid }),
setBassLine: (bassLine) => set({ bassLine }),
setIsPlaying: (isPlaying) => set({ isPlaying }),
setTempo: (tempo) => set({ tempo }),
setCurrentStep: (currentStep) => set({ currentStep }),
setMutes: (mutes) => set({ mutes }),
setDrumVolume: (drumVolume) => set({ drumVolume }),
setBassVolume: (bassVolume) => set({ bassVolume }),
setClientId: (clientId) => set({ clientId }),
setIsConnected: (isConnected) => set({ isConnected }),
setIsSynchronized: (isSynchronized) => set({ isSynchronized }),
resetPattern: () => {
const { steps } = get();
set({
grid: createEmptyGrid(steps),
bassLine: createEmptyBassLine(steps),
});
},
updateFromMessage: (type, payload) => {
switch (type) {
case 'state':
set({
grid: payload.grid,
bassLine: payload.bassLine,
tempo: payload.tempo,
isPlaying: payload.isPlaying,
mutes: payload.mutes,
drumVolume: payload.drumVolume,
bassVolume: payload.bassVolume,
steps: payload.steps,
});
break;
case 'grid': set({ grid: payload.grid }); break;
case 'bassLine': set({ bassLine: payload.bassLine }); break;
case 'tempo': set({ tempo: payload.tempo }); break;
case 'playback': set({ isPlaying: payload.isPlaying }); break;
case 'mutes': set({ mutes: payload.mutes }); break;
case 'drumVolume': set({ drumVolume: payload.drumVolume }); break;
case 'bassVolume': set({ bassVolume: payload.bassVolume }); break;
case 'steps': set({ steps: payload.steps }); break;
case 'clear':
set({
grid: createEmptyGrid(payload.steps),
bassLine: createEmptyBassLine(payload.steps),
});
break;
case 'import':
set({
tempo: payload.tempo,
steps: payload.steps,
grid: payload.grid,
mutes: payload.mutes,
bassLine: payload.bassLine,
});
break;
}
}
}));

16
src/types.ts Normal file
View File

@@ -0,0 +1,16 @@
export interface Instrument {
name: string;
sampleUrl?: string;
}
export type Grid = boolean[][];
export type BassLineGrid = string[][];
export interface BeatData {
tempo: number;
steps: number;
grid: Grid;
mutes?: boolean[];
bassLine?: BassLineGrid;
}

21
src/utils.ts Normal file
View File

@@ -0,0 +1,21 @@
// A simple debounce function
export const debounce = <F extends (...args: any[]) => any>(func: F, waitFor: number) => {
let timeout: ReturnType<typeof setTimeout> | null = null;
const debounced = (...args: Parameters<F>) => {
if (timeout !== null) {
clearTimeout(timeout);
timeout = null;
}
timeout = setTimeout(() => func(...args), waitFor);
};
const cancel = () => {
if (timeout !== null) {
clearTimeout(timeout);
timeout = null;
}
};
return [debounced, cancel] as [(...args: Parameters<F>) => void, () => void];
};

30
tsconfig.json Normal file
View File

@@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "ES2020",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"allowJs": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"paths": {
"@/*" : ["./*"]
}
}
}

View File

@@ -1,31 +1,39 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import { fileURLToPath } from 'url';
import { dirname, resolve } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
root: 'public',
plugins: [react()],
base: '/ag-beats/',
build: {
outDir: '../dist',
emptyOutDir: true,
assetsDir: '.',
rollupOptions: {
input: {
main: path.resolve(__dirname, 'public/index.html')
}
}
},
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: {
alias: {
'@': path.resolve(__dirname, './public'),
const env = loadEnv(mode, '.', '');
return {
root: 'src',
plugins: [
react(),
tailwindcss(),
],
base: '/ag-beats/',
build: {
outDir: '../dist',
emptyOutDir: true,
assetsDir: '.',
rollupOptions: {
input: {
main: resolve(__dirname, 'src/index.html')
}
}
};
},
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: {
alias: {
'@': resolve(__dirname, './src'),
}
}
};
});

39
vite.config.ts.bak Normal file
View File

@@ -0,0 +1,39 @@
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import { fileURLToPath } from 'url';
import { dirname, resolve } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
root: 'src',
plugins: [
react(),
tailwindcss(),
],
base: '/ag-beats/',
build: {
outDir: '../dist',
emptyOutDir: true,
assetsDir: '.',
rollupOptions: {
input: {
main: resolve(__dirname, 'src/index.html')
}
}
},
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: {
alias: {
'@': resolve(__dirname, './src'),
}
}
};
});

22
vite.config.ts.debug Normal file
View File

@@ -0,0 +1,22 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { fileURLToPath } from 'url';
import { dirname, resolve } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
export default defineConfig({
root: 'src',
plugins: [react()],
base: '/ag-beats/',
build: {
outDir: '../dist',
emptyOutDir: true,
},
resolve: {
alias: {
'@': resolve(__dirname, './src'),
}
}
});