Initial commit

This commit is contained in:
AG
2025-12-20 16:32:05 +02:00
commit f430c2b757
31 changed files with 4797 additions and 0 deletions

View File

@@ -0,0 +1,75 @@
🔍 Problem Summary
You implemented **Local Storage Autosave** (item #2), but **on page reload, the session does not restore the saved state**. The page loads with the default state instead.
---
## 🔎 Likely Root Cause
### ❌ You're saving the state to Local Storage, but **not loading it on startup**.
In `useDrumMachine.ts`, the state is probably initialized like this (or similar):
```ts
const [state, setState] = useState(defaultState);
```
This means the state **always starts from `defaultState`**, and no logic attempts to retrieve from `localStorage`.
---
## ✅ Solution
### 🛠 Modify the Initial State to Load from Local Storage
Update your `useDrumMachine` hook to **attempt to load the autosaved session from localStorage first**, before falling back to the default state.
#### ✅ Example Patch:
```ts
const loadInitialState = (): StateType => {
try {
const saved = localStorage.getItem('Autosaved Session');
if (saved) {
const parsed = JSON.parse(saved);
return parsed.data;
}
} catch (e) {
console.error('Failed to load autosaved session:', e);
}
return defaultState;
};
const [state, setState] = useState(loadInitialState);
```
---
## 📌 Notes
- Make sure this runs **only for new sessions**, not when joining an existing session.
- If your app distinguishes between "new session" and "joined session" using `useSession`, you can use that to conditionally load from local storage.
#### Example:
```ts
const sessionId = useSession();
const isNewSession = sessionId.startsWith('new-'); // or whatever logic you use
const [state, setState] = useState(() => {
if (isNewSession) return loadInitialState();
return defaultState;
});
```
---
## ✅ Recap
|Problem|Fix|
|---|---|
|State always loads from `defaultState`|Load from `localStorage` on first mount|
|Only autosave was implemented|Add **autosave + restore** for full loop|
|Make it conditional on session type|Don't overwrite a joined session with autosaved local one|

View File

@@ -0,0 +1,40 @@
### Local Storage Autosave
#### ✅ Behavior
- All local state changes (either **user-made** or **received from others**) are autosaved into local storage under the key `Autosaved Session`.
- Throttle or debounce autosaving to reduce performance overhead.
- **Delay** of `7501500ms` is typically ideal — tweak based on UX feedback.
- Wrap autosave logic in a `useEffect` (for React) that depends on your state.
- **Cancel debounce** on unmount to avoid memory leaks:
```
```ts
useEffect(() => {
return () => debouncedAutosave.cancel();
}, []);
```
#### 🔁 Autosave Trigger Events
- State change from user interaction
- State update from WebSocket messages
- After loading a saved session (explained further below)
#### 🧠 Decision Logic on Load
1. **New session**:
- Start with default state
- Autosave begins immediately
2. **Join existing session**:
- Request full state from server
- Begin autosaving once session state is received and applied

View File

@@ -0,0 +1,82 @@
### 1. Manual Save
#### ✅ "Save Session" Button
- Button icon: 🖫 (diskette), with tooltip: "Save session"
- Opens modal:
- Input: name of the session
- Option: overwrite existing session with same name (if applicable)
- Stores:
- Name
- Timestamp
- State (`data`)
- `isAutosave: false`
---
### 2. Saved Sessions Panel
#### ✅ Access
- Button/icon to open modal or dropdown with saved sessions
#### 🔁 Display Rules
- Top item: "Autosaved Session" (non-deletable, clearly labeled)
- Followed by manually saved sessions, **sorted alphabetically by name**
#### ✅ Item UI Elements
- Session name
- Save timestamp
- Load button (clickable name)
- Delete button (only for manual saves)
- Delete must open confirmation modal:
> “Delete this saved session? This action cannot be undone.”
#### ✅ Loading a Saved Session
- Replaces current app state with the saved one
- If unsaved changes exist, confirm before loading:
> “Load saved session? This will replace your current progress.”
---
### 🧠 Logic Extension: Loading Saved Sessions & Autosave
> ✅ **When the user loads a saved session, autosave continues in the background.**
- The **loaded state becomes the new working session**.
- All further changes are autosaved into the `Autosaved Session`.
- The user can still press "Save Session" again:
- If using the **same name**, prompt:
> “Overwrite the session ChillBeat? This will replace the saved version.”
- Or offer a new name input
- User can also choose to **delete the older version** after saving.

76
.context/task.md Normal file
View File

@@ -0,0 +1,76 @@
Here is a clear and concise explanation of the task for an AI agent to fix the described problem.
---
### **Objective: Fix Session State Synchronization for New Clients**
Your task is to correct a bug in the application's real-time collaboration feature. Currently, when a new client joins an existing session, they see the application's default state, not the session's current, modified state. This creates a state desynchronization issue.
### **Problem Analysis**
The client-side application is already architected to handle an initial state dump upon connection.
1. **`useWebSocket.ts`:** When a message with `type: 'welcome'` is received, the hook is designed to take the `message.payload.state` object and pass it to the application's main state management hook.
2. **`useDrumMachine.ts`:** This hook has a `useEffect` that listens for incoming messages. It contains a specific `case 'state':` that correctly processes a full state object, updating the grid, bassline, tempo, steps, and other parameters.
The problem is not on the client side. **The root cause is on the server-side WebSocket implementation.**
The server is likely doing one of the following incorrect things:
- It sends a `'welcome'` message that does **not** contain the complete, current state of the session.
- It sends a `'welcome'` message with an empty or default `state` object.
- There is a race condition where the server sends the `'welcome'` message _before_ it has finished retrieving the current session state from its data store.
### **Required Implementation**
You must modify the **server-side** WebSocket logic to ensure state is synchronized correctly.
1. **On a new WebSocket connection:** When a client connects and provides a `sessionId`, your server-side code must immediately perform the following steps.
2. **Retrieve the complete current state** for that `sessionId`. This state must include:
- The drum `grid`
- The `bassLine`
- The current `tempo`
- The number of `steps`
- The `mutes` array
- The `drumVolume`
- The `bassVolume`
3. **Construct a `'welcome'` message.** This message must contain the full state object you just retrieved. The JSON payload sent to the newly connected client must follow this exact structure:
JSON
```
{
"type": "welcome",
"payload": {
"clientId": "UNIQUE_CLIENT_ID_FOR_NEW_CLIENT",
"state": {
"grid": [/* current grid state */],
"bassLine": [/* current bassline state */],
"tempo": 135, // The current tempo
"steps": 32, // The current number of steps
"mutes": [false, true, ...], // The current mute states
"drumVolume": 0.8,
"bassVolume": 0.5
}
}
}
```
4. **Send this `'welcome'` message** to the newly connected client _immediately_ upon their connection. This must be the very first message they receive, ensuring the UI is rendered with the correct state from the start.
This change will ensure that any client joining an in-progress session will see the exact same state as all other participants, resolving the synchronization bug.

103
.context/task2.md Normal file
View File

@@ -0,0 +1,103 @@
To solve the **desynchronization problem** where **Client 2 sees default state instead of current state**, the AI agent needs to implement **state synchronization on join**. Here's a detailed plan for what needs to be done, which you can feed to an AI agent or developer:
---
## ✅ Problem Summary
* **Client 1** changes app state; changes are broadcast via WebSocket.
* **Client 2** joins a session but does **not receive the current state** of all elements—only the default.
* When Client 1 makes further changes, Client 2 sees them, but this causes layout inconsistency since Client 2s other state is outdated.
* If Client 2 makes changes, it **overwrites** the correct session state.
---
## 🧠 What the AI Agent Must Do
### 1. **Server: Maintain the Current State per Session**
* In `server.js`, ensure there is a per-session object (e.g., `{ sessionId: stateObject }`) that stores **all element states**.
* When **Client 1** sends a change message (`{ type: 'update', payload: {...} }`), update that stored session state accordingly.
#### ✅ Example in `server.js`
```js
const sessions = {}; // { [sessionId]: { state: {...}, clients: [] } }
function handleMessage(message, ws, sessionId) {
const data = JSON.parse(message);
if (data.type === 'update') {
// Update session state
if (!sessions[sessionId].state) sessions[sessionId].state = {};
sessions[sessionId].state[data.payload.id] = data.payload;
// Broadcast to others
broadcastExcept(ws, sessionId, message);
}
}
```
---
### 2. **Client 2: Request the Current Session State on Join**
* When Client 2 connects via WebSocket, it must send a message like:
```json
{ "type": "get_state" }
```
---
### 3. **Server: Respond to `get_state` with Full State Snapshot**
* On receiving `{ type: 'get_state' }`, send:
```json
{ "type": "session_state", "payload": { ...allCurrentState } }
```
---
### 4. **Client 2: Apply the Session State on First Load**
In `useDrumMachine` (or wherever state is applied), handle a new message type:
```ts
if (message.type === 'session_state') {
applyFullState(message.payload); // update all controls
}
```
---
### 5. **(Optional) Mark Synchronization Complete**
* Use a flag like `isSynchronized` on the client to **ignore all inputs** until the full session state is received and applied.
---
## 🧩 Where This Fits in Your Code
* In `App.tsx`, youre already using a `useWebSocket` hook — youll need to modify it so that:
* On `open`, send `get_state`.
* On receiving `session_state`, trigger state application.
* The actual application of state will happen inside `useDrumMachine`.
---
## ✅ Summary Checklist
| Step | Task |
| ---- | --------------------------------------------------------- |
| ✅ | Store session state on server per `sessionId` |
| ✅ | Update session state on every change |
| ✅ | On new client connect, send `get_state` |
| ✅ | Server responds with `session_state` |
| ✅ | Client applies full session state before handling updates |
| ⛔ | Don't allow local edits before sync is complete |
---
Would you like help modifying the specific `useWebSocket` or `useDrumMachine` code to support this logic?

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

22
README.md Normal file
View File

@@ -0,0 +1,22 @@
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`
## New features
1. Updated volume sliders styles for Firefox.
2. Headings and page Title update.
3. Space toggles Play/ Stop.
4. While playback is idle, pressing on a cell triggers instrument sound.

24
defaultState.js Normal file
View File

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

23
docker-compose.yml Normal file
View File

@@ -0,0 +1,23 @@
services:
nodejs-apps:
image: node:lts-slim
container_name: node-apps
ports:
- "3030:3000"
- "3031:3001"
- "3032:3002"
# Set the working directory inside the container
working_dir: /usr/src/app
volumes:
- ./nodejs_data:/usr/src/app
# The command to execute when the container starts.
# It performs the following sequence:
# 1. Installs PM2 globally.
# 2. Runs the main application dependencies installation (assuming you have a package.json).
# 3. Executes pm2-runtime, using the config file to launch all three apps
# (ag-home, ag-beats, ag-ball) and keeping the container running.
command: /bin/sh -c "npm install -g pm2 && cd ag-beats && npm install && cd ../ball-shooting && npm install && cd .. && pm2 start server.js --name ag-home && cd ag-beats && pm2 start ecosystem.config.js && cd ../ball-shooting && pm2 start npm --name ag-ball -- start && pm2 logs --raw"
restart: unless-stopped

11
ecosystem.config.js Normal file
View File

@@ -0,0 +1,11 @@
module.exports = {
apps : [{
name : "ag-beats",
script : "./server.js",
interpreter: "node",
args: "",
node_args: "",
wait_ready: true,
listen_timeout: 5000
}]
}

2520
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "ag-beats",
"version": "1.0.0",
"description": "A web-based drum machine.",
"main": "server.js",
"type": "commonjs",
"scripts": {
"start": "node server.js",
"build": "vite build",
"postinstall": "npm run build",
"dev": "node server.dev.js"
},
"dependencies": {
"express": "^4.19.2",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.5.3",
"vite": "^5.3.3",
"ws": "^8.18.0",
"uuid": "^9.0.1"
}
}

101
public/App.tsx Normal file
View File

@@ -0,0 +1,101 @@
import React, { useEffect, useCallback, useState, useRef } from 'react';
import { Sequencer } from './components/Sequencer';
import { useDrumMachine } from './hooks/useDrumMachine';
import { useSession } from './hooks/useSession';
import { useWebSocket } from './hooks/useWebSocket';
import { useCursors } from './hooks/useCursors';
import { ShareIcon, CursorIcon } from './components/icons';
function App(): React.ReactNode {
const sessionId = useSession();
const { lastMessage, sendMessage, isConnected, isSynchronized, clientId } = useWebSocket(sessionId);
const drumMachine = useDrumMachine(lastMessage, sendMessage);
const mainRef = useRef<HTMLElement>(null);
const cursors = useCursors(sendMessage, lastMessage, clientId, mainRef);
const { isPlaying, startPlayback, stopPlayback } = drumMachine;
const [showCopyMessage, setShowCopyMessage] = useState(false);
const handleKeyDown = useCallback((event: KeyboardEvent) => {
if (event.code === 'Space' && isSynchronized) {
event.preventDefault();
if (isPlaying) {
stopPlayback();
} else {
startPlayback();
}
}
}, [isPlaying, startPlayback, stopPlayback, isSynchronized]);
useEffect(() => {
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [handleKeyDown]);
const handleShareSession = () => {
navigator.clipboard.writeText(window.location.href).then(() => {
setShowCopyMessage(true);
setTimeout(() => setShowCopyMessage(false), 3000);
});
};
return (
<div className="min-h-screen flex flex-col items-center justify-center p-4 font-sans">
<div className="w-full max-w-6xl mx-auto">
<header className="mb-6 text-center relative z-10">
<h1 className="text-4xl font-bold text-slate-800 tracking-tight">AG Beats</h1>
<p className="text-slate-500 mt-2">Craft your beats and bass lines with this interactive step sequencer.</p>
<div className="absolute top-1/2 right-0 transform -translate-y-1/2 flex flex-col items-start gap-2">
<div className={`text-xs font-semibold ${isConnected ? 'text-green-500' : 'text-red-500'}`}>
{isConnected ? '● Connected' : '● Disconnected'}
</div>
<div className="relative">
<button
onClick={handleShareSession}
className="flex items-center justify-center gap-2 px-2 lg:px-3 py-1 lg:py-2 text-xs lg:text-sm font-semibold text-slate-700 bg-white border border-slate-300 rounded-md shadow-sm hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-orange-500"
>
<ShareIcon className="w-5 h-5" />
<span className="hidden lg:inline">Share Session</span>
</button>
{showCopyMessage && (
<div className="absolute top-full right-0 mt-2 p-2 text-xs text-white bg-slate-800 rounded-md shadow-lg transition-opacity duration-300 ease-in-out z-50">
Session link copied. Send it to your friends!
</div>
)}
</div>
</div>
</header>
<main ref={mainRef} className="relative">
{Object.values(cursors).map((cursor: any) => (
<div
key={cursor.id}
className="absolute z-50"
style={{
left: `${cursor.x}px`,
top: `${cursor.y}px`,
transition: 'left 0.1s linear, top 0.1s linear'
}}
>
<CursorIcon className="w-10 h-10" style={{ color: cursor.color }} />
</div>
))}
{isSynchronized ? (
<Sequencer {...drumMachine} />
) : (
<div className="flex items-center justify-center h-96 bg-slate-100 rounded-lg">
<p className="text-slate-500">Synchronizing session state...</p>
</div>
)}
</main>
<footer className="text-center mt-8 text-sm text-slate-400">
<p>Built with React, TypeScript, and Tailwind CSS. Powered by the Web Audio API and WebSockets.</p>
</footer>
</div>
</div>
);
}
export default App;

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

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
public/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 = 76;
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);
}
}

View File

@@ -0,0 +1,96 @@
import { useState, useEffect, useCallback, RefObject } from 'react';
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>({});
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;
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 = {};
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(() => {
if (!lastMessage) return;
const { type, payload, senderId } = lastMessage;
if (type === 'user-update') {
const remoteUsers = payload.users.filter((user: any) => user.id !== clientId);
const newCursors = {};
remoteUsers.forEach((user: any) => {
const existing = normalizedCursors[user.id];
newCursors[user.id] = {
id: user.id,
color: getCursorColor(user.id),
x: existing?.x || 0,
y: existing?.y || 0
};
});
setNormalizedCursors(newCursors);
} else if (type === 'cursor-move' && senderId !== clientId) {
setNormalizedCursors(prev => ({
...prev,
[senderId]: { ...prev[senderId], ...payload }
}));
}
}, [lastMessage, clientId]);
useEffect(() => {
updateCursorPositions();
window.addEventListener('resize', updateCursorPositions);
return () => {
window.removeEventListener('resize', updateCursorPositions);
};
}, [updateCursorPositions]);
return cursors;
}

View File

@@ -0,0 +1,616 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { INSTRUMENTS, INITIAL_TEMPO, INITIAL_STEPS, MIN_STEPS, MAX_STEPS, NOTE_FREQ_MAP } from '../constants';
import { Grid, BeatData, BassLineGrid } from '../types';
const createEmptyGrid = (steps: number): Grid => {
return Array.from({ length: INSTRUMENTS.length }, () => Array(steps).fill(false));
};
const createEmptyBassLine = (steps: number): BassLineGrid => {
return Array.from({ length: steps }, () => []);
}
const playKick = (audioContext: AudioContext, time: number, destination: AudioNode) => {
const osc = audioContext.createOscillator();
const subOsc = audioContext.createOscillator();
const gain = audioContext.createGain();
const subGain = audioContext.createGain();
const shaper = audioContext.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(destination);
const duration = 0.6;
const finalStopTime = time + duration;
osc.type = 'sine';
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.type = 'sine';
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(finalStopTime);
subOsc.stop(finalStopTime);
};
const playSnare = (audioContext: AudioContext, time: number, destination: AudioNode) => {
const noiseGain = audioContext.createGain();
const noiseFilter = audioContext.createBiquadFilter();
const osc = audioContext.createOscillator();
const oscGain = audioContext.createGain();
const noiseBuffer = audioContext.createBuffer(1, audioContext.sampleRate * 0.5, audioContext.sampleRate);
const output = noiseBuffer.getChannelData(0);
for (let i = 0; i < output.length; i++) {
output[i] = Math.random() * 2 - 1;
}
const noiseSource = audioContext.createBufferSource();
noiseSource.buffer = noiseBuffer;
noiseFilter.type = 'highpass';
noiseFilter.frequency.value = 1000;
noiseSource.connect(noiseFilter);
noiseFilter.connect(noiseGain);
noiseGain.connect(destination);
osc.type = 'triangle';
osc.frequency.setValueAtTime(200, time);
osc.connect(oscGain);
oscGain.connect(destination);
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);
};
const createHiHatSound = (audioContext: AudioContext, time: number, destination: AudioNode, duration: number) => {
const fundamental = 40;
const ratios = [2, 3, 4.16, 5.43, 6.79, 8.21];
const gain = audioContext.createGain();
const bandpass = audioContext.createBiquadFilter();
const highpass = audioContext.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(destination);
ratios.forEach(ratio => {
const osc = audioContext.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);
};
const playHiHat = (audioContext: AudioContext, time: number, destination: AudioNode) => {
createHiHatSound(audioContext, time, destination, 0.08);
};
const playOpenHat = (audioContext: AudioContext, time: number, destination: AudioNode) => {
createHiHatSound(audioContext, time, destination, 0.8);
};
const playRide = (audioContext: AudioContext, time: number, destination: AudioNode) => {
const masterGain = audioContext.createGain();
const highpass = audioContext.createBiquadFilter();
highpass.type = 'highpass';
highpass.frequency.setValueAtTime(800, time);
highpass.Q.value = 0.8;
masterGain.connect(highpass);
highpass.connect(destination);
const tickOsc = audioContext.createOscillator();
const tickGain = audioContext.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 = audioContext.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);
};
export const useDrumMachine = (lastMessage: any, sendMessage: (message: any) => void) => {
const [steps, setSteps] = useState(INITIAL_STEPS);
const [grid, setGrid] = useState<Grid>(createEmptyGrid(INITIAL_STEPS));
const [bassLine, setBassLine] = useState<BassLineGrid>(() => createEmptyBassLine(INITIAL_STEPS));
const [isPlaying, setIsPlaying] = useState(false);
const [tempo, setTempo] = useState(INITIAL_TEMPO);
const [currentStep, setCurrentStep] = useState<number | null>(null);
const [mutes, setMutes] = useState<boolean[]>(() => Array(INSTRUMENTS.length).fill(false));
const [drumVolume, setDrumVolume] = useState(1);
const [bassVolume, setBassVolume] = useState(0.4);
const audioContextRef = useRef<AudioContext | null>(null);
const audioBuffersRef = useRef<Map<string, AudioBuffer>>(new Map());
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 activeOscillatorsRef = useRef<Map<string, { osc: OscillatorNode; gain: GainNode }>>(new Map());
const drumMasterGainRef = useRef<GainNode | null>(null);
const bassMasterGainRef = useRef<GainNode | null>(null);
const loadSamples = useCallback(async (context: AudioContext) => {
const newBuffers = new Map<string, AudioBuffer>();
for (const instrument of INSTRUMENTS) {
if (!instrument.sampleUrl) continue;
try {
const response = await fetch(instrument.sampleUrl);
const arrayBuffer = await response.arrayBuffer();
const decodedData = await context.decodeAudioData(arrayBuffer);
newBuffers.set(instrument.name, decodedData);
} catch (error) {
console.error(`Error loading sample for ${instrument.name}:`, error);
}
}
audioBuffersRef.current = newBuffers;
}, []);
const initAudio = useCallback(async () => {
if (!audioContextRef.current) {
audioContextRef.current = new window.AudioContext();
await loadSamples(audioContextRef.current);
drumMasterGainRef.current = audioContextRef.current.createGain();
drumMasterGainRef.current.gain.value = drumVolume;
drumMasterGainRef.current.connect(audioContextRef.current.destination);
bassMasterGainRef.current = audioContextRef.current.createGain();
bassMasterGainRef.current.gain.value = bassVolume;
bassMasterGainRef.current.connect(audioContextRef.current.destination);
}
}, [loadSamples, drumVolume, bassVolume]);
const stopAllBassNotes = useCallback(() => {
if (!audioContextRef.current || !bassMasterGainRef.current) return;
const now = audioContextRef.current.currentTime;
activeOscillatorsRef.current.forEach(({ osc, gain }) => {
try {
gain.gain.cancelScheduledValues(now);
gain.gain.setTargetAtTime(0, now, 0.01);
osc.stop(now + 0.1);
} catch (e) { /* Ignore errors */ }
});
activeOscillatorsRef.current.clear();
}, []);
const setStep = (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 setBassNote = (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 clearPattern = () => {
if (timerRef.current) {
window.clearTimeout(timerRef.current);
timerRef.current = null;
}
const newGrid = createEmptyGrid(steps);
const newBassLine = createEmptyBassLine(steps);
setGrid(newGrid);
setBassLine(newBassLine);
stopAllBassNotes();
sendMessage({ type: 'clear', payload: { steps } });
};
const toggleMute = (instrumentIndex: number) => {
const newMutes = [...mutes];
newMutes[instrumentIndex] = !newMutes[instrumentIndex];
setMutes(newMutes);
sendMessage({ type: 'mutes', payload: { mutes: newMutes } });
};
const resizeGrid = useCallback((newSteps: number) => {
setGrid(prevGrid => {
const newGrid = createEmptyGrid(newSteps);
for(let i=0; i<INSTRUMENTS.length; i++){
for(let j=0; j<Math.min(prevGrid[i]?.length ?? 0, newSteps); j++){
newGrid[i][j] = prevGrid[i][j];
}
}
return newGrid;
});
setBassLine(prevBassLine => {
const newBassLine = createEmptyBassLine(newSteps);
for (let i = 0; i < Math.min(prevBassLine.length, newSteps); i++) {
newBassLine[i] = prevBassLine[i];
}
return newBassLine;
});
}, []);
const handleStepsChange = (newSteps: number) => {
const clampedSteps = Math.max(MIN_STEPS, Math.min(MAX_STEPS, newSteps));
if (clampedSteps === steps) return;
setSteps(clampedSteps);
resizeGrid(clampedSteps);
sendMessage({ type: 'steps', payload: { steps: clampedSteps } });
};
const scheduleNote = useCallback((beatNumber: number, time: number) => {
const audioContext = audioContextRef.current;
if (!audioContext || !bassMasterGainRef.current || !drumMasterGainRef.current) return;
for (let i = 0; i < INSTRUMENTS.length; i++) {
const instrumentName = INSTRUMENTS[i].name;
if (grid[i]?.[beatNumber] && !mutes[i]) {
if (instrumentName === 'Kick') playKick(audioContext, time, drumMasterGainRef.current);
else if (instrumentName === 'Snare') playSnare(audioContext, time, drumMasterGainRef.current);
else if (instrumentName === 'Hi-Hat') playHiHat(audioContext, time, drumMasterGainRef.current);
else if (instrumentName === 'Open Hat') playOpenHat(audioContext, time, drumMasterGainRef.current);
else if (instrumentName === 'Ride') playRide(audioContext, time, drumMasterGainRef.current);
}
}
const previousBeatNumber = (beatNumber - 1 + steps) % steps;
const currentNotes = bassLine[beatNumber] || [];
const previousNotes = bassLine[previousBeatNumber] || [];
previousNotes.forEach(note => {
if (!currentNotes.includes(note)) {
const activeNode = activeOscillatorsRef.current.get(note);
if (activeNode) {
const { osc, gain } = activeNode;
gain.gain.cancelScheduledValues(time);
gain.gain.setValueAtTime(gain.gain.value, time);
gain.gain.linearRampToValueAtTime(0, time + 0.02);
osc.stop(time + 0.02);
activeOscillatorsRef.current.delete(note);
}
}
});
currentNotes.forEach(note => {
if (!previousNotes.includes(note)) {
const freq = NOTE_FREQ_MAP[note];
if (freq) {
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.type = 'sine';
oscillator.frequency.setValueAtTime(freq, time);
gainNode.connect(bassMasterGainRef.current as GainNode);
gainNode.gain.setValueAtTime(0, time);
gainNode.gain.linearRampToValueAtTime(0.3, time + 0.01);
oscillator.connect(gainNode);
oscillator.start(time);
activeOscillatorsRef.current.set(note, { osc: oscillator, gain: gainNode });
}
}
});
}, [grid, mutes, bassLine, steps]);
const scheduler = useCallback(() => {
const audioContext = audioContextRef.current;
if (!audioContext) return;
while (nextNoteTimeRef.current < audioContext.currentTime + 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]);
const startPlayback = async () => {
await initAudio();
const audioContext = audioContextRef.current;
if (!audioContext || !bassMasterGainRef.current) return;
if (audioContext.state === 'suspended') await audioContext.resume();
const now = audioContext.currentTime;
bassMasterGainRef.current.gain.cancelScheduledValues(now);
bassMasterGainRef.current.gain.setTargetAtTime(bassVolume, now, 0.01);
sequenceStepRef.current = 0;
setCurrentStep(null);
nextNoteTimeRef.current = now;
setIsPlaying(true);
sendMessage({ type: 'playback', payload: { isPlaying: true } });
};
const stopPlayback = () => {
setIsPlaying(false);
sendMessage({ type: 'playback', payload: { isPlaying: false } });
};
const exportBeat = () => {
const beatData: BeatData = { tempo, steps, grid, mutes, bassLine };
const jsonString = JSON.stringify(beatData, null, 2);
const blob = new Blob([jsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'my-beat.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
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 text = e.target?.result;
if (typeof text !== 'string') throw new Error("File is not valid text");
const data: BeatData = JSON.parse(text);
if(typeof data.tempo !== 'number' || typeof data.steps !== 'number' || !Array.isArray(data.grid)) {
throw new Error("Invalid beat file format.");
}
setTempo(data.tempo);
setSteps(data.steps);
setGrid(data.grid);
setMutes(data.mutes || Array(INSTRUMENTS.length).fill(false));
setBassLine(data.bassLine || createEmptyBassLine(data.steps));
sendMessage({ type: 'import', payload: data });
alert('Beat imported successfully!');
} catch (error) {
console.error("Failed to import beat:", error);
alert('Failed to import beat. The file may be invalid.');
}
};
reader.readAsText(file);
event.target.value = '';
};
const handleDrumVolumeChange = useCallback((newVolume: number) => {
setDrumVolume(newVolume);
if (drumMasterGainRef.current && audioContextRef.current) {
drumMasterGainRef.current.gain.setTargetAtTime(newVolume, audioContextRef.current.currentTime, 0.01);
}
sendMessage({ type: 'drumVolume', payload: { drumVolume: newVolume } });
}, [sendMessage]);
const handleBassVolumeChange = useCallback((newVolume: number) => {
setBassVolume(newVolume);
if (bassMasterGainRef.current && audioContextRef.current) {
bassMasterGainRef.current.gain.setTargetAtTime(newVolume, audioContextRef.current.currentTime, 0.01);
}
sendMessage({ type: 'bassVolume', payload: { bassVolume: newVolume } });
}, [sendMessage]);
const triggerSound = useCallback(async (instrumentName: string) => {
await initAudio();
const audioContext = audioContextRef.current;
if (!audioContext || !drumMasterGainRef.current) return;
const time = audioContext.currentTime;
if (instrumentName === 'Kick') playKick(audioContext, time, drumMasterGainRef.current);
else if (instrumentName === 'Snare') playSnare(audioContext, time, drumMasterGainRef.current);
else if (instrumentName === 'Hi-Hat') playHiHat(audioContext, time, drumMasterGainRef.current);
else if (instrumentName === 'Open Hat') playOpenHat(audioContext, time, drumMasterGainRef.current);
else if (instrumentName === 'Ride') playRide(audioContext, time, drumMasterGainRef.current);
}, [initAudio]);
const triggerBassNote = useCallback(async (note: string) => {
await initAudio();
const audioContext = audioContextRef.current;
if (!audioContext || !bassMasterGainRef.current) return;
const time = audioContext.currentTime;
bassMasterGainRef.current.gain.cancelScheduledValues(time);
bassMasterGainRef.current.gain.setTargetAtTime(bassVolume, time, 0.01);
const freq = NOTE_FREQ_MAP[note];
if (freq) {
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.type = 'sine';
oscillator.frequency.setValueAtTime(freq, time);
gainNode.connect(bassMasterGainRef.current as GainNode);
gainNode.gain.setValueAtTime(0, time);
gainNode.gain.linearRampToValueAtTime(0.3, time + 0.01);
gainNode.gain.linearRampToValueAtTime(0, time + 1.0);
oscillator.connect(gainNode);
oscillator.start(time);
oscillator.stop(time + 1.0);
}
}, [initAudio, bassVolume]);
const setTempoCallback = useCallback((value: React.SetStateAction<number>) => {
setTempo(prevTempo => {
const newTempo = typeof value === 'function' ? value(prevTempo) : value;
sendMessage({ type: 'tempo', payload: { tempo: newTempo } });
return newTempo;
});
}, [sendMessage]);
useEffect(() => {
if (isPlaying) {
if (timerRef.current) clearTimeout(timerRef.current);
scheduler();
} else {
if (timerRef.current) clearTimeout(timerRef.current);
stopAllBassNotes();
setCurrentStep(null);
}
return () => {
if (timerRef.current) window.clearTimeout(timerRef.current);
};
}, [isPlaying, scheduler, stopAllBassNotes]);
useEffect(() => {
if (lastMessage) {
const { type, payload } = lastMessage;
switch (type) {
case 'state':
if (payload.grid) setGrid(payload.grid);
if (payload.bassLine) setBassLine(payload.bassLine);
if (payload.tempo) setTempo(payload.tempo);
if (payload.isPlaying) setIsPlaying(payload.isPlaying);
if (payload.mutes) setMutes(payload.mutes);
if (payload.drumVolume) setDrumVolume(payload.drumVolume);
if (payload.bassVolume) setBassVolume(payload.bassVolume);
if (payload.steps) {
setSteps(payload.steps);
resizeGrid(payload.steps);
}
break;
case 'grid': setGrid(payload.grid); break;
case 'bassLine': setBassLine(payload.bassLine); break;
case 'tempo': setTempo(payload.tempo); break;
case 'playback': setIsPlaying(payload.isPlaying); break;
case 'mutes': setMutes(payload.mutes); break;
case 'drumVolume': setDrumVolume(payload.drumVolume); break;
case 'bassVolume': setBassVolume(payload.bassVolume); break;
case 'steps':
setSteps(payload.steps);
resizeGrid(payload.steps);
break;
case 'clear':
setGrid(createEmptyGrid(payload.steps));
setBassLine(createEmptyBassLine(payload.steps));
break;
case 'import':
setTempo(payload.tempo);
setSteps(payload.steps);
setGrid(payload.grid);
setMutes(payload.mutes);
setBassLine(payload.bassLine);
break;
}
}
}, [lastMessage, resizeGrid]);
useEffect(() => {
return () => {
stopAllBassNotes();
if (audioContextRef.current?.state !== 'closed') {
audioContextRef.current?.close();
}
};
}, [stopAllBassNotes]);
return {
steps,
grid,
bassLine,
isPlaying,
tempo,
currentStep,
mutes,
drumVolume,
bassVolume,
setStep,
setBassNote,
clearPattern,
setTempo: setTempoCallback,
handleStepsChange,
startPlayback,
stopPlayback,
exportBeat,
importBeat,
toggleMute,
handleDrumVolumeChange,
handleBassVolumeChange,
triggerSound,
triggerBassNote,
};
};

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;
}

View File

@@ -0,0 +1,66 @@
import { useState, useEffect, useRef, useCallback } from 'react';
export function useWebSocket(sessionId: string | null) {
const [isConnected, setIsConnected] = useState(false);
const [isSynchronized, setIsSynchronized] = useState(false);
const [lastMessage, setLastMessage] = useState<any | null>(null);
const [clientId, setClientId] = useState<string | null>(null);
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);
// Request the full session state upon connecting
socket.send(JSON.stringify({ type: 'get_state' }));
};
socket.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.type === 'welcome') {
setClientId(message.payload.clientId);
} else if (message.type === 'session_state') {
// This is the full state, apply it and mark as synchronized
setLastMessage({ type: 'state', payload: message.payload });
setIsSynchronized(true);
} else {
setLastMessage(message);
}
};
socket.onclose = () => {
console.log('WebSocket disconnected');
setIsConnected(false);
setIsSynchronized(false);
ws.current = null;
};
socket.onerror = (error) => {
console.error('WebSocket error:', error);
socket.close();
};
ws.current = socket;
}, [sessionId]);
useEffect(() => {
connect();
return () => {
ws.current?.close();
};
}, [connect]);
const sendMessage = (message: any) => {
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify(message));
}
};
return { isConnected, isSynchronized, lastMessage, sendMessage, clientId };
}

12
public/index.css Normal file
View File

@@ -0,0 +1,12 @@
/* Firefox specific styles for range input thumb */
input[type=range]::-moz-range-thumb {
background-color: #f97316; /* orange-500 */
border: none;
border-radius: 9999px; /* full rounded */
height: 16px;
width: 16px;
cursor: pointer;
}
input[type=range]#bass-volume::-moz-range-thumb {
background-color: #8b5cf6; /* purple-500 */
}

15
public/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!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>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-slate-100">
<div id="root"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>

17
public/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>
);

6
public/metadata.json Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "AG Beats",
"description": "A rhythm machine and step sequencer built with React and Tailwind CSS. Create beats, adjust the tempo, change the number of steps, and import/export your creations as JSON files.",
"requestFramePermissions": [],
"prompt": ""
}

30
public/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": {
"@/*" : ["./*"]
}
}
}

16
public/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
public/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];
};

104
server.dev.js Normal file
View File

@@ -0,0 +1,104 @@
const express = require('express');
const path = require('path');
const http = require('http');
const { WebSocketServer } = require('ws');
const { v4: uuidv4 } = require('uuid');
const app = express();
const port = process.env.PORT || 3001;
const subfolder = '/ag-beats';
const distPath = path.join(__dirname, 'dist');
// --- Helper function for random colors ---
const COLORS = ['#F94144', '#F3722C', '#F8961E', '#F9C74F', '#90BE6D', '#43AA8B', '#4D908E', '#577590', '#277DA1'];
const getRandomColor = () => COLORS[Math.floor(Math.random() * COLORS.length)];
// --- HTTP and Static Server Setup ---
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);
// --- WebSocket Server ---
const wss = new WebSocketServer({ path: '/ag-beats', server: httpServer });
const sessions = new Map();
wss.on('connection', (ws, 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)) {
sessions.set(sessionId, { clients: new Map(), state: {} });
}
const session = sessions.get(sessionId);
session.clients.set(clientId, { ws, color: clientColor });
console.log(`Client ${clientId} connected to session ${sessionId}`);
// Welcome message with client's own ID and the session's current state
ws.send(JSON.stringify({
type: 'welcome',
payload: {
clientId,
state: session.state
}
}));
// Inform all clients about the current users
const userList = Array.from(session.clients.entries()).map(([id, { color }]) => ({ id, color }));
const userUpdateMessage = JSON.stringify({ type: 'user-update', payload: { users: userList } });
session.clients.forEach(({ ws: clientWs }) => clientWs.send(userUpdateMessage));
ws.on('message', (messageBuffer) => {
const message = messageBuffer.toString();
const parsedMessage = JSON.parse(message);
// Add sender's ID to the message for client-side identification
const messageToSend = JSON.stringify({ ...parsedMessage, senderId: clientId });
// Persist state on the server, excluding cursor movements
if (parsedMessage.type !== 'cursor-move') {
session.state = { ...session.state, ...parsedMessage.payload };
}
// Broadcast to all clients in the session, including the sender
session.clients.forEach(({ ws: clientWs }) => {
if (clientWs.readyState === clientWs.OPEN) {
clientWs.send(messageToSend);
}
});
});
ws.on('close', () => {
console.log(`Client ${clientId} disconnected from session ${sessionId}`);
session.clients.delete(clientId);
if (session.clients.size === 0) {
sessions.delete(sessionId);
console.log(`Session ${sessionId} closed.`);
} else {
// Inform remaining clients that a user has left
const userList = Array.from(session.clients.keys()).map(id => ({ id, color: session.clients.get(id).color }));
const userUpdateMessage = JSON.stringify({ type: 'user-update', payload: { users: userList } });
session.clients.forEach(({ ws: clientWs }) => clientWs.send(userUpdateMessage));
}
});
ws.on('error', (error) => console.error(`WebSocket error for client ${clientId}:`, error));
});
httpServer.listen(port, '0.0.0.0', () => {
console.log(`AG Beats development server started on port ${port} with HTTP.`);
});

119
server.js Normal file
View File

@@ -0,0 +1,119 @@
const express = require('express');
const path = require('path');
const http = require('http');
const fs = require('fs');
const { WebSocketServer } = require('ws');
const { v4: uuidv4 } = require('uuid');
const { defaultState } = require('./defaultState');
const app = express();
const port = process.env.PORT || 3001;
const subfolder = '/ag-beats';
const distPath = path.join(__dirname, 'dist');
// --- Helper function for random colors ---
const COLORS = ['#F94144', '#F3722C', '#F8961E', '#F9C74F', '#90BE6D', '#43AA8B', '#4D908E', '#577590', '#277DA1'];
const getRandomColor = () => COLORS[Math.floor(Math.random() * COLORS.length)];
// --- HTTP and Static Server Setup ---
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);
// --- WebSocket Server ---
const wss = new WebSocketServer({ path: '/ag-beats', server: httpServer });
const sessions = new Map();
wss.on('connection', (ws, 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)) {
// Deep copy the default state to ensure each session has its own mutable state
const initialState = 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}`);
// Welcome message with client's own ID
ws.send(JSON.stringify({
type: 'welcome',
payload: {
clientId,
}
}));
// Inform all clients about the current users
const userList = Array.from(session.clients.entries()).map(([id, { color }]) => ({ id, color }));
const userUpdateMessage = JSON.stringify({ type: 'user-update', payload: { users: userList } });
session.clients.forEach(({ ws: clientWs }) => clientWs.send(userUpdateMessage));
ws.on('message', (messageBuffer) => {
const message = messageBuffer.toString();
const parsedMessage = JSON.parse(message);
// Handle state requests
if (parsedMessage.type === 'get_state') {
ws.send(JSON.stringify({
type: 'session_state',
payload: session.state
}));
return;
}
// Add sender's ID to the message for client-side identification
const messageToSend = JSON.stringify({ ...parsedMessage, senderId: clientId });
// Persist state on the server, excluding cursor movements
if (parsedMessage.type !== 'cursor-move') {
session.state = { ...session.state, ...parsedMessage.payload };
}
// Broadcast to all clients in the session, including the sender
session.clients.forEach(({ ws: clientWs }) => {
if (clientWs.readyState === clientWs.OPEN) {
clientWs.send(messageToSend);
}
});
});
ws.on('close', () => {
console.log(`Client ${clientId} disconnected from session ${sessionId}`);
session.clients.delete(clientId);
if (session.clients.size === 0) {
sessions.delete(sessionId);
console.log(`Session ${sessionId} closed.`);
} else {
// Inform remaining clients that a user has left
const userList = Array.from(session.clients.keys()).map(id => ({ id, color: session.clients.get(id).color }));
const userUpdateMessage = JSON.stringify({ type: 'user-update', payload: { users: userList } });
session.clients.forEach(({ ws: clientWs }) => clientWs.send(userUpdateMessage));
}
});
ws.on('error', (error) => console.error(`WebSocket error for client ${clientId}:`, error));
});
httpServer.listen(port, '0.0.0.0', () => {
console.log(`AG Beats server started on port ${port} with HTTP.`);
if (process.send) {
process.send('ready');
}
});

31
vite.config.ts Normal file
View File

@@ -0,0 +1,31 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
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'),
}
}
};
});