This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Beatsync is a high-precision web audio player for multi-device synchronized playback. Turborepo monorepo with three packages:
apps/client: Next.js 15 (App Router, React 19, Tailwind v4, Shadcn/ui)apps/server: Bun HTTP + WebSocket server (nativeBun.serve, not Hono routing)packages/shared: Zod schemas shared across client/server (@beatsync/shared)
bun install # Install all dependencies (run from root)
bun dev # Start both client and server (Turborepo)
bun client # Client only (port 3000)
bun server # Server only (port 8080)
bun build # Build all packages
# Server-specific (run from apps/server/)
bun test # Run tests (Bun test runner)
bun test --watch # Watch mode
bun run cleanup # Dry-run orphaned R2 room cleanup
bun run cleanup:live # Delete orphaned R2 rooms
bun run type-check # tsc --noEmit
# Client-specific (run from apps/client/)
bun lint # next lintThe server uses a manager pattern with in-memory state (no database):
GlobalManager(singleton): Manages all rooms. Accessed viaGlobalManager.rooms. Caches active user count with dirty flag.RoomManager(per-room): Owns clients, audio sources, playback state, spatial audio config, chat. Handles audio loading coordination and synchronized play scheduling.ChatManager(per-room, owned by RoomManager): Message history with incremental IDs.BackupManager(singleton): Periodic state backup/restore to R2 (every 60s). Restores on startup.MusicProviderManager: External music search and streaming integration.
All WebSocket messages are validated with Zod discriminated unions. The flow:
- Client connects →
handleOpen()subscribes to room topic, sends initial room state - Incoming messages validated against
WSRequestSchema→ dispatched viaWebsocketRegistry(type-safe handler map inapps/server/src/websocket/registry.ts) - Each handler is a separate file in
apps/server/src/websocket/handlers/ - Server responses are three categories defined in
packages/shared/types/:WSBroadcast: Sent to all room clients (room events, scheduled actions, stream updates)WSUnicast: Sent to a single client (NTP responses, search results)WSResponse: Union of broadcast + unicast
Adding a new WebSocket message type requires: adding to ClientActionEnum in packages/shared/types/WSRequest.ts, creating a schema, adding a handler file, and registering it in the registry.
NTP-inspired protocol for millisecond-accurate cross-device playback:
- Client sends
NTP_REQUESTwitht0→ server stampst1/t2→ client receives att3 - Exponential moving average smoothing (α=0.2) for RTT estimation
- Minimum 10 measurements before "synced" state
- Play/pause commands are scheduled actions: server broadcasts
serverTimeToExecuteand clients execute at that synchronized moment, using max client RTT to calculate delay
Three-step upload flow (client uploads directly to R2, no server bandwidth used):
POST /upload/get-presigned-url→ server generates presigned R2 PUT URL- Client PUTs file directly to R2
POST /upload/complete→ server adds to room's audio sources, broadcasts update
R2 key structure: room-{roomId}/{sanitized-name}☆{timestamp}.{ext}
Utilities: apps/server/src/lib/r2.ts (presigned URLs, public URLs, batch delete, orphan cleanup), apps/server/src/utils/responses.ts (CORS headers, error/success response helpers).
Three Zustand stores in apps/client/src/store/:
global.tsx: Main store (~1500 lines). Audio sources, WebSocket connection, NTP sync state, spatial audio, playback state, volume, search results, stream jobs. Uses LRU buffer cache (max 3 audio buffers).room.tsx: Room metadata (roomId, username, loading state)chat.tsx: Chat messages
HTTP data fetching uses Axios + TanStack React Query. WebSocket message utilities in apps/client/src/utils/ws.ts.
When play is requested, the server doesn't immediately schedule playback. Instead:
- Server broadcasts
LOAD_AUDIO_SOURCEto all clients - Clients load/decode the audio and respond with
AUDIO_SOURCE_LOADED - Server waits for all clients (or 3s timeout) then schedules synchronized play
Grid-based positioning system where clients are placed on a grid. A "listening source" position determines gain per client using distance calculations. Server broadcasts spatial gain config at 100ms intervals. Client applies: effectiveGain = globalVolume × spatialGain.
apps/client/.env:
NEXT_PUBLIC_API_URL=http://localhost:8080
NEXT_PUBLIC_WS_URL=ws://localhost:8080/ws
apps/server/.env:
S3_BUCKET_NAME=
S3_PUBLIC_URL=
S3_ENDPOINT=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
- Docker: Multi-stage build with
oven/bun:1. Exposes port 8080. Entry:bun start. - PM2: Config in
pm2.config.js. Process name:beatsync-server. - Server has graceful shutdown (SIGTERM/SIGINT) that backs up state to R2 before exit.
- No testing framework on the client; server uses
bun testwith sinon for stubs - Server uses native
Bun.serve()with URL pathname switch routing (not Hono's router) - Room IDs are 6-digit codes
- Room cleanup: 60s after last client disconnects, room is deleted
- Admin auto-promotion: if last admin leaves, a random client is promoted