Total refactoring performed
This commit is contained in:
@@ -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
2412
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
32
package.json
32
package.json
@@ -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
24
server/db.ts
Normal 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
103
server/index.ts
Normal 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
BIN
session_state.db
Normal file
Binary file not shown.
104
src/App.tsx
Normal file
104
src/App.tsx
Normal 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
261
src/audio/Engine.ts
Normal 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
37
src/components/Modal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
308
src/components/Sequencer.tsx
Normal file
308
src/components/Sequencer.tsx
Normal 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
70
src/components/icons.tsx
Normal 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
39
src/constants.ts
Normal 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
9
src/defaultState.ts
Normal 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
106
src/hooks/useCursors.ts
Normal 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
237
src/hooks/useDrumMachine.ts
Normal 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
24
src/hooks/useSession.ts
Normal 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
80
src/hooks/useWebSocket.ts
Normal 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
1
src/index.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
14
src/index.html
Normal file
14
src/index.html
Normal 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
17
src/index.tsx
Normal 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
146
src/store/useStore.ts
Normal 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
16
src/types.ts
Normal 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
21
src/utils.ts
Normal 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
30
tsconfig.json
Normal 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": {
|
||||
"@/*" : ["./*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
39
vite.config.ts.bak
Normal 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
22
vite.config.ts.debug
Normal 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'),
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user