├── .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 |
157 |
158 |
159 | {isUploading ? (
160 |
161 | ) : (
162 |
163 | )}
164 |
165 |
166 |
167 | {isUploading
168 | ? "Uploading..."
169 | : fileName
170 | ? trimFileName(fileName)
171 | : "Upload audio"}
172 |
173 | {!isUploading && !fileName && (
174 |
175 | Add music to queue
176 |
177 | )}
178 |
179 |
180 |
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 |
183 | Show me how
184 |
185 |
186 |
187 |
188 |
189 |
190 | How to find your IP manually on macOS:
191 |
192 |
193 | Open System Preferences
194 | Click on Network
195 | Select your active connection (Wi-Fi or Ethernet)
196 | Click "Details..."
197 | Click "TCP/IP"
198 | Look for "IP Address"
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 |
58 | handleNudgeAmountChange(Math.max(1, Math.floor(nudgeAmount / 2)))
59 | }
60 | variant="outline"
61 | size="sm"
62 | >
63 | ÷2
64 |
65 |
67 | handleNudgeAmountChange(
68 | Math.min(1000, Math.floor(nudgeAmount * 2))
69 | )
70 | }
71 | variant="outline"
72 | size="sm"
73 | >
74 | ×2
75 |
76 | handleNudgeAmountChange(Number(value))}
79 | defaultValue="10"
80 | >
81 |
82 |
83 |
84 |
85 | 1 ms
86 | 5 ms
87 | 10 ms
88 | 20 ms
89 | 50 ms
90 | 100 ms
91 | 250 ms
92 | 500 ms
93 | 1000 ms (1s)
94 |
95 |
96 |
97 |
98 |
99 | onNudge(-nudgeAmount)}
101 | variant="secondary"
102 | size="default"
103 | disabled={disabled}
104 | >
105 | ◀ Slow Down
106 |
107 | onNudge(nudgeAmount)}
109 | variant="secondary"
110 | size="default"
111 | disabled={disabled}
112 | >
113 | Speed Up ▶
114 |
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 |
91 | {isSelected && isPlaying ? (
92 |
93 | ) : (
94 |
95 | )}
96 |
97 |
98 | {/* Playing indicator or track number (hidden on hover) */}
99 |
100 | {isPlayingThis ? (
101 |
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 |
144 |
145 |
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 |
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 |
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 |
setSelectedAudioId(value)}
26 | disabled={isLoadingAudioSources || audioSources.length === 0}
27 | >
28 |
29 |
30 |
31 |
32 | {audioSources.map((source) => (
33 |
34 |
35 | {source.name}
36 |
37 |
38 | ))}
39 |
40 |
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 |
handleReupload(item)}
68 | className="h-8 w-8 p-0 flex-shrink-0 ml-2"
69 | title="Reupload to all users"
70 | >
71 |
72 |
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 |
54 | Start
55 |
56 |
62 | Stop
63 |
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 |
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 |
58 |
59 | Default Library
60 |
61 |
62 |
63 |
67 |
68 | Search Music
69 |
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 |
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 |
Resync
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 |
72 |
{statusText[status]}
73 |
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 |
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 |
77 | Full Sync
78 |
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 |
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 |
--------------------------------------------------------------------------------