├── .gitignore ├── LICENSE ├── README.md ├── apps ├── client │ ├── .cursor │ │ └── rules │ │ │ └── posthog-integration.mdc │ ├── .gitignore │ ├── README.md │ ├── bun.lock │ ├── components.json │ ├── eslint.config.mjs │ ├── next.config.ts │ ├── package.json │ ├── postcss.config.mjs │ ├── public │ │ ├── DROELOE x San Holo - Lines of the Broken (ft. CUT).mp3 │ │ ├── INZO x ILLUSIO - Just A Mirage.mp3 │ │ ├── Illenium - Fractures (feat. Nevve).mp3 │ │ ├── Jacob Tillberg - Feel You.mp3 │ │ ├── STVCKS - Don't Be Scared.mp3 │ │ ├── Tom Reev, Assix & Jason Gewalt - Where It Hurts.mp3 │ │ ├── chess.mp3 │ │ ├── file.svg │ │ ├── globe.svg │ │ ├── joyful - chess (slowed).mp3 │ │ ├── next.svg │ │ ├── trndsttr.mp3 │ │ ├── vercel.svg │ │ ├── window.svg │ │ └── wonder.mp3 │ ├── src │ │ ├── app │ │ │ ├── favicon.ico │ │ │ ├── globals.css │ │ │ ├── icon.svg │ │ │ ├── layout.tsx │ │ │ ├── page.tsx │ │ │ └── room │ │ │ │ └── [roomId] │ │ │ │ └── page.tsx │ │ ├── components │ │ │ ├── AudioUploaderMinimal.tsx │ │ │ ├── IPFinder.tsx │ │ │ ├── Join.tsx │ │ │ ├── NewSyncer.tsx │ │ │ ├── NudgeControls.tsx │ │ │ ├── PostHogProvider.tsx │ │ │ ├── Queue.tsx │ │ │ ├── TimingDisplay.tsx │ │ │ ├── TrackSelector.tsx │ │ │ ├── UploadHistory.tsx │ │ │ ├── dashboard │ │ │ │ ├── AudioControls.tsx │ │ │ │ ├── Bottom.tsx │ │ │ │ ├── Dashboard.tsx │ │ │ │ ├── GainMeter.tsx │ │ │ │ ├── Left.tsx │ │ │ │ ├── Main.tsx │ │ │ │ └── Right.tsx │ │ │ ├── room │ │ │ │ ├── MusicControls.tsx │ │ │ │ ├── NTP.tsx │ │ │ │ ├── Player.tsx │ │ │ │ ├── RoomInfo.tsx │ │ │ │ ├── SocketStatus.tsx │ │ │ │ ├── SpatialAudioBackground.tsx │ │ │ │ ├── TopBar.tsx │ │ │ │ ├── UserGrid.tsx │ │ │ │ ├── WebSocketManager.tsx │ │ │ │ └── scrollbar.css │ │ │ └── ui │ │ │ │ ├── SyncProgress.tsx │ │ │ │ ├── accordion.tsx │ │ │ │ ├── avatar.tsx │ │ │ │ ├── badge.tsx │ │ │ │ ├── button.tsx │ │ │ │ ├── card.tsx │ │ │ │ ├── dropdown-menu.tsx │ │ │ │ ├── input-otp.tsx │ │ │ │ ├── input.tsx │ │ │ │ ├── label.tsx │ │ │ │ ├── old-slider.tsx │ │ │ │ ├── popover.tsx │ │ │ │ ├── select.tsx │ │ │ │ ├── separator.tsx │ │ │ │ ├── skeleton.tsx │ │ │ │ ├── slider.tsx │ │ │ │ ├── sonner.tsx │ │ │ │ ├── switch.tsx │ │ │ │ ├── tabs.tsx │ │ │ │ └── tooltip.tsx │ │ ├── lib │ │ │ ├── api.ts │ │ │ ├── audio.ts │ │ │ ├── localTypes.ts │ │ │ ├── randomNames.ts │ │ │ ├── room.ts │ │ │ └── utils.ts │ │ ├── store │ │ │ ├── global.tsx │ │ │ └── room.tsx │ │ └── utils │ │ │ ├── ntp.ts │ │ │ ├── time.ts │ │ │ └── ws.ts │ └── tsconfig.json └── server │ ├── .gitignore │ ├── README.md │ ├── bun.lock │ ├── package.json │ ├── src │ ├── config.ts │ ├── index.ts │ ├── roomManager.ts │ ├── routes │ │ ├── audio.ts │ │ ├── root.ts │ │ ├── stats.ts │ │ ├── upload.ts │ │ ├── websocket.ts │ │ └── websocketHandlers.ts │ ├── spatial.ts │ └── utils │ │ ├── responses.ts │ │ ├── spatial.ts │ │ └── websocket.ts │ └── tsconfig.json ├── bun.lock ├── package.json ├── packages └── shared │ ├── .gitignore │ ├── README.md │ ├── bun.lock │ ├── index.ts │ ├── package.json │ ├── tsconfig.json │ └── types │ ├── HTTPRequest.ts │ ├── WSBroadcast.ts │ ├── WSRequest.ts │ ├── WSResponse.ts │ ├── WSUnicast.ts │ ├── basic.ts │ └── index.ts ├── tsconfig.json ├── turbo.json └── vercel.json /.gitignore: -------------------------------------------------------------------------------- 1 | *.ctx.md 2 | 3 | # Add .turbo to your .gitignore file. The turbo CLI uses these folders for persisting logs, outputs, and other functionality. 4 | .turbo 5 | node_modules 6 | 7 | # uploads in server 8 | apps/server/uploads/* 9 | .DS_Store 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 freeman-jiang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Beatsync 2 | 3 | Beatsync is a high-precision web audio player built for multi-device playback. The official app is [beatsync.gg](https://www.beatsync.gg/). 4 | 5 | https://github.com/user-attachments/assets/2aa385a7-2a07-4ab5-80b1-fda553efc57b 6 | 7 | ## Features 8 | 9 | - **Millisecond-accurate synchronization**: Abstracts [NTP-inspired](https://en.wikipedia.org/wiki/Network_Time_Protocol) time synchronization primitives to achieve a high degree of accuracy 10 | - **Cross-platform**: Works on any device with a modern browser (Chrome recommended for best performance) 11 | - **Spatial audio:** Allows controlling device volumes through a virtual listening source for interesting sonic effects 12 | - **Polished interface**: Smooth loading states, status indicators, and all UI elements come built-in 13 | - **Self-hostable**: Run your own instance with a few commands 14 | 15 | 16 | > [!NOTE] 17 | > Beatsync is in early development. Mobile support is working, but experimental. Please consider creating an issue or contributing with a PR if you run into problems! 18 | 19 | ## Quickstart 20 | 21 | This project uses [Turborepo](https://turbo.build/repo). 22 | 23 | Fill in the `.env` file in `apps/client` with the following: 24 | 25 | ```sh 26 | NEXT_PUBLIC_API_URL=http://localhost:8080 27 | NEXT_PUBLIC_WS_URL=ws://localhost:8080/ws 28 | ``` 29 | 30 | Run the following commands to start the server and client: 31 | 32 | ```sh 33 | bun install # installs once for all workspaces 34 | bun dev # starts both client (:3000) and server (:8080) 35 | ``` 36 | 37 | | Directory | Purpose | 38 | | ----------------- | -------------------------------------------------------------- | 39 | | `apps/server` | Bun HTTP + WebSocket server | 40 | | `apps/client` | Next.js frontend with Tailwind & Shadcn/ui | 41 | | `packages/shared` | Type-safe schemas and functions shared between client & server | 42 | -------------------------------------------------------------------------------- /apps/client/.cursor/rules/posthog-integration.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: apply when interacting with PostHog/analytics tasks 3 | globs: 4 | alwaysApply: true 5 | --- 6 | 7 | Never hallucinate an API key. Instead, always use the API key populated in the .env file. 8 | 9 | # Feature flags 10 | 11 | A given feature flag should be used in as few places as possible. Do not increase the risk of undefined behavior by scattering the same feature flag across multiple areas of code. If the same feature flag needs to be introduced at multiple callsites, flag this for the developer to inspect carefully. 12 | 13 | If a job requires creating new feature flag names, make them as clear and descriptive as possible. 14 | 15 | If using TypeScript, use an enum to store flag names. If using JavaScript, store flag names as strings to an object declared as a constant, to simulate an enum. Use a consistent naming convention for this storage. enum/const object members should be written UPPERCASE_WITH_UNDERSCORE. 16 | 17 | Gate flag-dependent code on a check that verifies the flag's values are valid and expected. 18 | 19 | # Custom properties 20 | 21 | If a custom property for a person or event is at any point referenced in two or more files or two or more callsites in the same file, use an enum or const object, as above in feature flags. 22 | 23 | # Naming 24 | 25 | Before creating any new event or property names, consult with the developer for any existing naming convention. Consistency in naming is essential, and additional context may exist outside this project. Similarly, be careful about any changes to existing event and property names, as this may break reporting and distort data for the project. 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /apps/client/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | 43 | .env.local -------------------------------------------------------------------------------- /apps/client/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. 37 | -------------------------------------------------------------------------------- /apps/client/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "src/app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /apps/client/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends("next/core-web-vitals", "next/typescript"), 14 | ]; 15 | 16 | export default eslintConfig; 17 | -------------------------------------------------------------------------------- /apps/client/next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | /* config options here */ 5 | async rewrites() { 6 | return [ 7 | { 8 | source: "/ingest/static/:path*", 9 | destination: "https://us-assets.i.posthog.com/static/:path*", 10 | }, 11 | { 12 | source: "/ingest/:path*", 13 | destination: "https://us.i.posthog.com/:path*", 14 | }, 15 | { 16 | source: "/ingest/decide", 17 | destination: "https://us.i.posthog.com/decide", 18 | }, 19 | ]; 20 | }, 21 | // This is required to support PostHog trailing slash API requests 22 | skipTrailingSlashRedirect: true, 23 | }; 24 | 25 | export default nextConfig; 26 | -------------------------------------------------------------------------------- /apps/client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@radix-ui/react-accordion": "^1.2.4", 13 | "@radix-ui/react-avatar": "^1.1.3", 14 | "@radix-ui/react-dropdown-menu": "^2.1.7", 15 | "@radix-ui/react-label": "^2.1.2", 16 | "@radix-ui/react-popover": "^1.1.6", 17 | "@radix-ui/react-select": "^2.1.6", 18 | "@radix-ui/react-separator": "^1.1.3", 19 | "@radix-ui/react-slider": "^1.2.4", 20 | "@radix-ui/react-slot": "^1.1.2", 21 | "@radix-ui/react-switch": "^1.2.2", 22 | "@radix-ui/react-tabs": "^1.1.4", 23 | "@radix-ui/react-tooltip": "^1.2.0", 24 | "@tanstack/react-query": "^5.67.3", 25 | "@types/throttle-debounce": "^5.0.2", 26 | "@vercel/analytics": "^1.5.0", 27 | "axios": "^1.8.2", 28 | "class-variance-authority": "^0.7.1", 29 | "clsx": "^2.1.1", 30 | "input-otp": "^1.4.2", 31 | "lucide-react": "^0.477.0", 32 | "motion": "^12.9.2", 33 | "next": "15.2.1", 34 | "next-themes": "^0.4.6", 35 | "posthog-js": "^1.236.7", 36 | "posthog-node": "^4.14.0", 37 | "react": "^19.0.0", 38 | "react-dom": "^19.0.0", 39 | "react-hook-form": "^7.54.2", 40 | "react-icons": "^5.5.0", 41 | "sonner": "^2.0.1", 42 | "tailwind-merge": "^3.0.2", 43 | "tailwindcss-animate": "^1.0.7", 44 | "throttle-debounce": "^5.0.2", 45 | "zod": "^3.24.2", 46 | "zustand": "^5.0.3" 47 | }, 48 | "devDependencies": { 49 | "typescript": "^5", 50 | "@types/node": "^20", 51 | "@types/react": "^19", 52 | "@types/react-dom": "^19", 53 | "@tailwindcss/postcss": "^4", 54 | "tailwindcss": "^4", 55 | "eslint": "^9", 56 | "eslint-config-next": "15.2.1", 57 | "@eslint/eslintrc": "^3" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /apps/client/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: ["@tailwindcss/postcss"], 3 | }; 4 | 5 | export default config; 6 | -------------------------------------------------------------------------------- /apps/client/public/DROELOE x San Holo - Lines of the Broken (ft. CUT).mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeman-jiang/beatsync/025073bebdaf4d930269581ba3b174ff0ae358bb/apps/client/public/DROELOE x San Holo - Lines of the Broken (ft. CUT).mp3 -------------------------------------------------------------------------------- /apps/client/public/INZO x ILLUSIO - Just A Mirage.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeman-jiang/beatsync/025073bebdaf4d930269581ba3b174ff0ae358bb/apps/client/public/INZO x ILLUSIO - Just A Mirage.mp3 -------------------------------------------------------------------------------- /apps/client/public/Illenium - Fractures (feat. Nevve).mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeman-jiang/beatsync/025073bebdaf4d930269581ba3b174ff0ae358bb/apps/client/public/Illenium - Fractures (feat. Nevve).mp3 -------------------------------------------------------------------------------- /apps/client/public/Jacob Tillberg - Feel You.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeman-jiang/beatsync/025073bebdaf4d930269581ba3b174ff0ae358bb/apps/client/public/Jacob Tillberg - Feel You.mp3 -------------------------------------------------------------------------------- /apps/client/public/STVCKS - Don't Be Scared.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeman-jiang/beatsync/025073bebdaf4d930269581ba3b174ff0ae358bb/apps/client/public/STVCKS - Don't Be Scared.mp3 -------------------------------------------------------------------------------- /apps/client/public/Tom Reev, Assix & Jason Gewalt - Where It Hurts.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeman-jiang/beatsync/025073bebdaf4d930269581ba3b174ff0ae358bb/apps/client/public/Tom Reev, Assix & Jason Gewalt - Where It Hurts.mp3 -------------------------------------------------------------------------------- /apps/client/public/chess.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeman-jiang/beatsync/025073bebdaf4d930269581ba3b174ff0ae358bb/apps/client/public/chess.mp3 -------------------------------------------------------------------------------- /apps/client/public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/client/public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/client/public/joyful - chess (slowed).mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeman-jiang/beatsync/025073bebdaf4d930269581ba3b174ff0ae358bb/apps/client/public/joyful - chess (slowed).mp3 -------------------------------------------------------------------------------- /apps/client/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/client/public/trndsttr.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeman-jiang/beatsync/025073bebdaf4d930269581ba3b174ff0ae358bb/apps/client/public/trndsttr.mp3 -------------------------------------------------------------------------------- /apps/client/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/client/public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/client/public/wonder.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeman-jiang/beatsync/025073bebdaf4d930269581ba3b174ff0ae358bb/apps/client/public/wonder.mp3 -------------------------------------------------------------------------------- /apps/client/src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeman-jiang/beatsync/025073bebdaf4d930269581ba3b174ff0ae358bb/apps/client/src/app/favicon.ico -------------------------------------------------------------------------------- /apps/client/src/app/globals.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | @plugin "tailwindcss-animate"; 4 | 5 | @custom-variant dark (&:is(.dark *)); 6 | 7 | @theme inline { 8 | --color-background: var(--background); 9 | --color-foreground: var(--foreground); 10 | --font-sans: var(--font-inter); 11 | --font-mono: var(--font-geist-mono); 12 | --color-sidebar-ring: var(--sidebar-ring); 13 | --color-sidebar-border: var(--sidebar-border); 14 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); 15 | --color-sidebar-accent: var(--sidebar-accent); 16 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); 17 | --color-sidebar-primary: var(--sidebar-primary); 18 | --color-sidebar-foreground: var(--sidebar-foreground); 19 | --color-sidebar: var(--sidebar); 20 | --color-chart-5: var(--chart-5); 21 | --color-chart-4: var(--chart-4); 22 | --color-chart-3: var(--chart-3); 23 | --color-chart-2: var(--chart-2); 24 | --color-chart-1: var(--chart-1); 25 | --color-ring: var(--ring); 26 | --color-input: var(--input); 27 | --color-border: var(--border); 28 | --color-destructive-foreground: var(--destructive-foreground); 29 | --color-destructive: var(--destructive); 30 | --color-accent-foreground: var(--accent-foreground); 31 | --color-accent: var(--accent); 32 | --color-muted-foreground: var(--muted-foreground); 33 | --color-muted: var(--muted); 34 | --color-secondary-foreground: var(--secondary-foreground); 35 | --color-secondary: var(--secondary); 36 | --color-primary-foreground: var(--primary-foreground); 37 | --color-primary: var(--primary); 38 | --color-popover-foreground: var(--popover-foreground); 39 | --color-popover: var(--popover); 40 | --color-card-foreground: var(--card-foreground); 41 | --color-card: var(--card); 42 | --radius-sm: calc(var(--radius) - 4px); 43 | --radius-md: calc(var(--radius) - 2px); 44 | --radius-lg: var(--radius); 45 | --radius-xl: calc(var(--radius) + 4px); 46 | 47 | /* Sound wave animations */ 48 | --animate-sound-wave-1: sound-wave-1 1.2s ease-in-out infinite; 49 | --animate-sound-wave-2: sound-wave-2 1.4s ease-in-out infinite; 50 | --animate-sound-wave-3: sound-wave-3 1s ease-in-out infinite; 51 | } 52 | 53 | :root { 54 | --background: oklch(1 0 0); 55 | --foreground: oklch(0.145 0 0); 56 | --card: oklch(1 0 0); 57 | --card-foreground: oklch(0.145 0 0); 58 | --popover: oklch(1 0 0); 59 | --popover-foreground: oklch(0.145 0 0); 60 | --primary: oklch(0.205 0 0); 61 | --primary-foreground: oklch(0.985 0 0); 62 | --secondary: oklch(0.97 0 0); 63 | --secondary-foreground: oklch(0.205 0 0); 64 | --muted: oklch(0.97 0 0); 65 | --muted-foreground: oklch(0.556 0 0); 66 | --accent: oklch(0.97 0 0); 67 | --accent-foreground: oklch(0.205 0 0); 68 | --destructive: oklch(0.577 0.245 27.325); 69 | --destructive-foreground: oklch(0.577 0.245 27.325); 70 | --border: oklch(0.922 0 0); 71 | --input: oklch(0.922 0 0); 72 | --ring: oklch(0.708 0 0); 73 | --chart-1: oklch(0.646 0.222 41.116); 74 | --chart-2: oklch(0.6 0.118 184.704); 75 | --chart-3: oklch(0.398 0.07 227.392); 76 | --chart-4: oklch(0.828 0.189 84.429); 77 | --chart-5: oklch(0.769 0.188 70.08); 78 | --radius: 0.625rem; 79 | --sidebar: oklch(0.985 0 0); 80 | --sidebar-foreground: oklch(0.145 0 0); 81 | --sidebar-primary: oklch(0.205 0 0); 82 | --sidebar-primary-foreground: oklch(0.985 0 0); 83 | --sidebar-accent: oklch(0.97 0 0); 84 | --sidebar-accent-foreground: oklch(0.205 0 0); 85 | --sidebar-border: oklch(0.922 0 0); 86 | --sidebar-ring: oklch(0.708 0 0); 87 | } 88 | 89 | .dark { 90 | --background: oklch(0.145 0 0); 91 | --foreground: oklch(0.985 0 0); 92 | --card: oklch(0.145 0 0); 93 | --card-foreground: oklch(0.985 0 0); 94 | --popover: oklch(0.145 0 0); 95 | --popover-foreground: oklch(0.985 0 0); 96 | --primary: oklch(0.985 0 0); 97 | --primary-foreground: oklch(0.205 0 0); 98 | --secondary: oklch(0.269 0 0); 99 | --secondary-foreground: oklch(0.985 0 0); 100 | --muted: oklch(0.269 0 0); 101 | --muted-foreground: oklch(0.708 0 0); 102 | --accent: oklch(0.269 0 0); 103 | --accent-foreground: oklch(0.985 0 0); 104 | --destructive: oklch(0.396 0.141 25.723); 105 | --destructive-foreground: oklch(0.637 0.237 25.331); 106 | --border: oklch(0.269 0 0); 107 | --input: oklch(0.269 0 0); 108 | --ring: oklch(0.439 0 0); 109 | --chart-1: oklch(0.488 0.243 264.376); 110 | --chart-2: oklch(0.696 0.17 162.48); 111 | --chart-3: oklch(0.769 0.188 70.08); 112 | --chart-4: oklch(0.627 0.265 303.9); 113 | --chart-5: oklch(0.645 0.246 16.439); 114 | --sidebar: oklch(0.205 0 0); 115 | --sidebar-foreground: oklch(0.985 0 0); 116 | --sidebar-primary: oklch(0.488 0.243 264.376); 117 | --sidebar-primary-foreground: oklch(0.985 0 0); 118 | --sidebar-accent: oklch(0.269 0 0); 119 | --sidebar-accent-foreground: oklch(0.985 0 0); 120 | --sidebar-border: oklch(0.269 0 0); 121 | --sidebar-ring: oklch(0.439 0 0); 122 | } 123 | 124 | @layer base { 125 | * { 126 | @apply border-border outline-ring/50; 127 | } 128 | body { 129 | @apply bg-background text-foreground; 130 | } 131 | } 132 | 133 | @theme { 134 | --animate-caret-blink: caret-blink 1.25s ease-out infinite; 135 | @keyframes caret-blink { 136 | 0% { 137 | opacity: 1; 138 | } 139 | 70% { 140 | opacity: 1; 141 | } 142 | 100% { 143 | opacity: 1; 144 | } 145 | 20% { 146 | opacity: 0; 147 | } 148 | 50% { 149 | opacity: 0; 150 | } 151 | } 152 | 153 | /* Sound wave animations */ 154 | @keyframes sound-wave-1 { 155 | 0%, 156 | 100% { 157 | height: 40%; 158 | } 159 | 50% { 160 | height: 70%; 161 | } 162 | } 163 | 164 | @keyframes sound-wave-2 { 165 | 0%, 166 | 100% { 167 | height: 80%; 168 | } 169 | 50% { 170 | height: 40%; 171 | } 172 | } 173 | 174 | @keyframes sound-wave-3 { 175 | 0%, 176 | 100% { 177 | height: 60%; 178 | } 179 | 33% { 180 | height: 80%; 181 | } 182 | 66% { 183 | height: 30%; 184 | } 185 | } 186 | } 187 | 188 | @theme inline { 189 | --font-inter: var(--font-inter); 190 | /* https://kyrylo.org/css/2025/02/09/oklch-css-variables-for-tailwind-v4-colors.html */ 191 | --color-primary-50: oklch(98.19% 0.0181 155.83); 192 | --color-primary-100: oklch(96.24% 0.0434 156.74); 193 | --color-primary-200: oklch(92.5% 0.0806 155.99); 194 | --color-primary-300: oklch(87.12% 0.1363 154.45); 195 | --color-primary-400: oklch(80.03% 0.1821 151.71); 196 | --color-primary-500: oklch(72.27% 0.192 149.58); 197 | --color-primary-600: oklch(62.71% 0.1699 149.21); 198 | --color-primary-700: oklch(52.73% 0.1371 150.07); 199 | --color-primary-800: oklch(44.79% 0.1083 151.33); 200 | --color-primary-900: oklch(39.25% 0.0896 152.54); 201 | --color-primary-950: oklch(26.64% 0.0628 152.93); 202 | } 203 | -------------------------------------------------------------------------------- /apps/client/src/app/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /apps/client/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { PostHogProvider } from "@/components/PostHogProvider"; 2 | import { Toaster } from "@/components/ui/sonner"; 3 | import { cn } from "@/lib/utils"; 4 | import { Analytics } from "@vercel/analytics/react"; 5 | import type { Metadata } from "next"; 6 | import { Geist, Geist_Mono, Inter } from "next/font/google"; 7 | import "./globals.css"; 8 | 9 | const geistSans = Geist({ 10 | variable: "--font-geist-sans", 11 | subsets: ["latin"], 12 | }); 13 | 14 | const geistMono = Geist_Mono({ 15 | variable: "--font-geist-mono", 16 | subsets: ["latin"], 17 | }); 18 | 19 | const inter = Inter({ 20 | variable: "--font-inter", 21 | subsets: ["latin"], 22 | }); 23 | 24 | export const metadata: Metadata = { 25 | title: "Beatsync", 26 | description: 27 | "Beatsync is an open-source, web audio player built for multi-device playback.", 28 | keywords: ["music", "sync", "audio", "collaboration", "real-time"], 29 | authors: [{ name: "Freeman Jiang" }], 30 | }; 31 | 32 | export default function RootLayout({ 33 | children, 34 | }: Readonly<{ children: React.ReactNode }>) { 35 | return ( 36 | 37 | 45 | 46 | {children} 47 | 48 | 49 | 50 | 51 | 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /apps/client/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { Join } from "@/components/Join"; 3 | import { useGlobalStore } from "@/store/global"; 4 | import { useRoomStore } from "@/store/room"; 5 | import { useEffect } from "react"; 6 | 7 | export default function Home() { 8 | const resetGlobalStore = useGlobalStore((state) => state.resetStore); 9 | const resetRoomStore = useRoomStore((state) => state.reset); 10 | 11 | useEffect(() => { 12 | console.log("resetting stores"); 13 | // Reset both stores when the main page is loaded 14 | resetGlobalStore(); 15 | resetRoomStore(); 16 | }, [resetGlobalStore, resetRoomStore]); 17 | 18 | return ; 19 | } 20 | -------------------------------------------------------------------------------- /apps/client/src/app/room/[roomId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { NewSyncer } from "@/components/NewSyncer"; 2 | import { validateFullRoomId } from "@/lib/room"; 3 | 4 | export default async function Page({ 5 | params, 6 | }: { 7 | params: Promise<{ roomId: string }>; 8 | }) { 9 | const { roomId } = await params; 10 | if (!validateFullRoomId(roomId)) { 11 | return ( 12 |
13 |
14 | Invalid room ID: {roomId}. 15 |
16 |
17 | Please enter a valid 6-digit numeric code. 18 |
19 |
20 | ); 21 | } 22 | 23 | return ; 24 | } 25 | -------------------------------------------------------------------------------- /apps/client/src/components/AudioUploaderMinimal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { uploadAudioFile } from "@/lib/api"; 4 | import { cn, trimFileName } from "@/lib/utils"; 5 | import { useRoomStore } from "@/store/room"; 6 | import { CloudUpload, Plus } from "lucide-react"; 7 | import { usePostHog } from "posthog-js/react"; 8 | import { useState } from "react"; 9 | import { toast } from "sonner"; 10 | 11 | export const AudioUploaderMinimal = () => { 12 | const [isDragging, setIsDragging] = useState(false); 13 | const [isUploading, setIsUploading] = useState(false); 14 | const [fileName, setFileName] = useState(null); 15 | const roomId = useRoomStore((state) => state.roomId); 16 | const posthog = usePostHog(); 17 | 18 | const handleFileUpload = async (file: File) => { 19 | // Store file name for display 20 | setFileName(file.name); 21 | 22 | // Track upload initiated 23 | posthog.capture("upload_initiated", { 24 | file_name: file.name, 25 | file_size: file.size, 26 | file_type: file.type, 27 | room_id: roomId, 28 | }); 29 | 30 | try { 31 | setIsUploading(true); 32 | 33 | // Read file as base64 34 | const reader = new FileReader(); 35 | 36 | reader.onload = async (e) => { 37 | try { 38 | const base64Data = e.target?.result?.toString().split(",")[1]; 39 | if (!base64Data) throw new Error("Failed to convert file to base64"); 40 | 41 | // Upload the file to the server 42 | await uploadAudioFile({ 43 | name: file.name, 44 | audioData: base64Data, 45 | roomId, 46 | }); 47 | 48 | // Track successful upload 49 | posthog.capture("upload_success", { 50 | file_name: file.name, 51 | file_size: file.size, 52 | file_type: file.type, 53 | room_id: roomId, 54 | }); 55 | 56 | setTimeout(() => setFileName(null), 3000); 57 | } catch (err) { 58 | console.error("Error during upload:", err); 59 | toast.error("Failed to upload audio file"); 60 | setFileName(null); 61 | 62 | // Track upload failure 63 | posthog.capture("upload_failed", { 64 | file_name: file.name, 65 | file_size: file.size, 66 | file_type: file.type, 67 | room_id: roomId, 68 | error: err instanceof Error ? err.message : "Unknown error", 69 | }); 70 | } finally { 71 | setIsUploading(false); 72 | } 73 | }; 74 | 75 | reader.onerror = () => { 76 | toast.error("Failed to read file"); 77 | setIsUploading(false); 78 | setFileName(null); 79 | 80 | // Track file read error 81 | posthog.capture("upload_failed", { 82 | file_name: file.name, 83 | file_size: file.size, 84 | file_type: file.type, 85 | room_id: roomId, 86 | error: "Failed to read file", 87 | }); 88 | }; 89 | 90 | reader.readAsDataURL(file); 91 | } catch (err) { 92 | console.error("Error:", err); 93 | toast.error("Failed to process file"); 94 | setIsUploading(false); 95 | setFileName(null); 96 | 97 | // Track upload processing error 98 | posthog.capture("upload_failed", { 99 | file_name: file.name, 100 | file_size: file.size, 101 | file_type: file.type, 102 | room_id: roomId, 103 | error: err instanceof Error ? err.message : "Unknown processing error", 104 | }); 105 | } 106 | }; 107 | 108 | const onInputChange = (event: React.ChangeEvent) => { 109 | const file = event.target.files?.[0]; 110 | if (!file) return; 111 | handleFileUpload(file); 112 | }; 113 | 114 | const onDragOver = (event: React.DragEvent) => { 115 | event.preventDefault(); 116 | event.stopPropagation(); 117 | setIsDragging(true); 118 | }; 119 | 120 | const onDragLeave = (event: React.DragEvent) => { 121 | event.preventDefault(); 122 | event.stopPropagation(); 123 | setIsDragging(false); 124 | }; 125 | 126 | const onDropEvent = (event: React.DragEvent) => { 127 | event.preventDefault(); 128 | event.stopPropagation(); 129 | setIsDragging(false); 130 | 131 | const file = event.dataTransfer?.files?.[0]; 132 | if (!file) return; 133 | // make sure we only allow audio files 134 | if (!file.type.startsWith("audio/")) { 135 | toast.error("Please select an audio file"); 136 | return; 137 | } 138 | 139 | handleFileUpload(file); 140 | }; 141 | 142 | return ( 143 |
156 | 181 | 182 | 190 |
191 | ); 192 | }; 193 | -------------------------------------------------------------------------------- /apps/client/src/components/IPFinder.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { 3 | Popover, 4 | PopoverContent, 5 | PopoverTrigger, 6 | } from "@/components/ui/popover"; 7 | import { Skeleton } from "@/components/ui/skeleton"; 8 | import { Check, Copy, HelpCircle } from "lucide-react"; 9 | import { useEffect, useState } from "react"; 10 | 11 | const LocalIPFinder = () => { 12 | const [localIP, setLocalIP] = useState("Detecting..."); 13 | const [status, setStatus] = useState("searching"); 14 | const [error, setError] = useState(null); 15 | const [copied, setCopied] = useState(false); 16 | 17 | const copyToClipboard = (text: string) => { 18 | navigator.clipboard.writeText(text); 19 | setCopied(true); 20 | setTimeout(() => setCopied(false), 2000); 21 | }; 22 | 23 | const getLocalIP = async () => { 24 | const RTCPeerConnection = window.RTCPeerConnection; 25 | 26 | if (!RTCPeerConnection) { 27 | setError("WebRTC not supported by this browser"); 28 | setStatus("failed"); 29 | return null; 30 | } 31 | 32 | const pc = new RTCPeerConnection({ 33 | iceServers: [], // Empty STUN servers - we don't need external services 34 | }); 35 | 36 | try { 37 | // Create a data channel to force ICE candidate generation 38 | pc.createDataChannel(""); 39 | 40 | // Create an offer and set it as local description 41 | const offer = await pc.createOffer(); 42 | await pc.setLocalDescription(offer); 43 | 44 | // Set a timeout in case no viable candidates are found 45 | const timeoutPromise = new Promise((_, reject) => { 46 | setTimeout(() => reject(new Error("IP detection timeout")), 5000); 47 | }); 48 | 49 | // Wait for ICE candidate gathering 50 | const ipPromise = new Promise((resolve) => { 51 | pc.onicecandidate = (ice) => { 52 | if (!ice || !ice.candidate || !ice.candidate.candidate) return; 53 | 54 | const candidateString = ice.candidate.candidate; 55 | // Look for candidates that contain IPv4 addresses 56 | const ipRegex = /([0-9]{1,3}(\.[0-9]{1,3}){3})/; 57 | const matches = ipRegex.exec(candidateString); 58 | 59 | if (matches && matches[1]) { 60 | const ip = matches[1]; 61 | // Ignore special addresses and loopback 62 | if (ip !== "0.0.0.0" && !ip.startsWith("127.")) { 63 | resolve(ip); 64 | } 65 | } 66 | }; 67 | }); 68 | 69 | // Race the IP detection against the timeout 70 | const ip = await Promise.race([ipPromise, timeoutPromise]); 71 | pc.close(); 72 | setLocalIP(ip); 73 | setStatus("success"); 74 | return ip; 75 | } catch (error) { 76 | console.error("Error getting IP address:", error); 77 | pc.close(); 78 | setError(error instanceof Error ? error.message : String(error)); 79 | setStatus("failed"); 80 | return null; 81 | } 82 | }; 83 | 84 | useEffect(() => { 85 | getLocalIP(); 86 | }, []); 87 | 88 | return ( 89 |
90 |

Local IP Address Finder

91 | 92 | {status === "searching" && ( 93 |
94 | 95 | Detecting your local IP address... 96 | 97 |
98 | )} 99 | 100 | {status === "success" && ( 101 |
102 |
103 |

104 | Detected IP Address: 105 |

106 |
copyToClipboard(localIP)} 109 | > 110 |

111 | {localIP} 112 |

113 | 114 | {copied ? ( 115 | <> 116 | Copied! 117 | 118 | ) : ( 119 | <> 120 | Click to copy 121 | 122 | )} 123 | 124 |
125 |

126 | Use this IP address as the master device for audio 127 | synchronization. 128 |

129 |
130 |
131 | )} 132 | 133 | {status === "manual" && ( 134 |
135 |
136 |

Manual IP Address:

137 |
copyToClipboard(localIP)} 140 | > 141 |

142 | {localIP} 143 |

144 | 145 | {copied ? ( 146 | <> 147 | Copied! 148 | 149 | ) : ( 150 | <> 151 | Click to copy 152 | 153 | )} 154 | 155 |
156 |

157 | Using manually entered IP address for synchronization. 158 |

159 |
160 |
161 | )} 162 | 163 | {status === "failed" && ( 164 |
165 |
166 |

Detection Failed

167 |

{error}

168 |

169 | {error?.includes("timeout") 170 | ? "Currently, only Chrome browsers are supported. Please try again." 171 | : "Please enter your local IP address manually."} 172 |

173 |
174 |
175 | )} 176 | 177 |
178 |
179 |

Find your IP manually

180 | 181 | 182 | 186 | 187 | 188 |
189 |

190 | How to find your IP manually on macOS: 191 |

192 |
    193 |
  1. Open System Preferences
  2. 194 |
  3. Click on Network
  4. 195 |
  5. Select your active connection (Wi-Fi or Ethernet)
  6. 196 |
  7. Click "Details..."
  8. 197 |
  9. Click "TCP/IP"
  10. 198 |
  11. Look for "IP Address"
  12. 199 |
200 |
201 |
202 |
203 |
204 |
205 |
206 | ); 207 | }; 208 | 209 | export default LocalIPFinder; 210 | -------------------------------------------------------------------------------- /apps/client/src/components/NewSyncer.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { generateName } from "@/lib/randomNames"; 3 | import { useRoomStore } from "@/store/room"; 4 | import { motion } from "motion/react"; 5 | import { useEffect } from "react"; 6 | import { Dashboard } from "./dashboard/Dashboard"; 7 | import { WebSocketManager } from "./room/WebSocketManager"; 8 | 9 | interface NewSyncerProps { 10 | roomId: string; 11 | } 12 | 13 | // Main component has been refactored into smaller components 14 | export const NewSyncer = ({ roomId }: NewSyncerProps) => { 15 | const setUsername = useRoomStore((state) => state.setUsername); 16 | const setRoomId = useRoomStore((state) => state.setRoomId); 17 | const username = useRoomStore((state) => state.username); 18 | 19 | // Generate a new random username when the component mounts 20 | useEffect(() => { 21 | setRoomId(roomId); 22 | if (!username) { 23 | setUsername(generateName()); 24 | } 25 | }, [setUsername, username, roomId, setRoomId]); 26 | 27 | return ( 28 | 33 | {/* WebSocket connection manager (non-visual component) */} 34 | 35 | 36 | {/* Spatial audio background effects */} 37 | {/* */} 38 | 39 | 40 | 41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /apps/client/src/components/NudgeControls.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { 3 | Select, 4 | SelectContent, 5 | SelectItem, 6 | SelectTrigger, 7 | SelectValue, 8 | } from "@/components/ui/select"; 9 | import { useForm } from "react-hook-form"; 10 | 11 | interface NudgeControlsProps { 12 | totalNudge: number; 13 | onNudge: (amount: number) => void; 14 | disabled: boolean; 15 | } 16 | 17 | export const NudgeControls: React.FC = ({ 18 | totalNudge, 19 | onNudge, 20 | disabled, 21 | }) => { 22 | const { watch, setValue } = useForm({ 23 | defaultValues: { 24 | nudgeAmount: 10, 25 | }, 26 | }); 27 | 28 | const nudgeAmount = watch("nudgeAmount"); 29 | 30 | const handleNudgeAmountChange = (newAmount: number) => { 31 | const validValues = [1, 5, 10, 20, 50, 100, 250, 500, 1000]; 32 | 33 | // If the value is not in our predefined list, find the closest available option 34 | if (!validValues.includes(newAmount)) { 35 | const closest = validValues.reduce((prev, curr) => { 36 | return Math.abs(curr - newAmount) < Math.abs(prev - newAmount) 37 | ? curr 38 | : prev; 39 | }); 40 | console.log( 41 | `Nudge amount ${newAmount} not in valid options, using closest value: ${closest}` 42 | ); 43 | setValue("nudgeAmount", closest); 44 | } else { 45 | setValue("nudgeAmount", newAmount); 46 | console.log(`Nudge amount set to ${newAmount} ms`); 47 | } 48 | }; 49 | 50 | return ( 51 |
52 |

Microscopic Timing Controls

53 |
54 | Nudge Amount: {nudgeAmount} ms 55 |
56 | 65 | 76 | 96 |
97 |
98 |
99 | 107 | 115 |
116 |
117 | Total adjustment: {totalNudge > 0 ? "+" : ""} 118 | {totalNudge} ms ({(totalNudge / 1000).toFixed(3)} s) 119 |
120 |
121 | ); 122 | }; 123 | -------------------------------------------------------------------------------- /apps/client/src/components/PostHogProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { usePathname, useSearchParams } from "next/navigation"; 4 | import posthog from "posthog-js"; 5 | import { PostHogProvider as PHProvider, usePostHog } from "posthog-js/react"; 6 | import { Suspense, useEffect } from "react"; 7 | 8 | export function PostHogProvider({ children }: { children: React.ReactNode }) { 9 | useEffect(() => { 10 | if (process.env.NEXT_PUBLIC_POSTHOG_KEY) { 11 | posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, { 12 | api_host: "/ingest", 13 | ui_host: "https://us.posthog.com", 14 | capture_pageview: false, // We capture pageviews manually 15 | capture_pageleave: true, // Enable pageleave capture 16 | 17 | // debug: process.env.NODE_ENV === "development", 18 | }); 19 | } 20 | }, []); 21 | 22 | return ( 23 | 24 | 25 | {children} 26 | 27 | ); 28 | } 29 | 30 | function PostHogPageView() { 31 | const pathname = usePathname(); 32 | const searchParams = useSearchParams(); 33 | const posthog = usePostHog(); 34 | 35 | useEffect(() => { 36 | if (pathname && posthog) { 37 | let url = window.origin + pathname; 38 | const search = searchParams.toString(); 39 | if (search) { 40 | url += "?" + search; 41 | } 42 | posthog.capture("$pageview", { $current_url: url }); 43 | } 44 | }, [pathname, searchParams, posthog]); 45 | 46 | return null; 47 | } 48 | 49 | function SuspendedPostHogPageView() { 50 | return ( 51 | 52 | 53 | 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /apps/client/src/components/Queue.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | DropdownMenu, 3 | DropdownMenuContent, 4 | DropdownMenuItem, 5 | DropdownMenuTrigger, 6 | } from "@/components/ui/dropdown-menu"; 7 | import { LocalAudioSource } from "@/lib/localTypes"; 8 | import { cn, formatTime } from "@/lib/utils"; 9 | import { useGlobalStore } from "@/store/global"; 10 | import { MoreHorizontal, Pause, Play, UploadCloud } from "lucide-react"; 11 | import { AnimatePresence, motion } from "motion/react"; 12 | import { usePostHog } from "posthog-js/react"; 13 | 14 | export const Queue = ({ className, ...rest }: React.ComponentProps<"div">) => { 15 | const posthog = usePostHog(); 16 | const audioSources = useGlobalStore((state) => state.audioSources); 17 | const selectedAudioId = useGlobalStore((state) => state.selectedAudioId); 18 | const setSelectedAudioId = useGlobalStore( 19 | (state) => state.setSelectedAudioId 20 | ); 21 | const isInitingSystem = useGlobalStore((state) => state.isInitingSystem); 22 | const broadcastPlay = useGlobalStore((state) => state.broadcastPlay); 23 | const broadcastPause = useGlobalStore((state) => state.broadcastPause); 24 | const isPlaying = useGlobalStore((state) => state.isPlaying); 25 | const reuploadAudio = useGlobalStore((state) => state.reuploadAudio); 26 | 27 | const handleItemClick = (source: LocalAudioSource) => { 28 | if (source.id === selectedAudioId) { 29 | if (isPlaying) { 30 | broadcastPause(); 31 | posthog.capture("pause_track", { track_id: source.id }); 32 | } else { 33 | broadcastPlay(); 34 | posthog.capture("play_track", { track_id: source.id }); 35 | } 36 | } else { 37 | // Track selection event 38 | posthog.capture("select_track", { 39 | track_id: source.id, 40 | track_name: source.name, 41 | previous_track_id: selectedAudioId, 42 | }); 43 | 44 | setSelectedAudioId(source.id); 45 | broadcastPlay(0); 46 | } 47 | }; 48 | 49 | const handleReupload = (sourceId: string, sourceName: string) => { 50 | reuploadAudio(sourceId, sourceName); 51 | 52 | // Track reupload event 53 | posthog.capture("reupload_track", { 54 | track_id: sourceId, 55 | track_name: sourceName, 56 | }); 57 | }; 58 | 59 | return ( 60 |
61 | {/*

Beatsync

*/} 62 |
63 | {audioSources.length > 0 ? ( 64 | 65 | {audioSources.map((source, index) => { 66 | const isSelected = source.id === selectedAudioId; 67 | const isPlayingThis = isSelected && isPlaying; 68 | 69 | return ( 70 | handleItemClick(source)} 86 | > 87 | {/* Track number / Play icon */} 88 |
89 | {/* Play/Pause button (shown on hover) */} 90 | 97 | 98 | {/* Playing indicator or track number (hidden on hover) */} 99 |
100 | {isPlayingThis ? ( 101 |
102 |
103 |
104 |
105 |
106 | ) : ( 107 | 113 | {index + 1} 114 | 115 | )} 116 |
117 |
118 | 119 | {/* Track name */} 120 |
121 |
127 | {source.name} 128 |
129 |
130 | 131 | {/* Duration & Optional Re-upload Menu */} 132 |
133 |
134 | {formatTime(source.audioBuffer.duration)} 135 |
136 | 137 | {/* Dropdown for re-uploading - Always shown */} 138 | 139 | e.stopPropagation()} 142 | > 143 | 146 | 147 | e.stopPropagation()} 151 | > 152 | 154 | handleReupload(source.id, source.name) 155 | } 156 | className="flex items-center gap-2 cursor-pointer text-sm" 157 | disabled={source.id.startsWith("static")} 158 | > 159 | 160 | Reupload to room 161 | 162 | 163 | 164 |
165 |
166 | ); 167 | })} 168 |
169 | ) : ( 170 | 175 | {isInitingSystem ? "Loading tracks..." : "No tracks available"} 176 | 177 | )} 178 |
179 |
180 | ); 181 | }; 182 | -------------------------------------------------------------------------------- /apps/client/src/components/TimingDisplay.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import { formatTimeMicro } from "@/utils/time"; 3 | 4 | interface TimingDisplayProps { 5 | currentTime: number; // in milliseconds 6 | isPlaying: boolean; 7 | totalNudge: number; // in milliseconds 8 | clockOffset: number | null; // in milliseconds 9 | } 10 | 11 | export const TimingDisplay: React.FC = ({ 12 | currentTime, 13 | isPlaying, 14 | totalNudge, 15 | clockOffset, 16 | }) => { 17 | // Calculate colors based on offset values 18 | const getOffsetColor = (offset: number) => { 19 | if (Math.abs(offset) < 1) return "bg-green-500"; // Very close - green 20 | if (offset > 0) return "bg-red-500"; // Ahead - red 21 | return "bg-blue-500"; // Behind - blue 22 | }; 23 | 24 | // Get color based on 2-second cycle 25 | const getTimeCycleColor = (timeMs: number) => { 26 | const cyclePosition = Math.floor((timeMs % 6000) / 2000); 27 | 28 | switch (cyclePosition) { 29 | case 0: 30 | return "bg-red-500"; // 0-2 seconds: Red 31 | case 1: 32 | return "bg-green-500"; // 2-4 seconds: Green 33 | case 2: 34 | return "bg-blue-500"; // 4-6 seconds: Blue 35 | default: 36 | return "bg-gray-500"; 37 | } 38 | }; 39 | 40 | // Get text color based on 2-second cycle 41 | const getTimeCycleTextColor = (timeMs: number) => { 42 | const cyclePosition = Math.floor((timeMs % 6000) / 2000); 43 | 44 | switch (cyclePosition) { 45 | case 0: 46 | return "text-red-500"; // 0-2 seconds: Red 47 | case 1: 48 | return "text-green-500"; // 2-4 seconds: Green 49 | case 2: 50 | return "text-blue-500"; // 4-6 seconds: Blue 51 | default: 52 | return "text-gray-500"; 53 | } 54 | }; 55 | 56 | // Calculate which 2-second block we're in 57 | const currentCycleSeconds = Math.floor((currentTime % 6000) / 1000); 58 | const currentColorName = [ 59 | "Red", // 0s 60 | "Red", // 1s 61 | "Green", // 2s 62 | "Green", // 3s 63 | "Blue", // 4s 64 | "Blue", // 5s 65 | ][currentCycleSeconds]; 66 | 67 | return ( 68 |
69 |

Precise Timing Display

70 | 71 | {/* Color cycle indicator */} 72 |
73 |
74 | Color Cycle (6s): 75 | 76 | {currentColorName} ({currentCycleSeconds % 2}s) 77 | 78 |
79 | 80 | {/* Large color block for easy visual comparison between clients */} 81 |
82 |
88 |
89 | {currentCycleSeconds % 2} 90 |
91 |
92 |
93 |
94 | 95 | {/* Current playback time with microsecond precision */} 96 |
97 |
98 | Playback Time: 99 | 104 | {formatTimeMicro(currentTime)} 105 | 106 |
107 |
108 |
112 |
113 |
114 | 115 | {/* Nudge amount visualization */} 116 |
117 |
118 | Timing Adjustment: 119 | 120 | {totalNudge > 0 ? "+" : ""} 121 | {totalNudge} ms 122 | 123 |
124 |
125 |
126 |
0 131 | ? "bg-red-500" 132 | : "bg-blue-500" 133 | }`} 134 | style={{ marginLeft: `${50 + totalNudge * 10}%` }} // Scale for visibility 135 | >
136 |
137 |
138 |
139 | 140 | {/* Clock offset visualization */} 141 |
142 |
143 | Clock Offset: 144 | 145 | {clockOffset !== null 146 | ? `${clockOffset > 0 ? "+" : ""}${clockOffset.toFixed(3)} ms` 147 | : "Unknown"} 148 | 149 |
150 | {clockOffset !== null && ( 151 |
152 |
153 |
161 |
162 |
163 | )} 164 |
165 |
166 | ); 167 | }; 168 | -------------------------------------------------------------------------------- /apps/client/src/components/TrackSelector.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { 3 | Select, 4 | SelectContent, 5 | SelectItem, 6 | SelectTrigger, 7 | SelectValue, 8 | } from "@/components/ui/select"; 9 | import { useGlobalStore } from "@/store/global"; 10 | 11 | export const TrackSelector = () => { 12 | const audioSources = useGlobalStore((state) => state.audioSources); 13 | const selectedAudioId = useGlobalStore((state) => state.selectedAudioId); 14 | const setSelectedAudioId = useGlobalStore( 15 | (state) => state.setSelectedAudioId 16 | ); 17 | const isLoadingAudioSources = useGlobalStore( 18 | (state) => state.isInitingSystem 19 | ); 20 | 21 | return ( 22 |
23 | 41 |
42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /apps/client/src/components/UploadHistory.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useGlobalStore } from "@/store/global"; 4 | import { CloudUpload, History } from "lucide-react"; 5 | import { toast } from "sonner"; 6 | import { Button } from "./ui/button"; 7 | import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"; 8 | 9 | // Helper function to format relative time 10 | const formatRelativeTime = (timestamp: number): string => { 11 | const now = Date.now(); 12 | const diff = now - timestamp; 13 | 14 | // Convert to appropriate time unit 15 | if (diff < 60000) return "just now"; 16 | if (diff < 3600000) return `${Math.floor(diff / 60000)} minutes ago`; 17 | if (diff < 86400000) return `${Math.floor(diff / 3600000)} hours ago`; 18 | return `${Math.floor(diff / 86400000)} days ago`; 19 | }; 20 | 21 | export const UploadHistory = () => { 22 | const uploadHistory = useGlobalStore((state) => state.uploadHistory); 23 | const reuploadAudio = useGlobalStore((state) => state.reuploadAudio); 24 | 25 | const handleReupload = (item: { 26 | name: string; 27 | timestamp: number; 28 | id: string; 29 | }) => { 30 | reuploadAudio(item.id, item.name); 31 | toast.success(`Rebroadcasting ${item.name} to all users`); 32 | }; 33 | 34 | return ( 35 | 36 | 37 | 38 | 39 | Upload History 40 | 41 | 42 | 43 | {uploadHistory.length === 0 ? ( 44 |
45 | No upload history yet 46 |
47 | ) : ( 48 |
49 | {uploadHistory.map((item) => ( 50 |
54 |
55 | 56 | {item.name} 57 | 58 | 59 | {formatRelativeTime(item.timestamp)} 60 | 61 |
62 | 63 | {item.id && ( 64 | 73 | )} 74 |
75 | ))} 76 |
77 | )} 78 |
79 |
80 | ); 81 | }; 82 | -------------------------------------------------------------------------------- /apps/client/src/components/dashboard/AudioControls.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useGlobalStore } from "@/store/global"; 4 | import { Construction, Orbit } from "lucide-react"; 5 | import { motion } from "motion/react"; 6 | import { usePostHog } from "posthog-js/react"; 7 | import { Button } from "../ui/button"; 8 | 9 | export const AudioControls = () => { 10 | const posthog = usePostHog(); 11 | const startSpatialAudio = useGlobalStore((state) => state.startSpatialAudio); 12 | const stopSpatialAudio = useGlobalStore( 13 | (state) => state.sendStopSpatialAudio 14 | ); 15 | const isLoadingAudio = useGlobalStore((state) => state.isInitingSystem); 16 | 17 | const handleStartSpatialAudio = () => { 18 | startSpatialAudio(); 19 | posthog.capture("start_spatial_audio"); 20 | }; 21 | 22 | const handleStopSpatialAudio = () => { 23 | stopSpatialAudio(); 24 | posthog.capture("stop_spatial_audio"); 25 | }; 26 | 27 | return ( 28 | 29 |

34 | Audio Effects{" "} 35 | {isLoadingAudio && ( 36 | (loading...) 37 | )} 38 |

39 | 40 |
41 | 42 |
43 |
44 | 45 | Rotation 46 |
47 |
48 | 56 | 64 |
65 |
66 |
67 |
68 |
69 |
70 | 71 | More coming soon... 72 |
73 |
74 |
75 |
76 |
77 | ); 78 | }; 79 | -------------------------------------------------------------------------------- /apps/client/src/components/dashboard/Bottom.tsx: -------------------------------------------------------------------------------- 1 | import { motion } from "motion/react"; 2 | import { Player } from "../room/Player"; 3 | 4 | export const Bottom = () => { 5 | return ( 6 | 7 |
8 | 9 |
10 |
11 | ); 12 | }; 13 | -------------------------------------------------------------------------------- /apps/client/src/components/dashboard/Dashboard.tsx: -------------------------------------------------------------------------------- 1 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 2 | import { useGlobalStore } from "@/store/global"; 3 | import { Library, ListMusic, Rotate3D } from "lucide-react"; 4 | import { AnimatePresence, motion } from "motion/react"; 5 | import { TopBar } from "../room/TopBar"; 6 | import { Bottom } from "./Bottom"; 7 | import { Left } from "./Left"; 8 | import { Main } from "./Main"; 9 | import { Right } from "./Right"; 10 | 11 | interface DashboardProps { 12 | roomId: string; 13 | } 14 | 15 | export const Dashboard = ({ roomId }: DashboardProps) => { 16 | const isSynced = useGlobalStore((state) => state.isSynced); 17 | const isLoadingAudio = useGlobalStore((state) => state.isInitingSystem); 18 | 19 | const isReady = isSynced && !isLoadingAudio; 20 | 21 | const containerVariants = { 22 | hidden: { opacity: 0 }, 23 | visible: { 24 | opacity: 1, 25 | transition: { 26 | duration: 0.5, 27 | staggerChildren: 0.1, 28 | }, 29 | }, 30 | }; 31 | 32 | return ( 33 |
34 | {/* Top bar: Fixed height */} 35 | 36 | 37 | {isReady && ( 38 | 44 | {/* --- DESKTOP LAYOUT (lg+) --- */} 45 |
46 | 47 |
48 | 49 |
50 | 51 | {/* --- MOBILE LAYOUT (< lg) --- */} 52 |
53 | 57 | {/* Tab List at the top for mobile */} 58 | 59 | 63 | Library 64 | 65 | 69 | Queue 70 | 71 | 75 | Spatial 76 | 77 | 78 | 79 | {/* Tab Content Area - Scrolls independently */} 80 | 81 | 86 | 93 | 94 | 95 | 96 | 101 | 108 |
109 | 110 | 111 | 116 | 123 | 124 | 125 | 126 | 127 | 128 |
129 | 130 | {/* Bottom Player: Fixed height, outside the scrollable/tab area */} 131 | 132 |
133 | )} 134 |
135 | ); 136 | }; 137 | -------------------------------------------------------------------------------- /apps/client/src/components/dashboard/GainMeter.tsx: -------------------------------------------------------------------------------- 1 | import { useGlobalStore } from "@/store/global"; 2 | import { motion } from "motion/react"; 3 | import { useEffect, useState } from "react"; 4 | 5 | export const GainMeter = () => { 6 | const getCurrentGainValue = useGlobalStore( 7 | (state) => state.getCurrentGainValue 8 | ); 9 | const isEnabled = useGlobalStore((state) => state.isSpatialAudioEnabled); 10 | 11 | const [gainValue, setGainValue] = useState(1); 12 | 13 | // Update gain value every 50ms for smoother animation 14 | useEffect(() => { 15 | const intervalId = setInterval(() => { 16 | const currentGain = getCurrentGainValue(); 17 | setGainValue(currentGain); 18 | }, 50); 19 | 20 | return () => clearInterval(intervalId); 21 | }, [getCurrentGainValue]); 22 | 23 | // Calculate bar width as percentage (max gain is typically 1) 24 | // Limit to 94% to maintain visible border radius at max gain 25 | const barWidthPercent = Math.min(94, Math.max(0, gainValue * 94)); 26 | 27 | // Format gain value as percentage 28 | const gainPercentage = Math.round(gainValue * 100); 29 | 30 | // Get color based on gain value using smooth interpolation - neutral gray to green 31 | const getColor = () => { 32 | if (gainValue >= 0.9) return "#22c55e"; // green-500 33 | if (gainValue >= 0.7) return "#4ade80"; // green-400 34 | if (gainValue >= 0.5) return "#86efac"; // green-300 35 | if (gainValue >= 0.3) return "#a3a3a3"; // neutral-400 36 | return "#737373"; // neutral-500 37 | }; 38 | 39 | return ( 40 |
41 |
42 | {gainPercentage}% 43 |
44 | 45 | {/* Visual gain meter */} 46 |
47 | 61 |
62 |
63 | ); 64 | }; 65 | -------------------------------------------------------------------------------- /apps/client/src/components/dashboard/Left.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | import { Library, Search } from "lucide-react"; 5 | import { motion } from "motion/react"; 6 | import { AudioUploaderMinimal } from "../AudioUploaderMinimal"; 7 | import { Button } from "../ui/button"; 8 | import { Separator } from "../ui/separator"; 9 | import { AudioControls } from "./AudioControls"; 10 | 11 | interface LeftProps { 12 | className?: string; 13 | } 14 | 15 | export const Left = ({ className }: LeftProps) => { 16 | // const shareRoom = () => { 17 | // try { 18 | // navigator.share({ 19 | // title: "Join my BeatSync room", 20 | // text: `Join my BeatSync room with code: ${roomId}`, 21 | // url: window.location.href, 22 | // }); 23 | // } catch { 24 | // copyRoomId(); 25 | // } 26 | // }; 27 | 28 | return ( 29 | 37 | {/* Header section */} 38 | {/*
39 |
40 | 41 |
42 |

Beatsync

43 |
44 | 45 | 46 | */} 47 | 48 |

49 | Your Library 50 |

51 | 52 | {/* Navigation menu */} 53 | 54 | 61 | 62 | 63 | 70 | 71 | 72 | 73 | 74 | 75 | {/* Audio Controls */} 76 | 77 | 78 | {/* Tips Section */} 79 | 80 |
81 |
Tips
82 |
    83 |
  • 84 | Works best with multiple devices IRL in the same space. 85 |
  • 86 |
  • 87 | If audio gets de-synced, pause, play / full sync and try again or 88 | refresh. 89 |
  • 90 |
  • 91 | {"Play on speaker directly. Don't use Bluetooth."} 92 |
  • 93 |
94 |
95 | 96 |
97 | 98 |
99 |
100 |
101 | ); 102 | }; 103 | -------------------------------------------------------------------------------- /apps/client/src/components/dashboard/Main.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import { motion } from "motion/react"; 3 | import { Queue } from "../Queue"; 4 | 5 | export const Main = () => { 6 | return ( 7 | 13 | 14 | {/*

BeatSync

*/} 15 | 16 |
17 |
18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /apps/client/src/components/dashboard/Right.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import { Info } from "lucide-react"; 3 | import { motion } from "motion/react"; 4 | import { UserGrid } from "../room/UserGrid"; 5 | 6 | interface RightProps { 7 | className?: string; 8 | } 9 | 10 | export const Right = ({ className }: RightProps) => { 11 | return ( 12 | 18 | 19 | 20 | 21 | 22 | 23 |
24 |
25 |
26 | 27 | What is this? 28 |
29 |

30 | This grid simulates a spatial audio environment. The headphone 31 | icon (🎧) is a listening source. The circles represent other 32 | devices in the room. 33 |

34 |

35 | { 36 | "Drag the headphone icon around and hear how the volume changes on each device. Isn't it cool!" 37 | } 38 |

39 |
40 |
41 |
42 |
43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /apps/client/src/components/room/MusicControls.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { AlertTriangle } from "lucide-react"; 3 | import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"; 4 | import { TrackSelector } from "../TrackSelector"; 5 | import { Player } from "./Player"; 6 | 7 | export const MusicControls = () => { 8 | return ( 9 | 10 | 11 | Music Controls 12 | 13 | 14 |
15 |

16 | 17 | These controls affect all users in the room. 18 |

19 |
20 | 21 | 22 |
23 |
24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /apps/client/src/components/room/NTP.tsx: -------------------------------------------------------------------------------- 1 | import { useGlobalStore } from "@/store/global"; 2 | import { Button } from "../ui/button"; 3 | 4 | export const NTP = () => { 5 | const sendNTPRequest = useGlobalStore((state) => state.sendNTPRequest); 6 | const ntpMeasurements = useGlobalStore((state) => state.ntpMeasurements); 7 | const offsetEstimate = useGlobalStore((state) => state.offsetEstimate); 8 | const roundTripEstimate = useGlobalStore((state) => state.roundTripEstimate); 9 | const resetNTPConfig = useGlobalStore((state) => state.resetNTPConfig); 10 | const pauseAudio = useGlobalStore((state) => state.pauseAudio); 11 | 12 | const resync = () => { 13 | pauseAudio({ when: 0 }); 14 | resetNTPConfig(); 15 | sendNTPRequest(); 16 | }; 17 | 18 | return ( 19 |
20 | {ntpMeasurements.length > 0 && ( 21 |

Synced {ntpMeasurements.length} times

22 | )} 23 |

Offset: {offsetEstimate} ms

24 |

Round trip: {roundTripEstimate} ms

25 | 26 |
27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /apps/client/src/components/room/RoomInfo.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useRoomStore } from "@/store/room"; 3 | import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"; 4 | 5 | export const RoomInfo = () => { 6 | // Get room information directly from the store 7 | const roomId = useRoomStore((state) => state.roomId); 8 | const username = useRoomStore((state) => state.username); 9 | const userId = useRoomStore((state) => state.userId); 10 | 11 | return ( 12 | 13 | 14 | Room Information 15 | 16 | 17 |
18 |
19 | Room ID 20 | {roomId} 21 |
22 |
23 | Username 24 | {username} 25 |
26 |
27 | User ID 28 | {userId} 29 |
30 |
31 |
32 |
33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /apps/client/src/components/room/SocketStatus.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; // Import cn from utils 2 | import { useGlobalStore } from "@/store/global"; 3 | import { useEffect, useState } from "react"; 4 | 5 | export const SocketStatus = () => { 6 | const socket = useGlobalStore((state) => state.socket); 7 | const [isFlashing, setIsFlashing] = useState(false); 8 | 9 | // Get socket status 10 | const getStatus = () => { 11 | if (!socket) return "disconnected"; 12 | 13 | // WebSocket readyState values: 14 | // 0 - Connecting 15 | // 1 - Open 16 | // 2 - Closing 17 | // 3 - Closed 18 | 19 | switch (socket.readyState) { 20 | case WebSocket.CONNECTING: 21 | return "connecting"; 22 | case WebSocket.OPEN: 23 | return "connected"; 24 | case WebSocket.CLOSING: 25 | return "closing"; 26 | case WebSocket.CLOSED: 27 | return "disconnected"; 28 | default: 29 | return "unknown"; 30 | } 31 | }; 32 | 33 | const status = getStatus(); 34 | 35 | // Status color mapping 36 | const statusColors = { 37 | disconnected: "bg-red-500", 38 | connecting: "bg-yellow-500", 39 | connected: "bg-green-500", 40 | closing: "bg-orange-500", 41 | unknown: "bg-gray-500", 42 | }; 43 | 44 | // Status text mapping 45 | const statusText = { 46 | disconnected: "Disconnected", 47 | connecting: "Connecting...", 48 | connected: "Connected", 49 | closing: "Closing...", 50 | unknown: "Unknown", 51 | }; 52 | 53 | // Flash effect 54 | useEffect(() => { 55 | const flashInterval = setInterval(() => { 56 | setIsFlashing((prev) => !prev); 57 | }, 500); 58 | 59 | return () => clearInterval(flashInterval); 60 | }, []); 61 | 62 | return ( 63 |
64 | 74 | ); 75 | }; 76 | -------------------------------------------------------------------------------- /apps/client/src/components/room/SpatialAudioBackground.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useGlobalStore } from "@/store/global"; 3 | import { useRoomStore } from "@/store/room"; 4 | import { motion } from "motion/react"; 5 | 6 | export const SpatialAudioBackground = () => { 7 | const userId = useRoomStore((state) => state.userId); 8 | const spatialConfig = useGlobalStore((state) => state.spatialConfig); 9 | 10 | // Get the current user's gain value (0 to 1), default to 0 if not found 11 | const gain = spatialConfig?.gains[userId]?.gain ?? 0; 12 | 13 | // If gain is 0, don't render anything 14 | if (gain <= 0) return null; 15 | 16 | return ( 17 | <> 18 | 24 | 30 | 31 | {/* Additional color spots */} 32 | 45 | 46 | 60 | 61 | 75 | 76 | {/* New highlight spots for extra pop */} 77 | 91 | 92 | 107 | 108 | ); 109 | }; 110 | -------------------------------------------------------------------------------- /apps/client/src/components/room/TopBar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useGlobalStore } from "@/store/global"; 3 | import { Github, Hash, Users } from "lucide-react"; 4 | import { AnimatePresence, motion } from "motion/react"; 5 | import Link from "next/link"; 6 | import { SyncProgress } from "../ui/SyncProgress"; 7 | 8 | interface TopBarProps { 9 | roomId: string; 10 | } 11 | 12 | export const TopBar = ({ roomId }: TopBarProps) => { 13 | const isLoadingAudio = useGlobalStore((state) => state.isInitingSystem); 14 | const isSynced = useGlobalStore((state) => state.isSynced); 15 | const roundTripEstimate = useGlobalStore((state) => state.roundTripEstimate); 16 | const sendNTPRequest = useGlobalStore((state) => state.sendNTPRequest); 17 | const resetNTPConfig = useGlobalStore((state) => state.resetNTPConfig); 18 | const pauseAudio = useGlobalStore((state) => state.pauseAudio); 19 | const connectedClients = useGlobalStore((state) => state.connectedClients); 20 | const setIsLoadingAudio = useGlobalStore((state) => state.setIsInitingSystem); 21 | const clockOffset = useGlobalStore((state) => state.offsetEstimate); 22 | const resync = () => { 23 | try { 24 | pauseAudio({ when: 0 }); 25 | } catch (error) { 26 | console.error("Failed to pause audio:", error); 27 | } 28 | resetNTPConfig(); 29 | sendNTPRequest(); 30 | setIsLoadingAudio(true); 31 | }; 32 | 33 | // Show minimal nav bar when synced and not loading 34 | if (!isLoadingAudio && isSynced) { 35 | return ( 36 |
37 |
38 | 42 | Beatsync 43 | 44 |
45 |
46 | Synced 47 |
48 |
49 | 50 | {roomId} 51 |
52 |
53 | 54 | 55 | 56 | {connectedClients.length}{" "} 57 | {connectedClients.length === 1 ? "user" : "users"} 58 | 59 | 60 |
61 | {/* Hide separator on small screens */} 62 |
|
63 | {/* Hide Offset/RTT on small screens */} 64 |
65 | Offset: {clockOffset.toFixed(2)}ms 66 | 67 | RTT: {roundTripEstimate.toFixed(2)}ms 68 | 69 |
70 | {/* Hide separator on small screens */} 71 |
|
72 | {/* Hide Full Sync button on small screens */} 73 | 79 |
80 | 81 | {/* GitHub icon in the top right */} 82 | 88 | 89 | 90 |
91 | ); 92 | } 93 | 94 | // Use the existing SyncProgress component for loading/syncing states 95 | return ( 96 | 97 | {isLoadingAudio && ( 98 | 99 | 100 | 101 | )} 102 | 103 | ); 104 | }; 105 | -------------------------------------------------------------------------------- /apps/client/src/components/room/WebSocketManager.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { fetchAudio } from "@/lib/api"; 3 | import { RawAudioSource } from "@/lib/localTypes"; 4 | import { trimFileName } from "@/lib/utils"; 5 | import { useGlobalStore } from "@/store/global"; 6 | import { useRoomStore } from "@/store/room"; 7 | import { NTPMeasurement } from "@/utils/ntp"; 8 | import { 9 | epochNow, 10 | NTPResponseMessageType, 11 | WSResponseSchema, 12 | } from "@beatsync/shared"; 13 | import { useEffect } from "react"; 14 | import { toast } from "sonner"; 15 | 16 | // Helper function for NTP response handling 17 | const handleNTPResponse = (response: NTPResponseMessageType) => { 18 | const t3 = epochNow(); 19 | const { t0, t1, t2 } = response; 20 | 21 | // Calculate round-trip delay and clock offset 22 | // See: https://en.wikipedia.org/wiki/Network_Time_Protocol#Clock_synchronization_algorithm 23 | const clockOffset = (t1 - t0 + (t2 - t3)) / 2; 24 | const roundTripDelay = t3 - t0 - (t2 - t1); 25 | 26 | const measurement: NTPMeasurement = { 27 | t0, 28 | t1, 29 | t2, 30 | t3, 31 | roundTripDelay, 32 | clockOffset, 33 | }; 34 | 35 | return measurement; 36 | }; 37 | 38 | interface WebSocketManagerProps { 39 | roomId: string; 40 | username: string; 41 | } 42 | 43 | // No longer need the props interface 44 | export const WebSocketManager = ({ 45 | roomId, 46 | username, 47 | }: WebSocketManagerProps) => { 48 | // Room state 49 | const isLoadingRoom = useRoomStore((state) => state.isLoadingRoom); 50 | const setUserId = useRoomStore((state) => state.setUserId); 51 | 52 | // WebSocket and audio state 53 | const setSocket = useGlobalStore((state) => state.setSocket); 54 | const socket = useGlobalStore((state) => state.socket); 55 | const schedulePlay = useGlobalStore((state) => state.schedulePlay); 56 | const schedulePause = useGlobalStore((state) => state.schedulePause); 57 | const processSpatialConfig = useGlobalStore( 58 | (state) => state.processSpatialConfig 59 | ); 60 | const sendNTPRequest = useGlobalStore((state) => state.sendNTPRequest); 61 | const addNTPMeasurement = useGlobalStore((state) => state.addNTPMeasurement); 62 | const addAudioSource = useGlobalStore((state) => state.addAudioSource); 63 | const hasDownloadedAudio = useGlobalStore( 64 | (state) => state.hasDownloadedAudio 65 | ); 66 | const setConnectedClients = useGlobalStore( 67 | (state) => state.setConnectedClients 68 | ); 69 | const isSpatialAudioEnabled = useGlobalStore( 70 | (state) => state.isSpatialAudioEnabled 71 | ); 72 | const setIsSpatialAudioEnabled = useGlobalStore( 73 | (state) => state.setIsSpatialAudioEnabled 74 | ); 75 | const processStopSpatialAudio = useGlobalStore( 76 | (state) => state.processStopSpatialAudio 77 | ); 78 | 79 | // Once room has been loaded, connect to the websocket 80 | useEffect(() => { 81 | // Only run this effect once after room is loaded 82 | if (isLoadingRoom || !roomId || !username) return; 83 | console.log("Connecting to websocket"); 84 | 85 | // Don't create a new connection if we already have one 86 | if (socket) { 87 | return; 88 | } 89 | 90 | const SOCKET_URL = `${process.env.NEXT_PUBLIC_WS_URL}?roomId=${roomId}&username=${username}`; 91 | console.log("Creating new socket to", SOCKET_URL); 92 | const ws = new WebSocket(SOCKET_URL); 93 | setSocket(ws); 94 | 95 | ws.onopen = () => { 96 | console.log("Websocket onopen fired."); 97 | 98 | // Start syncing 99 | sendNTPRequest(); 100 | }; 101 | 102 | ws.onclose = () => { 103 | console.log("Websocket onclose fired."); 104 | }; 105 | 106 | ws.onmessage = async (msg) => { 107 | const response = WSResponseSchema.parse(JSON.parse(msg.data)); 108 | 109 | if (response.type === "NTP_RESPONSE") { 110 | const ntpMeasurement = handleNTPResponse(response); 111 | addNTPMeasurement(ntpMeasurement); 112 | 113 | // Check that we have not exceeded the max and then send another NTP request 114 | setTimeout(() => { 115 | sendNTPRequest(); 116 | }, 30); // 30ms delay to not overload 117 | } else if (response.type === "ROOM_EVENT") { 118 | const { event } = response; 119 | console.log("Room event:", event); 120 | 121 | if (event.type === "CLIENT_CHANGE") { 122 | setConnectedClients(event.clients); 123 | } else if (event.type === "NEW_AUDIO_SOURCE") { 124 | console.log("Received new audio source:", response); 125 | const { title, id } = event; 126 | 127 | // Check if we already have this audio file downloaded 128 | if (hasDownloadedAudio(id)) { 129 | console.log(`Audio file ${id} already downloaded, skipping fetch`); 130 | return; 131 | } 132 | 133 | toast.promise( 134 | fetchAudio(id) 135 | .then(async (blob) => { 136 | console.log("Audio fetched successfully:", id); 137 | try { 138 | const arrayBuffer = await blob.arrayBuffer(); 139 | console.log("ArrayBuffer created successfully"); 140 | 141 | const audioSource: RawAudioSource = { 142 | name: trimFileName(title), 143 | audioBuffer: arrayBuffer, 144 | id: id, // Include ID in the RawAudioSource 145 | }; 146 | 147 | return addAudioSource(audioSource); 148 | } catch (error) { 149 | console.error("Error processing audio data:", error); 150 | throw new Error("Failed to process audio data"); 151 | } 152 | }) 153 | .catch((error) => { 154 | console.error("Error fetching audio:", error); 155 | throw error; 156 | }), 157 | { 158 | loading: "Loading audio...", 159 | success: `Added: ${title}`, 160 | error: "Failed to load audio", 161 | } 162 | ); 163 | } 164 | } else if (response.type === "SCHEDULED_ACTION") { 165 | // handle scheduling action 166 | console.log("Received scheduled action:", response); 167 | const { scheduledAction, serverTimeToExecute } = response; 168 | 169 | if (scheduledAction.type === "PLAY") { 170 | schedulePlay({ 171 | trackTimeSeconds: scheduledAction.trackTimeSeconds, 172 | targetServerTime: serverTimeToExecute, 173 | audioId: scheduledAction.audioId, 174 | }); 175 | } else if (scheduledAction.type === "PAUSE") { 176 | schedulePause({ 177 | targetServerTime: serverTimeToExecute, 178 | }); 179 | } else if (scheduledAction.type === "SPATIAL_CONFIG") { 180 | processSpatialConfig(scheduledAction); 181 | if (!isSpatialAudioEnabled) { 182 | setIsSpatialAudioEnabled(true); 183 | } 184 | } else if (scheduledAction.type === "STOP_SPATIAL_AUDIO") { 185 | processStopSpatialAudio(); 186 | } 187 | } else if (response.type === "SET_CLIENT_ID") { 188 | setUserId(response.clientId); 189 | } else { 190 | console.log("Unknown response type:", response); 191 | } 192 | }; 193 | 194 | return () => { 195 | // Runs on unmount and dependency change 196 | console.log("Running cleanup for WebSocket connection"); 197 | ws.close(); 198 | }; 199 | // Not including socket in the dependency array because it will trigger the close when it's set 200 | }, [isLoadingRoom, roomId, username]); 201 | 202 | return null; // This is a non-visual component 203 | }; 204 | -------------------------------------------------------------------------------- /apps/client/src/components/room/scrollbar.css: -------------------------------------------------------------------------------- 1 | /* Custom Scrollbar Styles */ 2 | .scrollbar-thin::-webkit-scrollbar { 3 | width: 6px; 4 | } 5 | 6 | .scrollbar-thin::-webkit-scrollbar-track { 7 | background: transparent; 8 | } 9 | 10 | .scrollbar-thin::-webkit-scrollbar-thumb { 11 | background-color: rgba(156, 163, 175, 0.1); 12 | border-radius: 9999px; 13 | } 14 | 15 | .scrollbar-thin::-webkit-scrollbar-thumb:hover { 16 | background-color: rgba(156, 163, 175, 0.2); 17 | } 18 | 19 | .scrollbar-thin { 20 | scrollbar-width: thin; 21 | scrollbar-color: rgba(156, 163, 175, 0.1) transparent; 22 | } 23 | 24 | .scrollbar-thin:hover { 25 | scrollbar-color: rgba(156, 163, 175, 0.2) transparent; 26 | } 27 | 28 | /* Hide scrollbar when not needed */ 29 | .scrollbar-thin::-webkit-scrollbar-thumb { 30 | visibility: auto; 31 | } 32 | 33 | .scrollbar-thin::-webkit-scrollbar-thumb:vertical:only-child { 34 | visibility: hidden; 35 | } 36 | 37 | /* Scrollbar styling for Firefox */ 38 | .scrollbar-thumb-rounded-md { 39 | scrollbar-width: thin; 40 | } 41 | 42 | .scrollbar-thumb-muted-foreground\/10 { 43 | scrollbar-color: rgba(156, 163, 175, 0.1) transparent; 44 | } 45 | 46 | .hover\:scrollbar-thumb-muted-foreground\/20:hover { 47 | scrollbar-color: rgba(156, 163, 175, 0.2) transparent; 48 | } 49 | -------------------------------------------------------------------------------- /apps/client/src/components/ui/accordion.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AccordionPrimitive from "@radix-ui/react-accordion" 5 | import { ChevronDownIcon } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | function Accordion({ 10 | ...props 11 | }: React.ComponentProps) { 12 | return 13 | } 14 | 15 | function AccordionItem({ 16 | className, 17 | ...props 18 | }: React.ComponentProps) { 19 | return ( 20 | 25 | ) 26 | } 27 | 28 | function AccordionTrigger({ 29 | className, 30 | children, 31 | ...props 32 | }: React.ComponentProps) { 33 | return ( 34 | 35 | svg]:rotate-180", 39 | className 40 | )} 41 | {...props} 42 | > 43 | {children} 44 | 45 | 46 | 47 | ) 48 | } 49 | 50 | function AccordionContent({ 51 | className, 52 | children, 53 | ...props 54 | }: React.ComponentProps) { 55 | return ( 56 | 61 |
{children}
62 |
63 | ) 64 | } 65 | 66 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } 67 | -------------------------------------------------------------------------------- /apps/client/src/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Avatar({ 9 | className, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 21 | ) 22 | } 23 | 24 | function AvatarImage({ 25 | className, 26 | ...props 27 | }: React.ComponentProps) { 28 | return ( 29 | 34 | ) 35 | } 36 | 37 | function AvatarFallback({ 38 | className, 39 | ...props 40 | }: React.ComponentProps) { 41 | return ( 42 | 50 | ) 51 | } 52 | 53 | export { Avatar, AvatarImage, AvatarFallback } 54 | -------------------------------------------------------------------------------- /apps/client/src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const badgeVariants = cva( 8 | "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", 14 | secondary: 15 | "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", 16 | destructive: 17 | "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 18 | outline: 19 | "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", 20 | }, 21 | }, 22 | defaultVariants: { 23 | variant: "default", 24 | }, 25 | } 26 | ) 27 | 28 | function Badge({ 29 | className, 30 | variant, 31 | asChild = false, 32 | ...props 33 | }: React.ComponentProps<"span"> & 34 | VariantProps & { asChild?: boolean }) { 35 | const Comp = asChild ? Slot : "span" 36 | 37 | return ( 38 | 43 | ) 44 | } 45 | 46 | export { Badge, badgeVariants } 47 | -------------------------------------------------------------------------------- /apps/client/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40", 16 | outline: 17 | "border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2 has-[>svg]:px-3", 25 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", 26 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4", 27 | icon: "size-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | function Button({ 38 | className, 39 | variant, 40 | size, 41 | asChild = false, 42 | ...props 43 | }: React.ComponentProps<"button"> & 44 | VariantProps & { 45 | asChild?: boolean 46 | }) { 47 | const Comp = asChild ? Slot : "button" 48 | 49 | return ( 50 | 55 | ) 56 | } 57 | 58 | export { Button, buttonVariants } 59 | -------------------------------------------------------------------------------- /apps/client/src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Card({ className, ...props }: React.ComponentProps<"div">) { 6 | return ( 7 |
15 | ) 16 | } 17 | 18 | function CardHeader({ className, ...props }: React.ComponentProps<"div">) { 19 | return ( 20 |
25 | ) 26 | } 27 | 28 | function CardTitle({ className, ...props }: React.ComponentProps<"div">) { 29 | return ( 30 |
35 | ) 36 | } 37 | 38 | function CardDescription({ className, ...props }: React.ComponentProps<"div">) { 39 | return ( 40 |
45 | ) 46 | } 47 | 48 | function CardContent({ className, ...props }: React.ComponentProps<"div">) { 49 | return ( 50 |
55 | ) 56 | } 57 | 58 | function CardFooter({ className, ...props }: React.ComponentProps<"div">) { 59 | return ( 60 |
65 | ) 66 | } 67 | 68 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 69 | -------------------------------------------------------------------------------- /apps/client/src/components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" 5 | import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | function DropdownMenu({ 10 | ...props 11 | }: React.ComponentProps) { 12 | return 13 | } 14 | 15 | function DropdownMenuPortal({ 16 | ...props 17 | }: React.ComponentProps) { 18 | return ( 19 | 20 | ) 21 | } 22 | 23 | function DropdownMenuTrigger({ 24 | ...props 25 | }: React.ComponentProps) { 26 | return ( 27 | 31 | ) 32 | } 33 | 34 | function DropdownMenuContent({ 35 | className, 36 | sideOffset = 4, 37 | ...props 38 | }: React.ComponentProps) { 39 | return ( 40 | 41 | 50 | 51 | ) 52 | } 53 | 54 | function DropdownMenuGroup({ 55 | ...props 56 | }: React.ComponentProps) { 57 | return ( 58 | 59 | ) 60 | } 61 | 62 | function DropdownMenuItem({ 63 | className, 64 | inset, 65 | variant = "default", 66 | ...props 67 | }: React.ComponentProps & { 68 | inset?: boolean 69 | variant?: "default" | "destructive" 70 | }) { 71 | return ( 72 | 82 | ) 83 | } 84 | 85 | function DropdownMenuCheckboxItem({ 86 | className, 87 | children, 88 | checked, 89 | ...props 90 | }: React.ComponentProps) { 91 | return ( 92 | 101 | 102 | 103 | 104 | 105 | 106 | {children} 107 | 108 | ) 109 | } 110 | 111 | function DropdownMenuRadioGroup({ 112 | ...props 113 | }: React.ComponentProps) { 114 | return ( 115 | 119 | ) 120 | } 121 | 122 | function DropdownMenuRadioItem({ 123 | className, 124 | children, 125 | ...props 126 | }: React.ComponentProps) { 127 | return ( 128 | 136 | 137 | 138 | 139 | 140 | 141 | {children} 142 | 143 | ) 144 | } 145 | 146 | function DropdownMenuLabel({ 147 | className, 148 | inset, 149 | ...props 150 | }: React.ComponentProps & { 151 | inset?: boolean 152 | }) { 153 | return ( 154 | 163 | ) 164 | } 165 | 166 | function DropdownMenuSeparator({ 167 | className, 168 | ...props 169 | }: React.ComponentProps) { 170 | return ( 171 | 176 | ) 177 | } 178 | 179 | function DropdownMenuShortcut({ 180 | className, 181 | ...props 182 | }: React.ComponentProps<"span">) { 183 | return ( 184 | 192 | ) 193 | } 194 | 195 | function DropdownMenuSub({ 196 | ...props 197 | }: React.ComponentProps) { 198 | return 199 | } 200 | 201 | function DropdownMenuSubTrigger({ 202 | className, 203 | inset, 204 | children, 205 | ...props 206 | }: React.ComponentProps & { 207 | inset?: boolean 208 | }) { 209 | return ( 210 | 219 | {children} 220 | 221 | 222 | ) 223 | } 224 | 225 | function DropdownMenuSubContent({ 226 | className, 227 | ...props 228 | }: React.ComponentProps) { 229 | return ( 230 | 238 | ) 239 | } 240 | 241 | export { 242 | DropdownMenu, 243 | DropdownMenuPortal, 244 | DropdownMenuTrigger, 245 | DropdownMenuContent, 246 | DropdownMenuGroup, 247 | DropdownMenuLabel, 248 | DropdownMenuItem, 249 | DropdownMenuCheckboxItem, 250 | DropdownMenuRadioGroup, 251 | DropdownMenuRadioItem, 252 | DropdownMenuSeparator, 253 | DropdownMenuShortcut, 254 | DropdownMenuSub, 255 | DropdownMenuSubTrigger, 256 | DropdownMenuSubContent, 257 | } 258 | -------------------------------------------------------------------------------- /apps/client/src/components/ui/input-otp.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { OTPInput, OTPInputContext } from "input-otp" 5 | import { MinusIcon } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | function InputOTP({ 10 | className, 11 | containerClassName, 12 | ...props 13 | }: React.ComponentProps & { 14 | containerClassName?: string 15 | }) { 16 | return ( 17 | 26 | ) 27 | } 28 | 29 | function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) { 30 | return ( 31 |
36 | ) 37 | } 38 | 39 | function InputOTPSlot({ 40 | index, 41 | className, 42 | ...props 43 | }: React.ComponentProps<"div"> & { 44 | index: number 45 | }) { 46 | const inputOTPContext = React.useContext(OTPInputContext) 47 | const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {} 48 | 49 | return ( 50 |
59 | {char} 60 | {hasFakeCaret && ( 61 |
62 |
63 |
64 | )} 65 |
66 | ) 67 | } 68 | 69 | function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) { 70 | return ( 71 |
72 | 73 |
74 | ) 75 | } 76 | 77 | export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator } 78 | -------------------------------------------------------------------------------- /apps/client/src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Input({ className, type, ...props }: React.ComponentProps<"input">) { 6 | return ( 7 | 18 | ) 19 | } 20 | 21 | export { Input } 22 | -------------------------------------------------------------------------------- /apps/client/src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Label({ 9 | className, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 21 | ) 22 | } 23 | 24 | export { Label } 25 | -------------------------------------------------------------------------------- /apps/client/src/components/ui/old-slider.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SliderPrimitive from "@radix-ui/react-slider" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Slider({ 9 | className, 10 | defaultValue, 11 | value, 12 | min = 0, 13 | max = 100, 14 | ...props 15 | }: React.ComponentProps) { 16 | const _values = React.useMemo( 17 | () => 18 | Array.isArray(value) 19 | ? value 20 | : Array.isArray(defaultValue) 21 | ? defaultValue 22 | : [min, max], 23 | [value, defaultValue, min, max] 24 | ) 25 | 26 | return ( 27 | 39 | 45 | 51 | 52 | {Array.from({ length: _values.length }, (_, index) => ( 53 | 58 | ))} 59 | 60 | ) 61 | } 62 | 63 | export { Slider } 64 | -------------------------------------------------------------------------------- /apps/client/src/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as PopoverPrimitive from "@radix-ui/react-popover" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Popover({ 9 | ...props 10 | }: React.ComponentProps) { 11 | return 12 | } 13 | 14 | function PopoverTrigger({ 15 | ...props 16 | }: React.ComponentProps) { 17 | return 18 | } 19 | 20 | function PopoverContent({ 21 | className, 22 | align = "center", 23 | sideOffset = 4, 24 | ...props 25 | }: React.ComponentProps) { 26 | return ( 27 | 28 | 38 | 39 | ) 40 | } 41 | 42 | function PopoverAnchor({ 43 | ...props 44 | }: React.ComponentProps) { 45 | return 46 | } 47 | 48 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } 49 | -------------------------------------------------------------------------------- /apps/client/src/components/ui/select.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SelectPrimitive from "@radix-ui/react-select" 5 | import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | function Select({ 10 | ...props 11 | }: React.ComponentProps) { 12 | return 13 | } 14 | 15 | function SelectGroup({ 16 | ...props 17 | }: React.ComponentProps) { 18 | return 19 | } 20 | 21 | function SelectValue({ 22 | ...props 23 | }: React.ComponentProps) { 24 | return 25 | } 26 | 27 | function SelectTrigger({ 28 | className, 29 | children, 30 | ...props 31 | }: React.ComponentProps) { 32 | return ( 33 | 41 | {children} 42 | 43 | 44 | 45 | 46 | ) 47 | } 48 | 49 | function SelectContent({ 50 | className, 51 | children, 52 | position = "popper", 53 | ...props 54 | }: React.ComponentProps) { 55 | return ( 56 | 57 | 68 | 69 | 76 | {children} 77 | 78 | 79 | 80 | 81 | ) 82 | } 83 | 84 | function SelectLabel({ 85 | className, 86 | ...props 87 | }: React.ComponentProps) { 88 | return ( 89 | 94 | ) 95 | } 96 | 97 | function SelectItem({ 98 | className, 99 | children, 100 | ...props 101 | }: React.ComponentProps) { 102 | return ( 103 | 111 | 112 | 113 | 114 | 115 | 116 | {children} 117 | 118 | ) 119 | } 120 | 121 | function SelectSeparator({ 122 | className, 123 | ...props 124 | }: React.ComponentProps) { 125 | return ( 126 | 131 | ) 132 | } 133 | 134 | function SelectScrollUpButton({ 135 | className, 136 | ...props 137 | }: React.ComponentProps) { 138 | return ( 139 | 147 | 148 | 149 | ) 150 | } 151 | 152 | function SelectScrollDownButton({ 153 | className, 154 | ...props 155 | }: React.ComponentProps) { 156 | return ( 157 | 165 | 166 | 167 | ) 168 | } 169 | 170 | export { 171 | Select, 172 | SelectContent, 173 | SelectGroup, 174 | SelectItem, 175 | SelectLabel, 176 | SelectScrollDownButton, 177 | SelectScrollUpButton, 178 | SelectSeparator, 179 | SelectTrigger, 180 | SelectValue, 181 | } 182 | -------------------------------------------------------------------------------- /apps/client/src/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SeparatorPrimitive from "@radix-ui/react-separator" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Separator({ 9 | className, 10 | orientation = "horizontal", 11 | decorative = true, 12 | ...props 13 | }: React.ComponentProps) { 14 | return ( 15 | 25 | ) 26 | } 27 | 28 | export { Separator } 29 | -------------------------------------------------------------------------------- /apps/client/src/components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | function Skeleton({ className, ...props }: React.ComponentProps<"div">) { 4 | return ( 5 |
10 | ) 11 | } 12 | 13 | export { Skeleton } 14 | -------------------------------------------------------------------------------- /apps/client/src/components/ui/slider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as SliderPrimitive from "@radix-ui/react-slider"; 4 | import * as React from "react"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | function Slider({ 9 | className, 10 | defaultValue, 11 | value, 12 | min = 0, 13 | max = 100, 14 | ...props 15 | }: React.ComponentProps) { 16 | const _values = React.useMemo( 17 | () => 18 | Array.isArray(value) 19 | ? value 20 | : Array.isArray(defaultValue) 21 | ? defaultValue 22 | : [min, max], 23 | [value, defaultValue, min, max] 24 | ); 25 | 26 | return ( 27 | 39 | 45 | 51 | 52 | {Array.from({ length: _values.length }, (_, index) => ( 53 | 58 | ))} 59 | 60 | ); 61 | } 62 | 63 | export { Slider }; 64 | -------------------------------------------------------------------------------- /apps/client/src/components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useTheme } from "next-themes"; 4 | import { Toaster as Sonner, ToasterProps } from "sonner"; 5 | 6 | const Toaster = ({ ...props }: ToasterProps) => { 7 | const { theme = "dark" } = useTheme(); 8 | 9 | return ( 10 | 27 | ); 28 | }; 29 | 30 | export { Toaster }; 31 | -------------------------------------------------------------------------------- /apps/client/src/components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as SwitchPrimitive from "@radix-ui/react-switch"; 4 | import * as React from "react"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | function Switch({ 9 | className, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 21 | 27 | 28 | ); 29 | } 30 | 31 | export { Switch }; 32 | -------------------------------------------------------------------------------- /apps/client/src/components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TabsPrimitive from "@radix-ui/react-tabs" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Tabs({ 9 | className, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 18 | ) 19 | } 20 | 21 | function TabsList({ 22 | className, 23 | ...props 24 | }: React.ComponentProps) { 25 | return ( 26 | 34 | ) 35 | } 36 | 37 | function TabsTrigger({ 38 | className, 39 | ...props 40 | }: React.ComponentProps) { 41 | return ( 42 | 50 | ) 51 | } 52 | 53 | function TabsContent({ 54 | className, 55 | ...props 56 | }: React.ComponentProps) { 57 | return ( 58 | 63 | ) 64 | } 65 | 66 | export { Tabs, TabsList, TabsTrigger, TabsContent } 67 | -------------------------------------------------------------------------------- /apps/client/src/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const TooltipProvider = TooltipPrimitive.Provider 9 | 10 | const Tooltip = TooltipPrimitive.Root 11 | 12 | const TooltipTrigger = TooltipPrimitive.Trigger 13 | 14 | const TooltipContent = React.forwardRef< 15 | React.ElementRef, 16 | React.ComponentPropsWithoutRef 17 | >(({ className, sideOffset = 4, ...props }, ref) => ( 18 | 27 | )) 28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName 29 | 30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } 31 | -------------------------------------------------------------------------------- /apps/client/src/lib/api.ts: -------------------------------------------------------------------------------- 1 | import { UploadAudioType } from "@beatsync/shared"; 2 | import axios from "axios"; 3 | 4 | const BASE_URL = process.env.NEXT_PUBLIC_API_URL; 5 | if (!BASE_URL) { 6 | throw new Error("NEXT_PUBLIC_API_URL is not set"); 7 | } 8 | 9 | const baseAxios = axios.create({ 10 | baseURL: BASE_URL, 11 | }); 12 | 13 | export const uploadAudioFile = async (data: UploadAudioType) => { 14 | try { 15 | const response = await baseAxios.post<{ 16 | success: boolean; 17 | filename: string; 18 | path: string; 19 | size: number; 20 | }>("/upload", data); 21 | 22 | return response.data; 23 | } catch (error) { 24 | if (axios.isAxiosError(error)) { 25 | throw new Error( 26 | error.response?.data?.message || "Failed to upload audio file" 27 | ); 28 | } 29 | throw error; 30 | } 31 | }; 32 | 33 | export const fetchAudio = async (id: string) => { 34 | try { 35 | const response = await fetch(`${BASE_URL}/audio`, { 36 | method: "POST", 37 | headers: { 38 | "Content-Type": "application/json", 39 | }, 40 | body: JSON.stringify({ id }), 41 | }); 42 | 43 | if (!response.ok) { 44 | console.error(`RESPONSE NOT OK`); 45 | throw new Error(`Failed to fetch audio: ${response.statusText}`); 46 | } 47 | 48 | return await response.blob(); 49 | } catch (error) { 50 | if (axios.isAxiosError(error)) { 51 | throw new Error(error.response?.data?.message || "Failed to fetch audio"); 52 | } 53 | throw error; 54 | } 55 | }; 56 | -------------------------------------------------------------------------------- /apps/client/src/lib/audio.ts: -------------------------------------------------------------------------------- 1 | export const loadAudio = async (url: string) => { 2 | const audioContext = new AudioContext(); 3 | const response = await fetch(url); 4 | const arrayBuffer = await response.arrayBuffer(); 5 | const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); 6 | return audioBuffer; 7 | }; 8 | 9 | export async function playSound(url: string) { 10 | const audioContext = new AudioContext(); 11 | 12 | // Fetch the audio file 13 | const response = await fetch(url); 14 | const arrayBuffer = await response.arrayBuffer(); 15 | 16 | // Decode the audio data 17 | const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); 18 | 19 | // Create a source node 20 | const sourceNode = audioContext.createBufferSource(); 21 | sourceNode.buffer = audioBuffer; 22 | 23 | // Connect to destination and play 24 | sourceNode.connect(audioContext.destination); 25 | sourceNode.start(); 26 | } 27 | -------------------------------------------------------------------------------- /apps/client/src/lib/localTypes.ts: -------------------------------------------------------------------------------- 1 | export interface LocalAudioSource { 2 | name: string; 3 | audioBuffer: AudioBuffer; 4 | id: string; // Add ID field for tracking 5 | } 6 | 7 | export interface RawAudioSource { 8 | name: string; 9 | audioBuffer: ArrayBuffer; 10 | id: string; // Optional ID for tracking downloaded audio files 11 | } 12 | -------------------------------------------------------------------------------- /apps/client/src/lib/randomNames.ts: -------------------------------------------------------------------------------- 1 | const ADJECTIVES: string[] = [ 2 | "adaptable", 3 | "adventurous", 4 | "affectionate", 5 | "agreeable", 6 | "ambitious", 7 | "amiable", 8 | "amusing", 9 | "ardent", 10 | "attentive", 11 | "audacious", 12 | "awesome", 13 | "brave", 14 | "bright", 15 | "brilliant", 16 | "calm", 17 | "careful", 18 | "charming", 19 | "cheerful", 20 | "clever", 21 | "convivial", 22 | "courageous", 23 | "courteous", 24 | "creative", 25 | "curious", 26 | "daring", 27 | "delightful", 28 | "determined", 29 | "diligent", 30 | "dynamic", 31 | "eager", 32 | "easygoing", 33 | "efficient", 34 | "energetic", 35 | "enthusiastic", 36 | "epic", 37 | "excellent", 38 | "exceptional", 39 | "extraordinary", 40 | "fabulous", 41 | "fair", 42 | "faithful", 43 | "fantastic", 44 | "fearless", 45 | "friendly", 46 | "funny", 47 | "generous", 48 | "gentle", 49 | "genuine", 50 | "gifted", 51 | "glad", 52 | "goodhearted", 53 | "graceful", 54 | "gracious", 55 | "happy", 56 | "harmonious", 57 | "helpful", 58 | "honest", 59 | "hopeful", 60 | "humorous", 61 | "imaginative", 62 | "ingenious", 63 | "innovative", 64 | "inspiring", 65 | "intelligent", 66 | "interesting", 67 | "joyful", 68 | "jovial", 69 | "keen", 70 | "kind", 71 | "lively", 72 | "lovely", 73 | "loving", 74 | "loyal", 75 | "lucky", 76 | "magnificent", 77 | "mindful", 78 | "modest", 79 | "motivated", 80 | "noble", 81 | "optimistic", 82 | "passionate", 83 | "patient", 84 | "peaceful", 85 | "pleasant", 86 | "playful", 87 | "polite", 88 | "positive", 89 | "powerful", 90 | "practical", 91 | "precious", 92 | "prestigious", 93 | "proactive", 94 | "productive", 95 | "prominent", 96 | "proud", 97 | "quick-witted", 98 | "radiant", 99 | "reliable", 100 | "resilient", 101 | "resourceful", 102 | ]; 103 | 104 | const ANIMALS: string[] = [ 105 | "aardvark", 106 | "albatross", 107 | "alligator", 108 | "alpaca", 109 | "anteater", 110 | "antelope", 111 | "armadillo", 112 | "baboon", 113 | "badger", 114 | "bat", 115 | "bear", 116 | "beaver", 117 | "bison", 118 | "boar", 119 | "buffalo", 120 | "butterfly", 121 | "camel", 122 | "capybara", 123 | "caribou", 124 | "cat", 125 | "caterpillar", 126 | "cheetah", 127 | "chicken", 128 | "chimpanzee", 129 | "chinchilla", 130 | "cicada", 131 | "cobra", 132 | "cockatoo", 133 | "cricket", 134 | "crocodile", 135 | "crow", 136 | "deer", 137 | "dingo", 138 | "dog", 139 | "dolphin", 140 | "donkey", 141 | "dove", 142 | "dragonfly", 143 | "duck", 144 | "eagle", 145 | "earthworm", 146 | "eel", 147 | "elephant", 148 | "elk", 149 | "emu", 150 | "falcon", 151 | "ferret", 152 | "finch", 153 | "flamingo", 154 | "fox", 155 | "frog", 156 | "gazelle", 157 | "gerbil", 158 | "giraffe", 159 | "goat", 160 | "goose", 161 | "gorilla", 162 | "grasshopper", 163 | "hamster", 164 | "hare", 165 | "hawk", 166 | "hedgehog", 167 | "heron", 168 | "hippopotamus", 169 | "horse", 170 | "hummingbird", 171 | "hyena", 172 | "ibex", 173 | "iguana", 174 | "impala", 175 | "jaguar", 176 | "jellyfish", 177 | "kangaroo", 178 | "koala", 179 | "krill", 180 | "lemur", 181 | "leopard", 182 | "lion", 183 | "lizard", 184 | "llama", 185 | "lobster", 186 | "lynx", 187 | "meerkat", 188 | "mink", 189 | "mole", 190 | "mongoose", 191 | "monkey", 192 | "moose", 193 | "mosquito", 194 | "mouse", 195 | "narwhal", 196 | "newt", 197 | "octopus", 198 | "okapi", 199 | "opossum", 200 | "orangutan", 201 | "ostrich", 202 | "otter", 203 | "owl", 204 | "ox", 205 | ]; 206 | 207 | /** 208 | * Generates a random name based on the provided configuration. 209 | */ 210 | export function generateName(): string { 211 | const separator = "-"; 212 | const adjectiveIndex = Math.floor(Math.random() * ADJECTIVES.length); 213 | const animalIndex = Math.floor(Math.random() * ANIMALS.length); 214 | 215 | return `${ADJECTIVES[adjectiveIndex]}${separator}${ANIMALS[animalIndex]}`.toLowerCase(); 216 | } 217 | -------------------------------------------------------------------------------- /apps/client/src/lib/room.ts: -------------------------------------------------------------------------------- 1 | export const validatePartialRoomId = (roomId: string) => { 2 | return /^\d*$/.test(roomId); 3 | }; 4 | 5 | export const validateFullRoomId = (roomId: string) => { 6 | return /^[0-9]{6}$/.test(roomId); 7 | }; 8 | 9 | export const createUserId = () => { 10 | if (window.crypto && crypto.randomUUID) { 11 | return crypto.randomUUID(); 12 | } else { 13 | // Fallback for insecure contexts 14 | return ( 15 | Math.random().toString(36).substring(2, 15) + 16 | Math.random().toString(36).substring(2, 15) 17 | ); 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /apps/client/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | 8 | /** 9 | * Format time in seconds to MM:SS format 10 | */ 11 | export function formatTime(seconds: number): string { 12 | if (!seconds || isNaN(seconds)) return "00:00"; 13 | 14 | const minutes = Math.floor(seconds / 60); 15 | const remainingSeconds = Math.floor(seconds % 60); 16 | 17 | return `${minutes.toString().padStart(2, "0")}:${remainingSeconds 18 | .toString() 19 | .padStart(2, "0")}`; 20 | } 21 | 22 | export const trimFileName = (fileName: string) => { 23 | // Remove file extensions like .mp3, .wav, etc. 24 | return fileName.replace(/\.[^/.]+$/, ""); 25 | }; 26 | -------------------------------------------------------------------------------- /apps/client/src/store/room.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { create } from "zustand"; 3 | 4 | // Interface for just the state values (without methods) 5 | interface RoomStateValues { 6 | roomId: string; 7 | username: string; 8 | userId: string; 9 | isLoadingRoom: boolean; 10 | } 11 | 12 | interface RoomState extends RoomStateValues { 13 | setRoomId: (roomId: string) => void; 14 | setUsername: (username: string) => void; 15 | setUserId: (userId: string) => void; 16 | setIsLoading: (isLoading: boolean) => void; 17 | reset: () => void; 18 | } 19 | 20 | // Define initial state object 21 | const initialState: RoomStateValues = { 22 | roomId: "", 23 | username: "", 24 | userId: "", 25 | isLoadingRoom: false, 26 | }; 27 | 28 | export const useRoomStore = create()((set) => ({ 29 | // Set initial state 30 | ...initialState, 31 | 32 | // Actions 33 | setRoomId: (roomId) => set({ roomId }), 34 | setUsername: (username) => set({ username }), 35 | setUserId: (userId) => set({ userId }), 36 | setIsLoading: (isLoading) => set({ isLoadingRoom: isLoading }), 37 | 38 | // Reset to initial state 39 | reset: () => 40 | set((state) => ({ 41 | ...initialState, 42 | username: state.username, // Preserve the current username 43 | })), 44 | })); 45 | -------------------------------------------------------------------------------- /apps/client/src/utils/ntp.ts: -------------------------------------------------------------------------------- 1 | import { ClientActionEnum, epochNow } from "@beatsync/shared"; 2 | import { sendWSRequest } from "./ws"; 3 | 4 | export interface NTPMeasurement { 5 | t0: number; 6 | t1: number; 7 | t2: number; 8 | t3: number; 9 | roundTripDelay: number; 10 | clockOffset: number; 11 | } 12 | 13 | export const _sendNTPRequest = (ws: WebSocket) => { 14 | if (ws.readyState !== WebSocket.OPEN) { 15 | throw new Error("Cannot send NTP request: WebSocket is not open"); 16 | } 17 | 18 | const t0 = epochNow(); 19 | sendWSRequest({ 20 | ws, 21 | request: { 22 | type: ClientActionEnum.enum.NTP_REQUEST, 23 | t0, 24 | }, 25 | }); 26 | }; 27 | 28 | export const calculateOffsetEstimate = (ntpMeasurements: NTPMeasurement[]) => { 29 | // We take the best half of the measurements 30 | // We take the half of the requests with the smallest round-trip delays because higher delays are probably due to random network conditions 31 | const sortedMeasurements = [...ntpMeasurements].sort( 32 | (a, b) => a.roundTripDelay - b.roundTripDelay 33 | ); 34 | const bestMeasurements = sortedMeasurements.slice( 35 | 0, 36 | Math.ceil(sortedMeasurements.length / 2) 37 | ); 38 | 39 | // Calculate average round trip from all measurements 40 | const totalRoundTrip = ntpMeasurements.reduce( 41 | (sum, m) => sum + m.roundTripDelay, 42 | 0 43 | ); 44 | const averageRoundTrip = totalRoundTrip / ntpMeasurements.length; 45 | 46 | // But only use the best measurements for offset calculation 47 | const totalOffset = bestMeasurements.reduce( 48 | (sum, m) => sum + m.clockOffset, 49 | 0 50 | ); 51 | const averageOffset = totalOffset / bestMeasurements.length; 52 | 53 | const result = { averageOffset, averageRoundTrip }; 54 | console.log("New clock offset calculated:", result); 55 | 56 | return result; 57 | }; 58 | 59 | export const calculateWaitTimeMilliseconds = ( 60 | targetServerTime: number, 61 | clockOffset: number 62 | ): number => { 63 | const estimatedCurrentServerTime = epochNow() + clockOffset; 64 | return Math.max(0, targetServerTime - estimatedCurrentServerTime); 65 | }; 66 | -------------------------------------------------------------------------------- /apps/client/src/utils/time.ts: -------------------------------------------------------------------------------- 1 | export const formatTimeMicro = (timeMs: number): string => { 2 | const milliseconds = Math.floor(timeMs) % 1000; 3 | const seconds = Math.floor(timeMs / 1000) % 60; 4 | const minutes = Math.floor(timeMs / 60000); 5 | 6 | return `${minutes.toString().padStart(2, "0")}:${seconds 7 | .toString() 8 | .padStart(2, "0")}.${milliseconds.toString().padStart(3, "0")}`; 9 | }; 10 | -------------------------------------------------------------------------------- /apps/client/src/utils/ws.ts: -------------------------------------------------------------------------------- 1 | import { WSRequestType } from "@beatsync/shared"; 2 | 3 | export const sendWSRequest = ({ 4 | ws, 5 | request, 6 | }: { 7 | ws: WebSocket; 8 | request: WSRequestType; 9 | }) => { 10 | ws.send(JSON.stringify(request)); 11 | }; 12 | -------------------------------------------------------------------------------- /apps/client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /apps/server/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | public/audio 43 | -------------------------------------------------------------------------------- /apps/server/README.md: -------------------------------------------------------------------------------- 1 | To install dependencies: 2 | ```sh 3 | bun install 4 | ``` 5 | 6 | To run: 7 | ```sh 8 | bun run dev 9 | ``` 10 | 11 | open http://localhost:3000 12 | -------------------------------------------------------------------------------- /apps/server/bun.lock: -------------------------------------------------------------------------------- 1 | { 2 | "lockfileVersion": 1, 3 | "workspaces": { 4 | "": { 5 | "name": "server", 6 | "dependencies": { 7 | "@distube/ytdl-core": "^4.16.4", 8 | "hono": "^4.7.2", 9 | "zod": "^3.24.2", 10 | }, 11 | "devDependencies": { 12 | "@types/bun": "latest", 13 | }, 14 | }, 15 | }, 16 | "packages": { 17 | "@distube/ytdl-core": ["@distube/ytdl-core@4.16.4", "", { "dependencies": { "http-cookie-agent": "^6.0.8", "https-proxy-agent": "^7.0.6", "m3u8stream": "^0.8.6", "miniget": "^4.2.3", "sax": "^1.4.1", "tough-cookie": "^5.1.0", "undici": "^7.3.0" } }, "sha512-r0ZPMMB5rbUSQSez//dYDWjPSAEOm6eeV+9gyR+1vngGYFUi953Z/CoF4epTBS40X8dR32gyH3ERlh7NbnCaRg=="], 18 | 19 | "@types/bun": ["@types/bun@1.2.4", "", { "dependencies": { "bun-types": "1.2.4" } }, "sha512-QtuV5OMR8/rdKJs213iwXDpfVvnskPXY/S0ZiFbsTjQZycuqPbMW8Gf/XhLfwE5njW8sxI2WjISURXPlHypMFA=="], 20 | 21 | "@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], 22 | 23 | "@types/ws": ["@types/ws@8.5.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw=="], 24 | 25 | "agent-base": ["agent-base@7.1.3", "", {}, "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw=="], 26 | 27 | "bun-types": ["bun-types@1.2.4", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-nDPymR207ZZEoWD4AavvEaa/KZe/qlrbMSchqpQwovPZCKc7pwMoENjEtHgMKaAjJhy+x6vfqSBA1QU3bJgs0Q=="], 28 | 29 | "debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], 30 | 31 | "hono": ["hono@4.7.2", "", {}, "sha512-8V5XxoOF6SI12jkHkzX/6aLBMU5GEF5g387EjVSQipS0DlxWgWGSMeEayY3CRBjtTUQYwLHx9JYouWqKzy2Vng=="], 32 | 33 | "http-cookie-agent": ["http-cookie-agent@6.0.8", "", { "dependencies": { "agent-base": "^7.1.3" }, "peerDependencies": { "tough-cookie": "^4.0.0 || ^5.0.0", "undici": "^5.11.0 || ^6.0.0" }, "optionalPeers": ["undici"] }, "sha512-qnYh3yLSr2jBsTYkw11elq+T361uKAJaZ2dR4cfYZChw1dt9uL5t3zSUwehoqqVb4oldk1BpkXKm2oat8zV+oA=="], 34 | 35 | "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], 36 | 37 | "m3u8stream": ["m3u8stream@0.8.6", "", { "dependencies": { "miniget": "^4.2.2", "sax": "^1.2.4" } }, "sha512-LZj8kIVf9KCphiHmH7sbFQTVe4tOemb202fWwvJwR9W5ENW/1hxJN6ksAWGhQgSBSa3jyWhnjKU1Fw1GaOdbyA=="], 38 | 39 | "miniget": ["miniget@4.2.3", "", {}, "sha512-SjbDPDICJ1zT+ZvQwK0hUcRY4wxlhhNpHL9nJOB2MEAXRGagTljsO8MEDzQMTFf0Q8g4QNi8P9lEm/g7e+qgzA=="], 40 | 41 | "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], 42 | 43 | "sax": ["sax@1.4.1", "", {}, "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg=="], 44 | 45 | "tldts": ["tldts@6.1.83", "", { "dependencies": { "tldts-core": "^6.1.83" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-FHxxNJJ0WNsEBPHyC1oesQb3rRoxpuho/z2g3zIIAhw1WHJeQsUzK1jYK8TI1/iClaa4fS3Z2TCA9mtxXsENSg=="], 46 | 47 | "tldts-core": ["tldts-core@6.1.83", "", {}, "sha512-I2wb9OJc6rXyh9d4aInhSNWChNI+ra6qDnFEGEwe9OoA68lE4Temw29bOkf1Uvwt8VZS079t1BFZdXVBmmB4dw=="], 48 | 49 | "tough-cookie": ["tough-cookie@5.1.2", "", { "dependencies": { "tldts": "^6.1.32" } }, "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A=="], 50 | 51 | "undici": ["undici@7.4.0", "", {}, "sha512-PUQM3/es3noM24oUn10u3kNNap0AbxESOmnssmW+dOi9yGwlUSi5nTNYl3bNbTkWOF8YZDkx2tCmj9OtQ3iGGw=="], 52 | 53 | "undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], 54 | 55 | "zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="], 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /apps/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "scripts": { 4 | "dev": "bun run --hot src/index.ts", 5 | "start": "bun run src/index.ts" 6 | }, 7 | "dependencies": { 8 | "@beatsync/shared": "workspace:*", 9 | "@distube/ytdl-core": "^4.16.4", 10 | "hono": "^4.7.2", 11 | "nanoid": "^5.1.5", 12 | "zod": "^3.24.2" 13 | }, 14 | "devDependencies": { 15 | "@types/bun": "latest" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /apps/server/src/config.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | 3 | // Path configurations 4 | export const AUDIO_DIR = path.join(process.cwd(), "uploads", "audio"); 5 | 6 | // Audio settings 7 | export const AUDIO_LOW = 0.15; 8 | export const AUDIO_HIGH = 1.0; 9 | export const VOLUME_UP_RAMP_TIME = 0.5; 10 | export const VOLUME_DOWN_RAMP_TIME = 0.5; 11 | 12 | // Scheduling settings 13 | export const SCHEDULE_TIME_MS = 750; 14 | -------------------------------------------------------------------------------- /apps/server/src/index.ts: -------------------------------------------------------------------------------- 1 | import { handleGetAudio } from "./routes/audio"; 2 | import { handleRoot } from "./routes/root"; 3 | import { handleStats } from "./routes/stats"; 4 | import { handleUpload } from "./routes/upload"; 5 | import { handleWebSocketUpgrade } from "./routes/websocket"; 6 | import { 7 | handleClose, 8 | handleMessage, 9 | handleOpen, 10 | } from "./routes/websocketHandlers"; 11 | import { corsHeaders, errorResponse } from "./utils/responses"; 12 | import { WSData } from "./utils/websocket"; 13 | 14 | // Bun.serve with WebSocket support 15 | const server = Bun.serve({ 16 | hostname: "0.0.0.0", 17 | port: 8080, 18 | async fetch(req, server) { 19 | const url = new URL(req.url); 20 | 21 | // Handle CORS preflight requests 22 | if (req.method === "OPTIONS") { 23 | return new Response(null, { headers: corsHeaders }); 24 | } 25 | 26 | try { 27 | switch (url.pathname) { 28 | case "/": 29 | return handleRoot(req); 30 | 31 | case "/ws": 32 | return handleWebSocketUpgrade(req, server); 33 | 34 | case "/upload": 35 | return handleUpload(req, server); 36 | 37 | case "/audio": 38 | return handleGetAudio(req, server); 39 | 40 | case "/stats": 41 | return handleStats(req); 42 | 43 | default: 44 | return errorResponse("Not found", 404); 45 | } 46 | } catch (err) { 47 | return errorResponse("Internal server error", 500); 48 | } 49 | }, 50 | 51 | websocket: { 52 | open(ws) { 53 | handleOpen(ws, server); 54 | }, 55 | 56 | message(ws, message) { 57 | handleMessage(ws, message, server); 58 | }, 59 | 60 | close(ws) { 61 | handleClose(ws, server); 62 | }, 63 | }, 64 | }); 65 | 66 | console.log(`HTTP listening on http://${server.hostname}:${server.port}`); 67 | -------------------------------------------------------------------------------- /apps/server/src/routes/audio.ts: -------------------------------------------------------------------------------- 1 | import { GetAudioSchema } from "@beatsync/shared"; 2 | import { Server } from "bun"; 3 | import * as path from "path"; 4 | import { AUDIO_DIR } from "../config"; 5 | import { errorResponse } from "../utils/responses"; 6 | 7 | export const handleGetAudio = async (req: Request, server: Server) => { 8 | try { 9 | // Check if it's a POST request 10 | if (req.method !== "POST") { 11 | return errorResponse("Method not allowed", 405); 12 | } 13 | 14 | // Check content type 15 | const contentType = req.headers.get("content-type"); 16 | if (!contentType || !contentType.includes("application/json")) { 17 | return errorResponse("Content-Type must be application/json", 400); 18 | } 19 | 20 | // Parse and validate the request body 21 | const rawBody = await req.json(); 22 | const parseResult = GetAudioSchema.safeParse(rawBody); 23 | 24 | if (!parseResult.success) { 25 | return errorResponse( 26 | `Invalid request data: ${parseResult.error.message}`, 27 | 400 28 | ); 29 | } 30 | 31 | const { id } = parseResult.data; 32 | 33 | // Create a BunFile reference to the audio file 34 | // The id already contains the room-specific path 35 | const audioPath = path.join(AUDIO_DIR, id); 36 | const file = Bun.file(audioPath); 37 | 38 | // Check if file exists using Bun.file's exists() method 39 | if (!(await file.exists())) { 40 | return errorResponse("Audio file not found", 404); 41 | } 42 | 43 | // Return the file directly as the response body 44 | return new Response(file, { 45 | headers: { 46 | "Content-Type": "audio/mpeg", 47 | "Content-Length": file.size.toString(), 48 | "Access-Control-Allow-Origin": "*", 49 | }, 50 | }); 51 | } catch (error) { 52 | console.error("Error handling audio request:", error); 53 | return errorResponse("Failed to process audio request", 500); 54 | } 55 | }; 56 | -------------------------------------------------------------------------------- /apps/server/src/routes/root.ts: -------------------------------------------------------------------------------- 1 | export const handleRoot = (req: Request) => { 2 | return new Response("Hello Hono!"); 3 | }; 4 | -------------------------------------------------------------------------------- /apps/server/src/routes/stats.ts: -------------------------------------------------------------------------------- 1 | import { existsSync } from "node:fs"; 2 | import { readdir } from "node:fs/promises"; 3 | import type { CpuInfo } from "os"; 4 | import * as os from "os"; 5 | import { AUDIO_DIR } from "../config"; 6 | import { roomManager } from "../roomManager"; 7 | import { corsHeaders } from "../utils/responses"; 8 | 9 | export async function handleStats(req: Request): Promise { 10 | const cpus = os.cpus(); 11 | const totalMemory = os.totalmem(); 12 | const freeMemory = os.freemem(); 13 | const usedMemory = totalMemory - freeMemory; 14 | const memoryUsage = process.memoryUsage(); // rss, heapTotal, heapUsed, external, arrayBuffers 15 | 16 | const stats = { 17 | cpu: { 18 | count: cpus.length, 19 | cores: cpus.map((core: CpuInfo) => ({ 20 | model: core.model, 21 | speed: core.speed, 22 | })), 23 | }, 24 | memory: { 25 | total: `${(totalMemory / 1024 / 1024 / 1024).toFixed(2)} GB`, 26 | free: `${(freeMemory / 1024 / 1024 / 1024).toFixed(2)} GB`, 27 | used: `${(usedMemory / 1024 / 1024 / 1024).toFixed(2)} GB`, 28 | process: { 29 | rss: `${(memoryUsage.rss / 1024 / 1024).toFixed(2)} MB`, 30 | heapTotal: `${(memoryUsage.heapTotal / 1024 / 1024).toFixed(2)} MB`, 31 | heapUsed: `${(memoryUsage.heapUsed / 1024 / 1024).toFixed(2)} MB`, 32 | external: `${(memoryUsage.external / 1024 / 1024).toFixed(2)} MB`, 33 | arrayBuffers: `${(memoryUsage.arrayBuffers / 1024 / 1024).toFixed( 34 | 2 35 | )} MB`, 36 | }, 37 | }, 38 | }; 39 | 40 | // --- Add Room Manager Stats --- 41 | const roomDetails = Object.fromEntries( 42 | Array.from(roomManager.rooms.entries()).map(([roomId, roomData]) => [ 43 | roomId, 44 | { 45 | clientCount: roomData.clients.size, 46 | // Add other room-specific details if needed 47 | }, 48 | ]) 49 | ); 50 | 51 | // --- Add Audio Directory Stats --- 52 | let audioDirStats: Record = { 53 | path: AUDIO_DIR, 54 | exists: false, 55 | roomFolders: 0, 56 | error: null, 57 | }; 58 | try { 59 | if (existsSync(AUDIO_DIR)) { 60 | audioDirStats.exists = true; 61 | const entries = await readdir(AUDIO_DIR, { withFileTypes: true }); 62 | audioDirStats.roomFolders = entries.filter( 63 | (entry) => entry.isDirectory() && entry.name.startsWith("room-") 64 | ).length; 65 | // Could add total size calculation here if needed (e.g., using du) 66 | } 67 | } catch (err: any) { 68 | console.error("Error reading audio directory:", err); 69 | audioDirStats.error = err.message; 70 | } 71 | // --- Combine stats --- 72 | const combinedStats = { 73 | ...stats, // Existing CPU and Memory stats 74 | rooms: { 75 | total: roomManager.rooms.size, 76 | details: roomDetails, 77 | }, 78 | audioStorage: audioDirStats, 79 | }; 80 | 81 | return new Response(JSON.stringify(combinedStats), { 82 | headers: { ...corsHeaders, "Content-Type": "application/json" }, 83 | }); 84 | } 85 | -------------------------------------------------------------------------------- /apps/server/src/routes/upload.ts: -------------------------------------------------------------------------------- 1 | import { Server } from "bun"; 2 | 3 | import { UploadAudioSchema } from "@beatsync/shared"; 4 | import { mkdir } from "node:fs/promises"; 5 | import * as path from "path"; 6 | import { AUDIO_DIR } from "../config"; 7 | import { errorResponse, jsonResponse, sendBroadcast } from "../utils/responses"; 8 | 9 | export const handleUpload = async (req: Request, server: Server) => { 10 | try { 11 | // Check if it's a POST request 12 | if (req.method !== "POST") { 13 | return errorResponse("Method not allowed", 405); 14 | } 15 | 16 | // Check content type 17 | const contentType = req.headers.get("content-type"); 18 | if (!contentType || !contentType.includes("application/json")) { 19 | return errorResponse("Content-Type must be application/json", 400); 20 | } 21 | 22 | // Parse and validate the request body using Zod schema 23 | const rawBody = await req.json(); 24 | const parseResult = UploadAudioSchema.safeParse(rawBody); 25 | 26 | if (!parseResult.success) { 27 | return errorResponse( 28 | `Invalid request data: ${parseResult.error.message}`, 29 | 400 30 | ); 31 | } 32 | 33 | const { name, audioData, roomId } = parseResult.data; 34 | 35 | // Create room-specific directory if it doesn't exist 36 | const roomDir = path.join(AUDIO_DIR, `room-${roomId}`); 37 | await mkdir(roomDir, { recursive: true }); 38 | 39 | // Generate unique filename with timestamp 40 | const timestamp = Date.now(); 41 | const ext = path.extname(name) || ".mp3"; // Preserve original extension or default to mp3 42 | const filename = `${timestamp}${ext}`; 43 | 44 | // The ID that will be used for retrieving the file (includes room path) 45 | const fileId = path.join(`room-${roomId}`, filename); 46 | // Full path to the file 47 | const filePath = path.join(AUDIO_DIR, fileId); 48 | 49 | // Decode base64 audio data and write to file using Bun.write 50 | const audioBuffer = Buffer.from(audioData, "base64"); 51 | await Bun.write(filePath, audioBuffer); 52 | 53 | sendBroadcast({ 54 | server, 55 | roomId, 56 | message: { 57 | type: "ROOM_EVENT", 58 | event: { 59 | type: "NEW_AUDIO_SOURCE", 60 | id: fileId, 61 | title: name, // Keep original name for display 62 | duration: 1, // TODO: lol calculate this later properly 63 | addedAt: Date.now(), 64 | addedBy: roomId, 65 | }, 66 | }, 67 | }); 68 | 69 | // Return success response with the file details 70 | return jsonResponse({ 71 | success: true, 72 | }); // Wait for the broadcast to be received. 73 | } catch (error) { 74 | console.error("Error handling upload:", error); 75 | return errorResponse("Failed to process upload", 500); 76 | } 77 | }; 78 | -------------------------------------------------------------------------------- /apps/server/src/routes/websocket.ts: -------------------------------------------------------------------------------- 1 | import { Server } from "bun"; 2 | import { nanoid } from "nanoid"; 3 | import { errorResponse } from "../utils/responses"; 4 | import { WSData } from "../utils/websocket"; 5 | 6 | export const handleWebSocketUpgrade = (req: Request, server: Server) => { 7 | const url = new URL(req.url); 8 | const roomId = url.searchParams.get("roomId"); 9 | const username = url.searchParams.get("username"); 10 | 11 | if (!roomId || !username) { 12 | // Check which parameters are missing and log them 13 | const missingParams = []; 14 | 15 | if (!roomId) missingParams.push("roomId"); 16 | if (!username) missingParams.push("username"); 17 | 18 | console.log( 19 | `WebSocket connection attempt missing parameters: ${missingParams.join( 20 | ", " 21 | )}` 22 | ); 23 | 24 | return errorResponse("roomId and userId are required"); 25 | } 26 | 27 | const clientId = nanoid(); 28 | console.log(`User ${username} joined room ${roomId} with userId ${clientId}`); 29 | 30 | const data: WSData = { 31 | roomId, 32 | username, 33 | clientId, 34 | }; 35 | 36 | // Upgrade the connection with the WSData context 37 | const upgraded = server.upgrade(req, { 38 | data, 39 | }); 40 | 41 | if (!upgraded) { 42 | return errorResponse("WebSocket upgrade failed"); 43 | } 44 | 45 | return undefined; 46 | }; 47 | -------------------------------------------------------------------------------- /apps/server/src/routes/websocketHandlers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ClientActionEnum, 3 | epochNow, 4 | WSBroadcastType, 5 | WSRequestSchema, 6 | } from "@beatsync/shared"; 7 | import { Server, ServerWebSocket } from "bun"; 8 | import { SCHEDULE_TIME_MS } from "../config"; 9 | import { roomManager } from "../roomManager"; 10 | import { sendBroadcast, sendUnicast } from "../utils/responses"; 11 | import { WSData } from "../utils/websocket"; 12 | 13 | const createClientUpdate = (roomId: string) => { 14 | const message: WSBroadcastType = { 15 | type: "ROOM_EVENT", 16 | event: { 17 | type: ClientActionEnum.Enum.CLIENT_CHANGE, 18 | clients: roomManager.getClients(roomId), 19 | }, 20 | }; 21 | return message; 22 | }; 23 | 24 | export const handleOpen = (ws: ServerWebSocket, server: Server) => { 25 | console.log( 26 | `WebSocket connection opened for user ${ws.data.username} in room ${ws.data.roomId}` 27 | ); 28 | sendUnicast({ 29 | ws, 30 | message: { 31 | type: "SET_CLIENT_ID", 32 | clientId: ws.data.clientId, 33 | }, 34 | }); 35 | 36 | const { roomId } = ws.data; 37 | ws.subscribe(roomId); 38 | 39 | roomManager.addClient(ws); 40 | 41 | const message = createClientUpdate(roomId); 42 | sendBroadcast({ server, roomId, message }); 43 | }; 44 | 45 | export const handleMessage = async ( 46 | ws: ServerWebSocket, 47 | message: string | Buffer, 48 | server: Server 49 | ) => { 50 | const t1 = epochNow(); 51 | const { roomId, username } = ws.data; 52 | 53 | try { 54 | const parsedData = JSON.parse(message.toString()); 55 | const parsedMessage = WSRequestSchema.parse(parsedData); 56 | 57 | console.log( 58 | `Room: ${roomId} | User: ${username} | Message: ${JSON.stringify( 59 | parsedMessage 60 | )}` 61 | ); 62 | 63 | // NTP Request 64 | if (parsedMessage.type === ClientActionEnum.enum.NTP_REQUEST) { 65 | sendUnicast({ 66 | ws, 67 | message: { 68 | type: "NTP_RESPONSE", 69 | t0: parsedMessage.t0, // Echo back the client's t0 70 | t1, // Server receive time 71 | t2: epochNow(), // Server send time 72 | }, 73 | }); 74 | 75 | return; 76 | } else if ( 77 | parsedMessage.type === ClientActionEnum.enum.PLAY || 78 | parsedMessage.type === ClientActionEnum.enum.PAUSE 79 | ) { 80 | sendBroadcast({ 81 | server, 82 | roomId, 83 | message: { 84 | type: "SCHEDULED_ACTION", 85 | scheduledAction: parsedMessage, 86 | serverTimeToExecute: epochNow() + SCHEDULE_TIME_MS, // 500 ms from now 87 | // TODO: Make the longest RTT + some amount instead of hardcoded this breaks for long RTTs 88 | }, 89 | }); 90 | 91 | return; 92 | } else if ( 93 | parsedMessage.type === ClientActionEnum.enum.START_SPATIAL_AUDIO 94 | ) { 95 | // Start loop only if not already started 96 | const room = roomManager.getRoomState(roomId); 97 | if (!room || room.intervalId) return; // do nothing if no room or interval already exists 98 | 99 | roomManager.startInterval({ server, roomId }); 100 | } else if ( 101 | parsedMessage.type === ClientActionEnum.enum.STOP_SPATIAL_AUDIO 102 | ) { 103 | // This important for 104 | const message: WSBroadcastType = { 105 | type: "SCHEDULED_ACTION", 106 | scheduledAction: { 107 | type: "STOP_SPATIAL_AUDIO", 108 | }, 109 | serverTimeToExecute: epochNow() + 0, 110 | }; 111 | 112 | // Reset all gains: 113 | sendBroadcast({ server, roomId, message }); 114 | 115 | // Stop the spatial audio interval if it exists 116 | const room = roomManager.getRoomState(roomId); 117 | if (!room || !room.intervalId) return; // do nothing if no room or no interval exists 118 | 119 | roomManager.stopInterval(roomId); 120 | } else if (parsedMessage.type === ClientActionEnum.enum.REUPLOAD_AUDIO) { 121 | // Handle reupload request by broadcasting the audio source again 122 | // This will trigger clients that don't have this audio to download it 123 | sendBroadcast({ 124 | server, 125 | roomId, 126 | message: { 127 | type: "ROOM_EVENT", 128 | event: { 129 | type: "NEW_AUDIO_SOURCE", 130 | id: parsedMessage.audioId, // Use the existing file ID 131 | title: parsedMessage.audioName, // Use the original name 132 | duration: 1, // TODO: Calculate properly 133 | addedAt: Date.now(), 134 | addedBy: roomId, 135 | }, 136 | }, 137 | }); 138 | } else if (parsedMessage.type === ClientActionEnum.enum.REORDER_CLIENT) { 139 | // Handle client reordering 140 | const reorderedClients = roomManager.reorderClients({ 141 | roomId, 142 | clientId: parsedMessage.clientId, 143 | server, 144 | }); 145 | 146 | // Broadcast the updated client order to all clients 147 | sendBroadcast({ 148 | server, 149 | roomId, 150 | message: { 151 | type: "ROOM_EVENT", 152 | event: { 153 | type: ClientActionEnum.Enum.CLIENT_CHANGE, 154 | clients: reorderedClients, 155 | }, 156 | }, 157 | }); 158 | } else if ( 159 | parsedMessage.type === ClientActionEnum.enum.SET_LISTENING_SOURCE 160 | ) { 161 | // Handle listening source update 162 | roomManager.updateListeningSource({ 163 | roomId, 164 | position: parsedMessage, 165 | server, 166 | }); 167 | } else if (parsedMessage.type === ClientActionEnum.enum.MOVE_CLIENT) { 168 | // Handle client move 169 | roomManager.moveClient({ parsedMessage, roomId, server }); 170 | } else { 171 | console.log(`UNRECOGNIZED MESSAGE: ${JSON.stringify(parsedMessage)}`); 172 | } 173 | } catch (error) { 174 | console.error("Invalid message format:", error); 175 | ws.send( 176 | JSON.stringify({ type: "ERROR", message: "Invalid message format" }) 177 | ); 178 | } 179 | }; 180 | 181 | export const handleClose = (ws: ServerWebSocket, server: Server) => { 182 | console.log( 183 | `WebSocket connection closed for user ${ws.data.username} in room ${ws.data.roomId}` 184 | ); 185 | ws.unsubscribe(ws.data.roomId); 186 | 187 | roomManager.removeClient(ws.data.roomId, ws.data.clientId); 188 | 189 | const message = createClientUpdate(ws.data.roomId); 190 | server.publish(ws.data.roomId, JSON.stringify(message)); 191 | }; 192 | -------------------------------------------------------------------------------- /apps/server/src/spatial.ts: -------------------------------------------------------------------------------- 1 | import { PositionType } from "@beatsync/shared/types/basic"; 2 | 3 | function calculateEuclideanDistance( 4 | p1: PositionType, 5 | p2: PositionType 6 | ): number { 7 | const dx = p1.x - p2.x; 8 | const dy = p1.y - p2.y; 9 | return Math.sqrt(dx * dx + dy * dy); 10 | } 11 | 12 | interface GainParams { 13 | client: PositionType; 14 | source: PositionType; 15 | falloff?: number; 16 | minGain?: number; 17 | maxGain?: number; 18 | } 19 | 20 | export const calculateGainFromDistanceToSource = (params: GainParams) => { 21 | return gainFromDistanceQuadratic(params); 22 | }; 23 | 24 | export function gainFromDistanceExp({ 25 | client, 26 | source, 27 | falloff = 0.05, 28 | minGain = 0.15, 29 | maxGain = 1.0, 30 | }: GainParams): number { 31 | const distance = calculateEuclideanDistance(client, source); 32 | const gain = maxGain * Math.exp(-falloff * distance); 33 | return Math.max(minGain, gain); 34 | } 35 | 36 | export function gainFromDistanceLinear({ 37 | client, 38 | source, 39 | falloff = 0.01, 40 | minGain = 0.15, 41 | maxGain = 1.0, 42 | }: GainParams): number { 43 | const distance = calculateEuclideanDistance(client, source); 44 | // Linear falloff: gain decreases linearly with distance 45 | const gain = maxGain - falloff * distance; 46 | return Math.max(minGain, gain); 47 | } 48 | 49 | export function gainFromDistanceQuadratic({ 50 | client, 51 | source, 52 | falloff = 0.001, 53 | minGain = 0.15, 54 | maxGain = 1.0, 55 | }: GainParams): number { 56 | const distance = calculateEuclideanDistance(client, source); 57 | // Quadratic falloff: gain decreases with square of distance 58 | const gain = maxGain - falloff * distance * distance; 59 | return Math.max(minGain, gain); 60 | } 61 | -------------------------------------------------------------------------------- /apps/server/src/utils/responses.ts: -------------------------------------------------------------------------------- 1 | import { WSBroadcastType, WSUnicastType } from "@beatsync/shared"; 2 | import { Server, ServerWebSocket } from "bun"; 3 | import { WSData } from "./websocket"; 4 | 5 | export const corsHeaders = { 6 | "Access-Control-Allow-Origin": "*", 7 | "Access-Control-Allow-Methods": "*", 8 | "Access-Control-Allow-Headers": "*", 9 | }; 10 | 11 | // Helper functions for common responses 12 | export const jsonResponse = (data: any, status = 200) => 13 | new Response(JSON.stringify(data), { 14 | status, 15 | headers: corsHeaders, 16 | }); 17 | 18 | export const errorResponse = (message: string, status = 400) => 19 | new Response(message, { 20 | status, 21 | headers: corsHeaders, 22 | }); 23 | 24 | // Broadcast to all clients in the room 25 | export const sendBroadcast = ({ 26 | server, 27 | roomId, 28 | message, 29 | }: { 30 | server: Server; 31 | roomId: string; 32 | message: WSBroadcastType; 33 | }) => { 34 | server.publish(roomId, JSON.stringify(message)); 35 | }; 36 | 37 | export const sendUnicast = ({ 38 | ws, 39 | message, 40 | }: { 41 | ws: ServerWebSocket; 42 | message: WSUnicastType; 43 | }) => { 44 | ws.send(JSON.stringify(message)); 45 | }; 46 | -------------------------------------------------------------------------------- /apps/server/src/utils/spatial.ts: -------------------------------------------------------------------------------- 1 | import { ClientType, GRID } from "@beatsync/shared"; 2 | 3 | /** 4 | * Positions clients in a circle around a center point 5 | * @param clients Map of clients to position 6 | */ 7 | export function positionClientsInCircle( 8 | clients: Map 9 | ): void { 10 | const clientCount = clients.size; 11 | 12 | // Early return for single client case 13 | if (clientCount === 1) { 14 | // Center the single client explicitly 15 | const client = clients.values().next().value!; 16 | client.position = { 17 | x: GRID.ORIGIN_X, 18 | y: GRID.ORIGIN_Y - 25, 19 | }; 20 | return; 21 | } 22 | 23 | // Position multiple clients in a circle 24 | let index = 0; 25 | clients.forEach((client) => { 26 | // Calculate position on the circle 27 | const angle = (index / clientCount) * 2 * Math.PI - Math.PI / 2; 28 | client.position = { 29 | x: GRID.ORIGIN_X + GRID.CLIENT_RADIUS * Math.cos(angle), 30 | y: GRID.ORIGIN_Y + GRID.CLIENT_RADIUS * Math.sin(angle), 31 | }; 32 | index++; 33 | }); 34 | } 35 | 36 | /** 37 | * Debug function to print client positions 38 | * @param clients Map of clients to debug 39 | */ 40 | export function debugClientPositions(clients: Map): void { 41 | console.log("Client Positions:"); 42 | clients.forEach((client, id) => { 43 | console.log(`Client ${id}: x=${client.position.x}, y=${client.position.y}`); 44 | }); 45 | } 46 | -------------------------------------------------------------------------------- /apps/server/src/utils/websocket.ts: -------------------------------------------------------------------------------- 1 | export interface WSData { 2 | roomId: string; 3 | clientId: string; 4 | username: string; 5 | } 6 | -------------------------------------------------------------------------------- /apps/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "jsx": "react-jsx", 5 | "jsxImportSource": "hono/jsx" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "beatsync", 3 | "packageManager": "bun@1.2.2", 4 | "scripts": { 5 | "build": "turbo run build", 6 | "dev": "turbo run dev", 7 | "start": "turbo run start", 8 | "server": "turbo start --filter=server", 9 | "client": "turbo start --filter=client" 10 | }, 11 | "workspaces": [ 12 | "apps/*", 13 | "packages/*" 14 | ], 15 | "devDependencies": { 16 | "@types/bun": "latest" 17 | }, 18 | "peerDependencies": { 19 | "typescript": "^5.0.0" 20 | }, 21 | "dependencies": { 22 | "turbo": "^2.4.4" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/shared/.gitignore: -------------------------------------------------------------------------------- 1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore 2 | 3 | # Logs 4 | 5 | logs 6 | _.log 7 | npm-debug.log_ 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | .pnpm-debug.log* 12 | 13 | # Caches 14 | 15 | .cache 16 | 17 | # Diagnostic reports (https://nodejs.org/api/report.html) 18 | 19 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 20 | 21 | # Runtime data 22 | 23 | pids 24 | _.pid 25 | _.seed 26 | *.pid.lock 27 | 28 | # Directory for instrumented libs generated by jscoverage/JSCover 29 | 30 | lib-cov 31 | 32 | # Coverage directory used by tools like istanbul 33 | 34 | coverage 35 | *.lcov 36 | 37 | # nyc test coverage 38 | 39 | .nyc_output 40 | 41 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 42 | 43 | .grunt 44 | 45 | # Bower dependency directory (https://bower.io/) 46 | 47 | bower_components 48 | 49 | # node-waf configuration 50 | 51 | .lock-wscript 52 | 53 | # Compiled binary addons (https://nodejs.org/api/addons.html) 54 | 55 | build/Release 56 | 57 | # Dependency directories 58 | 59 | node_modules/ 60 | jspm_packages/ 61 | 62 | # Snowpack dependency directory (https://snowpack.dev/) 63 | 64 | web_modules/ 65 | 66 | # TypeScript cache 67 | 68 | *.tsbuildinfo 69 | 70 | # Optional npm cache directory 71 | 72 | .npm 73 | 74 | # Optional eslint cache 75 | 76 | .eslintcache 77 | 78 | # Optional stylelint cache 79 | 80 | .stylelintcache 81 | 82 | # Microbundle cache 83 | 84 | .rpt2_cache/ 85 | .rts2_cache_cjs/ 86 | .rts2_cache_es/ 87 | .rts2_cache_umd/ 88 | 89 | # Optional REPL history 90 | 91 | .node_repl_history 92 | 93 | # Output of 'npm pack' 94 | 95 | *.tgz 96 | 97 | # Yarn Integrity file 98 | 99 | .yarn-integrity 100 | 101 | # dotenv environment variable files 102 | 103 | .env 104 | .env.development.local 105 | .env.test.local 106 | .env.production.local 107 | .env.local 108 | 109 | # parcel-bundler cache (https://parceljs.org/) 110 | 111 | .parcel-cache 112 | 113 | # Next.js build output 114 | 115 | .next 116 | out 117 | 118 | # Nuxt.js build / generate output 119 | 120 | .nuxt 121 | dist 122 | 123 | # Gatsby files 124 | 125 | # Comment in the public line in if your project uses Gatsby and not Next.js 126 | 127 | # https://nextjs.org/blog/next-9-1#public-directory-support 128 | 129 | # public 130 | 131 | # vuepress build output 132 | 133 | .vuepress/dist 134 | 135 | # vuepress v2.x temp and cache directory 136 | 137 | .temp 138 | 139 | # Docusaurus cache and generated files 140 | 141 | .docusaurus 142 | 143 | # Serverless directories 144 | 145 | .serverless/ 146 | 147 | # FuseBox cache 148 | 149 | .fusebox/ 150 | 151 | # DynamoDB Local files 152 | 153 | .dynamodb/ 154 | 155 | # TernJS port file 156 | 157 | .tern-port 158 | 159 | # Stores VSCode versions used for testing VSCode extensions 160 | 161 | .vscode-test 162 | 163 | # yarn v2 164 | 165 | .yarn/cache 166 | .yarn/unplugged 167 | .yarn/build-state.yml 168 | .yarn/install-state.gz 169 | .pnp.* 170 | 171 | # IntelliJ based IDEs 172 | .idea 173 | 174 | # Finder (MacOS) folder config 175 | .DS_Store 176 | -------------------------------------------------------------------------------- /packages/shared/README.md: -------------------------------------------------------------------------------- 1 | # shared 2 | 3 | To install dependencies: 4 | 5 | ```bash 6 | bun install 7 | ``` 8 | 9 | To run: 10 | 11 | ```bash 12 | bun run index.ts 13 | ``` 14 | 15 | This project was created using `bun init` in bun v1.2.2. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. 16 | -------------------------------------------------------------------------------- /packages/shared/bun.lock: -------------------------------------------------------------------------------- 1 | { 2 | "lockfileVersion": 1, 3 | "workspaces": { 4 | "": { 5 | "name": "shared", 6 | "dependencies": { 7 | "zod": "^3.24.2", 8 | }, 9 | "devDependencies": { 10 | "@types/bun": "latest", 11 | }, 12 | "peerDependencies": { 13 | "typescript": "^5.0.0", 14 | }, 15 | }, 16 | }, 17 | "packages": { 18 | "@types/bun": ["@types/bun@1.2.4", "", { "dependencies": { "bun-types": "1.2.4" } }, "sha512-QtuV5OMR8/rdKJs213iwXDpfVvnskPXY/S0ZiFbsTjQZycuqPbMW8Gf/XhLfwE5njW8sxI2WjISURXPlHypMFA=="], 19 | 20 | "@types/node": ["@types/node@22.13.10", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw=="], 21 | 22 | "@types/ws": ["@types/ws@8.5.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw=="], 23 | 24 | "bun-types": ["bun-types@1.2.4", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-nDPymR207ZZEoWD4AavvEaa/KZe/qlrbMSchqpQwovPZCKc7pwMoENjEtHgMKaAjJhy+x6vfqSBA1QU3bJgs0Q=="], 25 | 26 | "typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="], 27 | 28 | "undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], 29 | 30 | "zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="], 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/shared/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./types"; 2 | export const epochNow = () => performance.timeOrigin + performance.now(); 3 | -------------------------------------------------------------------------------- /packages/shared/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@beatsync/shared", 3 | "type": "module", 4 | "devDependencies": { 5 | "@types/bun": "latest" 6 | }, 7 | "peerDependencies": { 8 | "typescript": "^5.0.0" 9 | }, 10 | "dependencies": { 11 | "zod": "^3.24.2" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/shared/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": ["ESNext", "DOM"], 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | 22 | // Some stricter flags (disabled by default) 23 | "noUnusedLocals": false, 24 | "noUnusedParameters": false, 25 | "noPropertyAccessFromIndexSignature": false 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/shared/types/HTTPRequest.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const UploadAudioSchema = z.object({ 4 | name: z.string(), 5 | audioData: z.string(), // base64 encoded audio data 6 | roomId: z.string(), 7 | }); 8 | export type UploadAudioType = z.infer; 9 | 10 | export const GetAudioSchema = z.object({ 11 | id: z.string(), 12 | }); 13 | export type GetAudioType = z.infer; 14 | -------------------------------------------------------------------------------- /packages/shared/types/WSBroadcast.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { PauseActionSchema, PlayActionSchema } from "./WSRequest"; 3 | import { PositionSchema } from "./basic"; 4 | 5 | const ClientSchema = z.object({ 6 | username: z.string(), 7 | clientId: z.string(), 8 | ws: z.any(), 9 | rtt: z.number().nonnegative().default(0), // Round-trip time in milliseconds 10 | position: PositionSchema, 11 | }); 12 | export type ClientType = z.infer; 13 | 14 | const ClientChangeMessageSchema = z.object({ 15 | type: z.literal("CLIENT_CHANGE"), 16 | clients: z.array(ClientSchema), 17 | }); 18 | 19 | const AudioSourceSchema = z.object({ 20 | type: z.literal("NEW_AUDIO_SOURCE"), 21 | id: z.string(), 22 | title: z.string(), 23 | duration: z.number().positive(), 24 | thumbnail: z.string().url().optional(), 25 | addedAt: z.number(), 26 | addedBy: z.string(), 27 | }); 28 | export type AudioSourceType = z.infer; 29 | 30 | const SpatialConfigSchema = z.object({ 31 | type: z.literal("SPATIAL_CONFIG"), 32 | gains: z.record( 33 | z.string(), 34 | z.object({ gain: z.number().min(0).max(1), rampTime: z.number() }) 35 | ), 36 | listeningSource: PositionSchema, 37 | }); 38 | 39 | export type SpatialConfigType = z.infer; 40 | 41 | const StopSpatialAudioSchema = z.object({ 42 | type: z.literal("STOP_SPATIAL_AUDIO"), 43 | }); 44 | export type StopSpatialAudioType = z.infer; 45 | 46 | const ScheduledActionSchema = z.object({ 47 | type: z.literal("SCHEDULED_ACTION"), 48 | serverTimeToExecute: z.number(), 49 | scheduledAction: z.discriminatedUnion("type", [ 50 | PlayActionSchema, 51 | PauseActionSchema, 52 | SpatialConfigSchema, 53 | StopSpatialAudioSchema, 54 | ]), 55 | }); 56 | 57 | const RoomEventSchema = z.object({ 58 | type: z.literal("ROOM_EVENT"), 59 | event: z.discriminatedUnion("type", [ 60 | ClientChangeMessageSchema, 61 | AudioSourceSchema, 62 | ]), 63 | }); 64 | 65 | // HERE 66 | export const WSBroadcastSchema = z.discriminatedUnion("type", [ 67 | ScheduledActionSchema, 68 | RoomEventSchema, 69 | ]); 70 | export type WSBroadcastType = z.infer; 71 | -------------------------------------------------------------------------------- /packages/shared/types/WSRequest.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { PositionSchema } from "./basic"; 3 | export const ClientSchema = z.object({ 4 | username: z.string(), 5 | clientId: z.string(), 6 | }); 7 | 8 | export const ClientActionEnum = z.enum([ 9 | "PLAY", 10 | "PAUSE", 11 | "CLIENT_CHANGE", 12 | "NTP_REQUEST", 13 | "START_SPATIAL_AUDIO", 14 | "STOP_SPATIAL_AUDIO", 15 | "REUPLOAD_AUDIO", 16 | "REORDER_CLIENT", 17 | "SET_LISTENING_SOURCE", 18 | "MOVE_CLIENT", 19 | ]); 20 | 21 | export const NTPRequestPacketSchema = z.object({ 22 | type: z.literal(ClientActionEnum.enum.NTP_REQUEST), 23 | t0: z.number(), // Client send timestamp 24 | }); 25 | 26 | export const PlayActionSchema = z.object({ 27 | type: z.literal(ClientActionEnum.enum.PLAY), 28 | trackTimeSeconds: z.number(), 29 | audioId: z.string(), 30 | }); 31 | 32 | export const PauseActionSchema = z.object({ 33 | type: z.literal(ClientActionEnum.enum.PAUSE), 34 | }); 35 | 36 | const StartSpatialAudioSchema = z.object({ 37 | type: z.literal(ClientActionEnum.enum.START_SPATIAL_AUDIO), 38 | }); 39 | 40 | const StopSpatialAudioSchema = z.object({ 41 | type: z.literal(ClientActionEnum.enum.STOP_SPATIAL_AUDIO), 42 | }); 43 | 44 | const ReuploadAudioSchema = z.object({ 45 | type: z.literal(ClientActionEnum.enum.REUPLOAD_AUDIO), 46 | audioId: z.string(), 47 | audioName: z.string(), 48 | }); 49 | 50 | const ReorderClientSchema = z.object({ 51 | type: z.literal(ClientActionEnum.enum.REORDER_CLIENT), 52 | clientId: z.string(), 53 | }); 54 | 55 | const SetListeningSourceSchema = z.object({ 56 | type: z.literal(ClientActionEnum.enum.SET_LISTENING_SOURCE), 57 | x: z.number(), 58 | y: z.number(), 59 | }); 60 | 61 | const MoveClientSchema = z.object({ 62 | type: z.literal(ClientActionEnum.enum.MOVE_CLIENT), 63 | clientId: z.string(), 64 | position: PositionSchema, 65 | }); 66 | export type MoveClientType = z.infer; 67 | 68 | export const WSRequestSchema = z.discriminatedUnion("type", [ 69 | PlayActionSchema, 70 | PauseActionSchema, 71 | NTPRequestPacketSchema, 72 | StartSpatialAudioSchema, 73 | StopSpatialAudioSchema, 74 | ReuploadAudioSchema, 75 | ReorderClientSchema, 76 | SetListeningSourceSchema, 77 | MoveClientSchema, 78 | ]); 79 | export type WSRequestType = z.infer; 80 | export type PlayActionType = z.infer; 81 | export type PauseActionType = z.infer; 82 | export type ReuploadAudioType = z.infer; 83 | export type ReorderClientType = z.infer; 84 | export type SetListeningSourceType = z.infer; 85 | -------------------------------------------------------------------------------- /packages/shared/types/WSResponse.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { WSBroadcastSchema } from "./WSBroadcast"; 3 | import { WSUnicastSchema } from "./WSUnicast"; 4 | export const WSResponseSchema = z.union([WSUnicastSchema, WSBroadcastSchema]); 5 | export type WSResponseType = z.infer; 6 | -------------------------------------------------------------------------------- /packages/shared/types/WSUnicast.ts: -------------------------------------------------------------------------------- 1 | // 1:1 Private WS Responses 2 | import { z } from "zod"; 3 | 4 | const NTPResponseMessageSchema = z.object({ 5 | type: z.literal("NTP_RESPONSE"), 6 | t0: z.number(), // Client send timestamp (echoed back) 7 | t1: z.number(), // Server receive timestamp 8 | t2: z.number(), // Server send timestamp 9 | }); 10 | export type NTPResponseMessageType = z.infer; 11 | 12 | const SetClientID = z.object({ 13 | type: z.literal("SET_CLIENT_ID"), 14 | clientId: z.string(), 15 | }); 16 | 17 | export const WSUnicastSchema = z.discriminatedUnion("type", [ 18 | NTPResponseMessageSchema, 19 | SetClientID, 20 | ]); 21 | export type WSUnicastType = z.infer; 22 | -------------------------------------------------------------------------------- /packages/shared/types/basic.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const GRID = { 4 | SIZE: 100, 5 | ORIGIN_X: 50, 6 | ORIGIN_Y: 50, 7 | CLIENT_RADIUS: 25, 8 | } as const; 9 | 10 | export const PositionSchema = z.object({ 11 | x: z.number().min(0).max(GRID.SIZE), 12 | y: z.number().min(0).max(GRID.SIZE), 13 | }); 14 | export type PositionType = z.infer; 15 | -------------------------------------------------------------------------------- /packages/shared/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./basic"; 2 | export * from "./HTTPRequest"; 3 | export * from "./WSBroadcast"; 4 | export * from "./WSRequest"; 5 | export * from "./WSResponse"; 6 | export * from "./WSUnicast"; 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": ["ESNext", "DOM"], 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | 22 | // Some stricter flags (disabled by default) 23 | "noUnusedLocals": false, 24 | "noUnusedParameters": false, 25 | "noPropertyAccessFromIndexSignature": false 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "tasks": { 4 | "build": { 5 | "dependsOn": ["^build"], 6 | "outputs": ["dist/**", ".next/**", "!.next/cache/**"] 7 | }, 8 | "dev": { 9 | "persistent": true, 10 | "cache": false 11 | }, 12 | "start": { 13 | "dependsOn": ["build"], 14 | "outputs": [".next/**"] 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "buildCommand": "cd apps/client && next build", 3 | "outputDirectory": "apps/client/.next" 4 | } 5 | --------------------------------------------------------------------------------