Initial commit
This commit is contained in:
75
.context/Autosave Adjustment.md
Normal file
75
.context/Autosave Adjustment.md
Normal 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|
|
||||||
40
.context/Local Storage Autosave.md
Normal file
40
.context/Local Storage Autosave.md
Normal 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 `750–1500ms` 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
|
||||||
82
.context/Manual Session State Save.md
Normal file
82
.context/Manual Session State Save.md
Normal 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
76
.context/task.md
Normal 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
103
.context/task2.md
Normal 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 2’s 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`, you’re already using a `useWebSocket` hook — you’ll 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
24
.gitignore
vendored
Normal 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
22
README.md
Normal 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
24
defaultState.js
Normal 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
23
docker-compose.yml
Normal 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
11
ecosystem.config.js
Normal 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
2520
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
package.json
Normal file
27
package.json
Normal 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
101
public/App.tsx
Normal 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;
|
||||||
37
public/components/Modal.tsx
Normal file
37
public/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>
|
||||||
|
);
|
||||||
|
};
|
||||||
350
public/components/Sequencer.tsx
Normal file
350
public/components/Sequencer.tsx
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
|
||||||
|
import React, { useRef, useState, useEffect } from 'react';
|
||||||
|
import { INSTRUMENTS, BASS_NOTES, MIN_TEMPO, MAX_TEMPO, MIN_STEPS, MAX_STEPS } from '../constants';
|
||||||
|
import { PlayIcon, StopIcon, ClearIcon, UploadIcon, DownloadIcon, PlusIcon, MinusIcon, MuteIcon, UnmuteIcon } from './icons';
|
||||||
|
import { Grid, BassLineGrid } from '../types';
|
||||||
|
import { Modal } from './Modal';
|
||||||
|
|
||||||
|
interface SequencerProps {
|
||||||
|
steps: number;
|
||||||
|
grid: Grid;
|
||||||
|
bassLine: BassLineGrid;
|
||||||
|
isPlaying: boolean;
|
||||||
|
tempo: number;
|
||||||
|
currentStep: number | null;
|
||||||
|
mutes: boolean[];
|
||||||
|
drumVolume: number;
|
||||||
|
bassVolume: number;
|
||||||
|
setStep: (instrumentIndex: number, stepIndex: number, isActive: boolean) => void;
|
||||||
|
setBassNote: (stepIndex: number, note: string) => void;
|
||||||
|
clearPattern: () => void;
|
||||||
|
setTempo: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
handleStepsChange: (newSteps: number) => void;
|
||||||
|
startPlayback: () => Promise<void>;
|
||||||
|
stopPlayback: () => void;
|
||||||
|
exportBeat: () => void;
|
||||||
|
importBeat: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
toggleMute: (instrumentIndex: number) => void;
|
||||||
|
handleDrumVolumeChange: (newVolume: number) => void;
|
||||||
|
handleBassVolumeChange: (newVolume: number) => void;
|
||||||
|
triggerSound: (instrumentName: string) => void;
|
||||||
|
triggerBassNote: (note: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SequencerButton: React.FC<{ onClick?: () => void; children: React.ReactNode; className?: string; disabled?: boolean }> = ({ onClick, children, className, disabled }) => (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`flex items-center justify-center gap-2 px-4 py-2 text-sm font-semibold text-slate-700 bg-white border border-slate-300 rounded-md shadow-sm hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-all ${className}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Sequencer: React.FC<SequencerProps> = ({
|
||||||
|
steps, grid, isPlaying, tempo, currentStep, mutes, bassLine,
|
||||||
|
drumVolume, bassVolume,
|
||||||
|
clearPattern, setTempo, handleStepsChange, setStep, setBassNote,
|
||||||
|
startPlayback, stopPlayback, exportBeat, importBeat, toggleMute,
|
||||||
|
handleDrumVolumeChange, handleBassVolumeChange, triggerSound, triggerBassNote
|
||||||
|
}) => {
|
||||||
|
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [dragActivationState, setDragActivationState] = useState(false);
|
||||||
|
const [dragStartRow, setDragStartRow] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const [isBassDragging, setIsBassDragging] = useState(false);
|
||||||
|
const [bassDragActivationState, setBassDragActivationState] = useState(false);
|
||||||
|
const [isClearModalOpen, setIsClearModalOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setIsDragging(false);
|
||||||
|
setDragStartRow(null);
|
||||||
|
setIsBassDragging(false);
|
||||||
|
};
|
||||||
|
window.addEventListener('mouseup', handleMouseUp);
|
||||||
|
return () => window.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
const handleImportClick = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCellMouseDown = (instIndex: number, stepIndex: number) => {
|
||||||
|
setIsDragging(true);
|
||||||
|
setDragStartRow(instIndex);
|
||||||
|
const newState = !grid[instIndex]?.[stepIndex];
|
||||||
|
setDragActivationState(newState);
|
||||||
|
setStep(instIndex, stepIndex, newState);
|
||||||
|
if (newState && !isPlaying) {
|
||||||
|
triggerSound(INSTRUMENTS[instIndex].name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCellMouseEnter = (instIndex: number, stepIndex: number) => {
|
||||||
|
if (isDragging && instIndex === dragStartRow) {
|
||||||
|
setStep(instIndex, stepIndex, dragActivationState);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBassCellMouseDown = (noteName: string, stepIndex: number) => {
|
||||||
|
const isCurrentlyActive = bassLine[stepIndex]?.includes(noteName);
|
||||||
|
const newState = !isCurrentlyActive;
|
||||||
|
setBassNote(stepIndex, noteName);
|
||||||
|
setIsBassDragging(true);
|
||||||
|
setBassDragActivationState(newState);
|
||||||
|
if (newState && !isPlaying) {
|
||||||
|
triggerBassNote(noteName);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBassCellMouseEnter = (noteName: string, stepIndex: number) => {
|
||||||
|
if (isBassDragging) {
|
||||||
|
const isCurrentlyActive = bassLine[stepIndex]?.includes(noteName);
|
||||||
|
if (bassDragActivationState !== isCurrentlyActive) {
|
||||||
|
setBassNote(stepIndex, noteName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const incrementTempo = () => setTempo(t => Math.min(MAX_TEMPO, t + 1));
|
||||||
|
const decrementTempo = () => setTempo(t => Math.max(MIN_TEMPO, t - 1));
|
||||||
|
|
||||||
|
const handleTempoScroll = (e: React.WheelEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.deltaY < 0) {
|
||||||
|
decrementTempo();
|
||||||
|
} else {
|
||||||
|
incrementTempo();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const incrementSteps = () => {
|
||||||
|
const newSteps = steps + 4;
|
||||||
|
if (newSteps <= MAX_STEPS) {
|
||||||
|
handleStepsChange(newSteps);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const decrementSteps = () => {
|
||||||
|
const newSteps = steps - 4;
|
||||||
|
if (newSteps >= MIN_STEPS) {
|
||||||
|
handleStepsChange(newSteps);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearConfirm = () => {
|
||||||
|
clearPattern();
|
||||||
|
setIsClearModalOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal
|
||||||
|
isOpen={isClearModalOpen}
|
||||||
|
onClose={() => setIsClearModalOpen(false)}
|
||||||
|
onConfirm={handleClearConfirm}
|
||||||
|
title="Clear Session"
|
||||||
|
>
|
||||||
|
<p>Are you sure you want to clear the session? This will erase all current progress.</p>
|
||||||
|
</Modal>
|
||||||
|
<div className={`bg-white p-4 sm:p-6 rounded-xl shadow-lg border border-slate-200 ${isDragging || isBassDragging ? 'select-none' : ''}`}>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4 mb-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!isPlaying ? (
|
||||||
|
<SequencerButton onClick={startPlayback} title="Play">
|
||||||
|
<PlayIcon className="w-5 h-5" /> <span className="hidden lg:inline">Play</span>
|
||||||
|
</SequencerButton>
|
||||||
|
) : (
|
||||||
|
<SequencerButton onClick={stopPlayback} title="Stop">
|
||||||
|
<StopIcon className="w-5 h-5" /> <span className="hidden lg:inline">Stop</span>
|
||||||
|
</SequencerButton>
|
||||||
|
)}
|
||||||
|
<SequencerButton onClick={() => setIsClearModalOpen(true)} title="Clear">
|
||||||
|
<ClearIcon className="w-5 h-5" /> <span className="hidden lg:inline">Clear</span>
|
||||||
|
</SequencerButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-x-6 gap-y-2 flex-wrap justify-center">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label htmlFor="tempo-display" className="text-sm font-medium text-slate-600">Tempo</label>
|
||||||
|
<div
|
||||||
|
className="flex items-center border border-slate-300 rounded-md shadow-sm"
|
||||||
|
onWheel={handleTempoScroll}
|
||||||
|
title="Scroll to adjust tempo"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={decrementTempo}
|
||||||
|
disabled={tempo <= MIN_TEMPO}
|
||||||
|
className="p-2 text-slate-700 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed rounded-l-md focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-1"
|
||||||
|
aria-label="Decrease tempo"
|
||||||
|
>
|
||||||
|
<MinusIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
id="tempo-display"
|
||||||
|
className="px-3 py-2 text-sm font-mono text-slate-700 w-24 text-center border-l border-r border-slate-300 bg-white"
|
||||||
|
>
|
||||||
|
{tempo} BPM
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={incrementTempo}
|
||||||
|
disabled={tempo >= MAX_TEMPO}
|
||||||
|
className="p-2 text-slate-700 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed rounded-r-md focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-1"
|
||||||
|
aria-label="Increase tempo"
|
||||||
|
>
|
||||||
|
<PlusIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label htmlFor="steps-display" className="text-sm font-medium text-slate-600">Steps</label>
|
||||||
|
<div className="flex items-center border border-slate-300 rounded-md shadow-sm">
|
||||||
|
<button
|
||||||
|
onClick={decrementSteps}
|
||||||
|
disabled={steps <= MIN_STEPS}
|
||||||
|
className="p-2 text-slate-700 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed rounded-l-md focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-1"
|
||||||
|
aria-label="Decrease steps by one tact (4 steps)"
|
||||||
|
>
|
||||||
|
<MinusIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
id="steps-display"
|
||||||
|
className="px-3 py-2 text-sm font-mono text-slate-700 w-16 text-center border-l border-r border-slate-300 bg-white"
|
||||||
|
>
|
||||||
|
{steps}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={incrementSteps}
|
||||||
|
disabled={steps >= MAX_STEPS}
|
||||||
|
className="p-2 text-slate-700 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed rounded-r-md focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-1"
|
||||||
|
aria-label="Increase steps by one tact (4 steps)"
|
||||||
|
>
|
||||||
|
<PlusIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<SequencerButton onClick={handleImportClick} title="Import">
|
||||||
|
<UploadIcon className="w-5 h-5" /> <span className="hidden lg:inline">Import</span>
|
||||||
|
</SequencerButton>
|
||||||
|
<input type="file" ref={fileInputRef} onChange={importBeat} accept=".json" className="hidden" />
|
||||||
|
<SequencerButton onClick={exportBeat} title="Export">
|
||||||
|
<DownloadIcon className="w-5 h-5" /> <span className="hidden lg:inline">Export</span>
|
||||||
|
</SequencerButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex flex-col items-center gap-2 pt-10">
|
||||||
|
<input
|
||||||
|
id="drum-volume"
|
||||||
|
type="range" min="0" max="1.5" step="0.01" value={drumVolume}
|
||||||
|
onChange={e => handleDrumVolumeChange(Number(e.target.value))}
|
||||||
|
className="w-4 h-48 appearance-none cursor-pointer bg-slate-200 rounded-lg [writing-mode:vertical-lr] [direction:rtl] accent-orange-500"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
/>
|
||||||
|
<label htmlFor="drum-volume" className="text-xs font-medium text-slate-500 tracking-wider">DRUMS</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-x-auto pb-2">
|
||||||
|
<div className="grid gap-1" style={{ gridTemplateColumns: `100px repeat(${steps}, 1fr)` }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="sticky left-0 bg-white z-10"></div>
|
||||||
|
{Array.from({ length: steps }, (_, i) => (
|
||||||
|
<div key={`header-${i}`} className="text-center text-xs text-slate-400">
|
||||||
|
{(i % 4 === 0) ? (i/4 + 1) : ''}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{/* Grid Rows */}
|
||||||
|
{INSTRUMENTS.map((instrument, instIndex) => (
|
||||||
|
<div className="contents group" key={instrument.name}>
|
||||||
|
<div className={`sticky left-0 bg-white flex items-center justify-end pr-2 py-1 transition-opacity z-10 ${mutes[instIndex] ? 'opacity-60' : ''}`}>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleMute(instIndex)}
|
||||||
|
className="mr-1 p-1 rounded-full text-slate-400 hover:bg-slate-200 hover:text-slate-600 focus:ring-2 focus:ring-orange-500 transition-opacity"
|
||||||
|
aria-label={mutes[instIndex] ? `Unmute ${instrument.name}` : `Mute ${instrument.name}`}
|
||||||
|
>
|
||||||
|
{mutes[instIndex] ? <MuteIcon className="w-4 h-4" /> : <UnmuteIcon className="w-4 h-4 opacity-0 group-hover:opacity-100 focus:opacity-100" />}
|
||||||
|
</button>
|
||||||
|
<span className="text-xs sm:text-sm font-bold text-slate-600 tracking-wider text-right">{instrument.name}</span>
|
||||||
|
</div>
|
||||||
|
{grid[instIndex]?.map((isActive, stepIndex) => {
|
||||||
|
const isCurrent = currentStep === stepIndex;
|
||||||
|
const isFourth = stepIndex % 4 === 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${instrument.name}-${stepIndex}`}
|
||||||
|
className={`w-full aspect-square rounded-md cursor-pointer transition-all duration-100 border ${isFourth ? 'border-l-slate-300' : 'border-l-transparent'} min-w-0
|
||||||
|
${isActive ? 'bg-orange-500 border-orange-600' : 'bg-slate-200 hover:bg-slate-300 border-slate-300'}
|
||||||
|
${isCurrent ? 'ring-2 ring-blue-500 ring-offset-1' : ''}
|
||||||
|
${mutes[instIndex] ? 'opacity-60' : ''}`}
|
||||||
|
onMouseDown={() => handleCellMouseDown(instIndex, stepIndex)}
|
||||||
|
onMouseEnter={() => handleCellMouseEnter(instIndex, stepIndex)}
|
||||||
|
onDragStart={(e) => e.preventDefault()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Bass Sequencer */}
|
||||||
|
<div className="mt-8 pt-6 border-t border-slate-200 flex items-start gap-4">
|
||||||
|
<div className="flex flex-col items-center gap-2 pt-10">
|
||||||
|
<input
|
||||||
|
id="bass-volume"
|
||||||
|
type="range" min="0" max="1" step="0.01" value={bassVolume}
|
||||||
|
onChange={e => handleBassVolumeChange(Number(e.target.value))}
|
||||||
|
className="w-4 h-48 appearance-none cursor-pointer bg-slate-200 rounded-lg [writing-mode:vertical-lr] [direction:rtl] accent-purple-500"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
/>
|
||||||
|
<label htmlFor="bass-volume" className="text-xs font-medium text-slate-500 tracking-wider">BASS</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-x-auto pb-2">
|
||||||
|
<div className="grid gap-px" style={{ gridTemplateColumns: `100px repeat(${steps}, 1fr)` }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="sticky left-0 bg-white z-10"></div>
|
||||||
|
{Array.from({ length: steps }, (_, i) => (
|
||||||
|
<div key={`bass-header-${i}`} className="text-center text-xs text-slate-400 h-4 flex items-end justify-center">
|
||||||
|
{(i % 4 === 0) ? <span className="mb-1">{(i/4 + 1)}</span> : ''}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{/* Bass Grid Rows (reversed to have low notes at the bottom) */}
|
||||||
|
{[...BASS_NOTES].reverse().map((note) => (
|
||||||
|
<div className="contents" key={note.name}>
|
||||||
|
<div className={`sticky left-0 bg-white flex items-center justify-end pr-2 py-0 z-10 ${note.isSharp ? 'bg-slate-100' : 'bg-white'}`}>
|
||||||
|
<span className={`text-xs font-mono tracking-wider text-right ${note.isSharp ? 'text-slate-500' : 'text-slate-700 font-bold'}`}>{note.name}</span>
|
||||||
|
</div>
|
||||||
|
{Array.from({ length: steps }).map((_, stepIndex) => {
|
||||||
|
const isSelected = bassLine[stepIndex]?.includes(note.name);
|
||||||
|
const isCurrent = currentStep === stepIndex;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${note.name}-${stepIndex}`}
|
||||||
|
className={`w-full h-5 cursor-pointer transition-colors duration-100 relative
|
||||||
|
${isSelected ? 'bg-purple-500' : note.isSharp ? 'bg-slate-200 hover:bg-slate-300' : 'bg-slate-100 hover:bg-slate-300'}
|
||||||
|
`}
|
||||||
|
onMouseDown={() => handleBassCellMouseDown(note.name, stepIndex)}
|
||||||
|
onMouseEnter={() => handleBassCellMouseEnter(note.name, stepIndex)}
|
||||||
|
onDragStart={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
{isCurrent && <div className="absolute inset-0 bg-blue-500/30"></div>}
|
||||||
|
{stepIndex > 0 && stepIndex % 4 === 0 && <div className="absolute inset-y-0 left-0 w-px bg-slate-300"></div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
70
public/components/icons.tsx
Normal file
70
public/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
public/constants.ts
Normal file
39
public/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 = 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
96
public/hooks/useCursors.ts
Normal file
96
public/hooks/useCursors.ts
Normal 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;
|
||||||
|
}
|
||||||
616
public/hooks/useDrumMachine.ts
Normal file
616
public/hooks/useDrumMachine.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
24
public/hooks/useSession.ts
Normal file
24
public/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;
|
||||||
|
}
|
||||||
66
public/hooks/useWebSocket.ts
Normal file
66
public/hooks/useWebSocket.ts
Normal 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
12
public/index.css
Normal 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
15
public/index.html
Normal 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
17
public/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>
|
||||||
|
);
|
||||||
6
public/metadata.json
Normal file
6
public/metadata.json
Normal 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
30
public/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": {
|
||||||
|
"@/*" : ["./*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
public/types.ts
Normal file
16
public/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
public/utils.ts
Normal file
21
public/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];
|
||||||
|
};
|
||||||
104
server.dev.js
Normal file
104
server.dev.js
Normal 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
119
server.js
Normal 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
31
vite.config.ts
Normal 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'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user