├── .env.example ├── .github └── workflows │ ├── pr_staging_deploy.yml │ ├── pr_staging_teardown.yml │ └── publish.yml ├── .gitignore ├── README.md ├── api ├── .dockerignore ├── .eslintrc.js ├── .gitignore ├── Dockerfile ├── README.md ├── fly.toml ├── package-lock.json ├── package.json ├── src │ ├── app.ts │ └── soundcloud.ts └── tsconfig.json ├── app ├── .eslintrc.cjs ├── .gitignore ├── .prettierrc.mjs ├── LICENSE ├── README.md ├── components.json ├── index.html ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── public │ ├── favicon.ico │ ├── logo180.png │ ├── manifest.json │ └── robots.txt ├── src │ ├── App.tsx │ ├── components │ │ ├── analyzers │ │ │ ├── audioAnalyzer.tsx │ │ │ ├── fftAnalyzerControls.tsx │ │ │ └── scopeAnalyzerControls.tsx │ │ ├── audio │ │ │ ├── audioSource.tsx │ │ │ └── sourceControls │ │ │ │ ├── common.ts │ │ │ │ ├── file.tsx │ │ │ │ ├── mic.tsx │ │ │ │ ├── overlay.css │ │ │ │ └── screenshare.tsx │ │ ├── canvas │ │ │ ├── AudioScope.tsx │ │ │ ├── AutoOrbitCamera.tsx │ │ │ ├── Visual3D.tsx │ │ │ ├── common.tsx │ │ │ └── paletteTracker.tsx │ │ ├── controls │ │ │ ├── audioSource │ │ │ │ ├── fileUpload.tsx │ │ │ │ └── soundcloud │ │ │ │ │ ├── controls.tsx │ │ │ │ │ ├── player.tsx │ │ │ │ │ ├── track.tsx │ │ │ │ │ └── user.tsx │ │ │ ├── common.tsx │ │ │ ├── dock.tsx │ │ │ ├── main.tsx │ │ │ ├── mobile-drawer.tsx │ │ │ ├── mode │ │ │ │ ├── audio.tsx │ │ │ │ ├── audioScope.tsx │ │ │ │ └── common.tsx │ │ │ ├── modeSheet.tsx │ │ │ ├── searchFilterInput.tsx │ │ │ └── visualSettingsSheet.tsx │ │ ├── ui │ │ │ ├── button.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── popover.tsx │ │ │ ├── scroll-area.tsx │ │ │ ├── select.tsx │ │ │ ├── separator.tsx │ │ │ ├── sheet.tsx │ │ │ ├── slider.tsx │ │ │ ├── switch.tsx │ │ │ └── tabs.tsx │ │ └── visualizers │ │ │ ├── audioScope │ │ │ ├── base.tsx │ │ │ ├── index.tsx │ │ │ ├── reactive.tsx │ │ │ └── shaders │ │ │ │ ├── fragment.ts │ │ │ │ └── vertex.ts │ │ │ ├── cube │ │ │ ├── base.tsx │ │ │ ├── controls.tsx │ │ │ ├── index.tsx │ │ │ └── reactive.tsx │ │ │ ├── diffusedRing │ │ │ ├── base.tsx │ │ │ ├── controls.tsx │ │ │ ├── index.tsx │ │ │ └── reactive.tsx │ │ │ ├── dna │ │ │ ├── base.tsx │ │ │ ├── index.tsx │ │ │ └── reactive.tsx │ │ │ ├── grid │ │ │ ├── base.tsx │ │ │ ├── controls.tsx │ │ │ ├── index.tsx │ │ │ └── reactive.tsx │ │ │ ├── ground.tsx │ │ │ ├── models.ts │ │ │ ├── movingBoxes │ │ │ ├── base.tsx │ │ │ ├── index.tsx │ │ │ └── reactive.tsx │ │ │ ├── registry.tsx │ │ │ ├── ribbons │ │ │ ├── base.tsx │ │ │ ├── index.tsx │ │ │ └── reactive.tsx │ │ │ ├── sphere │ │ │ ├── base.tsx │ │ │ ├── controls.tsx │ │ │ ├── index.tsx │ │ │ └── reactive.tsx │ │ │ ├── stencil │ │ │ ├── base.tsx │ │ │ ├── config.tsx │ │ │ ├── polys │ │ │ │ ├── diagonal.ts │ │ │ │ └── owl.ts │ │ │ └── reactive.tsx │ │ │ ├── swarm │ │ │ ├── base.tsx │ │ │ ├── index.tsx │ │ │ └── reactive.tsx │ │ │ ├── treadmill │ │ │ ├── horse.png │ │ │ ├── horse.tsx │ │ │ ├── index.tsx │ │ │ ├── reactive.tsx │ │ │ └── treadmill.tsx │ │ │ └── visualizerModal.tsx │ ├── context │ │ ├── searchFilters.tsx │ │ ├── soundcloud.tsx │ │ └── theme.tsx │ ├── hooks │ │ ├── use-client-details.ts │ │ └── use-debounce.ts │ ├── lib │ │ ├── analyzers │ │ │ ├── common.ts │ │ │ ├── fft.ts │ │ │ ├── scalarEventDetector.ts │ │ │ └── scope.ts │ │ ├── appState.ts │ │ ├── applicationModes.ts │ │ ├── easing.ts │ │ ├── eventDetector.ts │ │ ├── mappers │ │ │ ├── coordinateMappers │ │ │ │ ├── common.ts │ │ │ │ ├── data │ │ │ │ │ ├── controls.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── mapper.ts │ │ │ │ │ └── store.ts │ │ │ │ ├── noise │ │ │ │ │ ├── controls.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── mapper.ts │ │ │ │ │ └── store.ts │ │ │ │ ├── registry.tsx │ │ │ │ └── waveform │ │ │ │ │ ├── controls.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── mapper.ts │ │ │ │ │ └── store.ts │ │ │ ├── motionMappers │ │ │ │ ├── common.ts │ │ │ │ └── curlNoise.ts │ │ │ ├── textureMappers │ │ │ │ └── textureMapper.ts │ │ │ └── valueTracker │ │ │ │ ├── common.ts │ │ │ │ └── energyTracker.ts │ │ ├── palettes.ts │ │ ├── soundcloud │ │ │ ├── api.ts │ │ │ └── models.ts │ │ ├── storeHelpers.ts │ │ └── utils.ts │ ├── main.tsx │ ├── style │ │ └── globals.css │ └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts └── docs ├── demo-2024-1-12.gif └── waveform.gif /.env.example: -------------------------------------------------------------------------------- 1 | SOUNDCLOUD_CLIENT_ID=CHANGE_ME 2 | SOUNDCLOUD_SECRET=CHANGE_ME -------------------------------------------------------------------------------- /.github/workflows/pr_staging_deploy.yml: -------------------------------------------------------------------------------- 1 | name: Staging PR Deploy 2 | on: 3 | pull_request: 4 | branches: [dev] 5 | paths-ignore: 6 | - "**.md" 7 | env: 8 | PR_REPO_NAME: staging-pr-${{ github.event.pull_request.node_id }} 9 | jobs: 10 | create-page-host: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Create new repository for temporary deployment 14 | uses: dcyoung/ga-create-git-repo@v1.0.0 15 | with: 16 | name: ${{ env.PR_REPO_NAME }} 17 | access-token: ${{ secrets.PAT }} 18 | pr-build-deploy: 19 | needs: create-page-host 20 | runs-on: ubuntu-latest 21 | permissions: 22 | pull-requests: write 23 | environment: 24 | name: pr-staging 25 | url: https://dcyoung.github.io/${{ env.PR_REPO_NAME }}/ 26 | steps: 27 | - name: Setup Node.js 28 | uses: actions/setup-node@v2 29 | - name: Set GitHub Actions as Commit Author 30 | run: | 31 | git config --global user.name github-actions 32 | git config --global user.email github-actions@github.com 33 | - name: Checkout Repo 34 | uses: actions/checkout@v4 35 | with: 36 | path: "pr-build" 37 | - uses: pnpm/action-setup@v2 38 | with: 39 | version: 8 40 | - name: Install and Build 41 | run: | 42 | cd pr-build/app 43 | pnpm i 44 | pnpm run build --base=/${{ env.PR_REPO_NAME }}/ 45 | env: 46 | CI: "" 47 | - name: Checkout temporary deployment target repo 48 | uses: actions/checkout@v4 49 | with: 50 | repository: dcyoung/${{ env.PR_REPO_NAME }} 51 | fetch-depth: 0 52 | path: "pr-deploy" 53 | token: ${{ secrets.PAT }} 54 | - name: Push files to target 55 | run: | 56 | cp -r pr-build/app/dist/* pr-deploy 57 | cd pr-deploy 58 | git add . 59 | git commit -m $GITHUB_SHA 60 | git branch -M gh-pages 61 | git push -f -u origin gh-pages 62 | - name: Create link in PR 63 | uses: mshick/add-pr-comment@v2 64 | with: 65 | message: | 66 | **Staging Preview:** 67 | https://dcyoung.github.io/${{ env.PR_REPO_NAME }}/ 68 | -------------------------------------------------------------------------------- /.github/workflows/pr_staging_teardown.yml: -------------------------------------------------------------------------------- 1 | name: Staging PR Teardown 2 | on: 3 | pull_request: 4 | branches: [dev] 5 | types: [closed] 6 | paths-ignore: 7 | - "**.md" 8 | env: 9 | PR_REPO_NAME: staging-pr-${{ github.event.pull_request.node_id }} 10 | jobs: 11 | delete-page-host: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | - name: Delete repository for temporary deployment 17 | uses: dcyoung/ga-delete-git-repo@v1.0.0 18 | with: 19 | name: dcyoung/${{ env.PR_REPO_NAME }} 20 | access-token: ${{ secrets.PAT }} 21 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: App Build and Deploy 2 | on: 3 | push: 4 | branches: 5 | - dev 6 | permissions: 7 | contents: write 8 | jobs: 9 | build-and-deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 🛎️ 13 | uses: actions/checkout@v4 14 | - uses: pnpm/action-setup@v2 15 | with: 16 | version: 8 17 | - name: Install and Build 18 | run: | 19 | cd app 20 | pnpm i 21 | pnpm build 22 | env: 23 | CI: "" 24 | - name: Deploy 🚀 25 | uses: JamesIves/github-pages-deploy-action@v4 26 | with: 27 | folder: app/dist # The folder the action should deploy. 28 | 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .vscode/ 3 | scratch.sh 4 | scratch/ 5 | notes/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # r3f-audio-visualizer ![Release](https://github.com/dcyoung/r3f-audio-visualizer/actions/workflows/publish.yml/badge.svg) 2 | 3 | An interactive audio visualizer built with react and THREE.js. 4 | 5 | [CLICK HERE FOR A LIVE DEMO](https://dcyoung.github.io/r3f-audio-visualizer/) 6 | 7 | --- 8 | 9 | [![docs/demo-2024-1-12.gif](docs/demo-2024-1-12.gif)](https://dcyoung.github.io/r3f-audio-visualizer/) 10 | 11 | --- 12 | 13 | ## Quickstart 14 | 15 | ```bash 16 | git clone https://github.com/dcyoung/r3f-audio-visualizer.git 17 | 18 | cd r3f-audio-visualizer 19 | 20 | cd app/ 21 | pnpm i 22 | pnpm dev 23 | ``` 24 | -------------------------------------------------------------------------------- /api/.dockerignore: -------------------------------------------------------------------------------- 1 | ** 2 | !package*.json 3 | !src/ 4 | !tsconfig.json 5 | !.esclintrc.js -------------------------------------------------------------------------------- /api/.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** @type {import("eslint").Linter.Config} */ 2 | const config = { 3 | root: true, 4 | parser: "@typescript-eslint/parser", 5 | ignorePatterns: [ 6 | "dist/", 7 | "node_modules/", 8 | "notes/" 9 | ], 10 | parserOptions: { 11 | ecmaVersion: "latest", 12 | sourceType: "module", 13 | tsconfigRootDir: __dirname, 14 | project: [ 15 | "./tsconfig.json", 16 | ], 17 | }, 18 | env: { 19 | "browser": true, 20 | "es2021": true 21 | }, 22 | extends: [ 23 | "eslint:recommended", 24 | "plugin:@typescript-eslint/recommended" 25 | ], 26 | overrides: [ 27 | { 28 | "env": { 29 | "node": true 30 | }, 31 | "files": [ 32 | ".eslintrc.{js,cjs}" 33 | ], 34 | "parserOptions": { 35 | "sourceType": "script" 36 | } 37 | } 38 | ], 39 | plugins: [ 40 | "@typescript-eslint" 41 | ], 42 | rules: { 43 | } 44 | } 45 | 46 | module.exports = config; 47 | -------------------------------------------------------------------------------- /api/.gitignore: -------------------------------------------------------------------------------- 1 | scratch.md 2 | scratch/ 3 | notes/ 4 | .env 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | pnpm-debug.log* 13 | lerna-debug.log* 14 | 15 | node_modules 16 | dist 17 | dist-ssr 18 | *.local 19 | 20 | # Editor directories and files 21 | .vscode/* 22 | !.vscode/extensions.json 23 | .idea 24 | .DS_Store 25 | *.suo 26 | *.ntvs* 27 | *.njsproj 28 | *.sln 29 | *.sw? 30 | -------------------------------------------------------------------------------- /api/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18.16.0-alpine as base 2 | 3 | WORKDIR /app 4 | 5 | # Add package file 6 | COPY package*.json . 7 | RUN npm install 8 | 9 | COPY . . 10 | 11 | RUN npm run build 12 | 13 | FROM node:18.16.0-alpine as server 14 | # Start production image build 15 | # Copy node modules and build directory 16 | COPY --from=base /app/node_modules /app/node_modules 17 | COPY --from=base /app/dist /app/dist 18 | 19 | ENV NODE_ENV=production 20 | ENV PORT=8080 21 | EXPOSE 8080 22 | CMD ["/app/dist/app.js"] -------------------------------------------------------------------------------- /api/README.md: -------------------------------------------------------------------------------- 1 | # API 2 | 3 | An application accessible API proxy to the soundcloud API. 4 | 5 | ## Quickstart 6 | 7 | ```bash 8 | npm install 9 | npm run dev 10 | ``` 11 | 12 | ## Run with docker: 13 | 14 | ```bash 15 | docker build -t api-server . 16 | docker run -t -i \ 17 | --env SOUNDCLOUD_CLIENT_ID=... \ 18 | --env SOUNDCLOUD_SECRET=... \ 19 | -p 3000:8080 \ 20 | api-server 21 | ``` 22 | 23 | Then, the equivalent of: 24 | 25 | ```bash 26 | curl "https://api.soundcloud.com/playlists?q=test" \ 27 | -H "Authorization: OAuth " \ 28 | | jq 29 | ``` 30 | 31 | becomes... 32 | 33 | ```bash 34 | curl "localhost:3000/proxy/playlists?q=test" | jq 35 | ``` 36 | 37 | ## Fly Deployment 38 | 39 | ```bash 40 | APP_NAME="CHANGE_ME" 41 | REGION="CHANGE_ME" 42 | ORG="CHANGE_ME" 43 | 44 | flyctl launch \ 45 | --remote-only \ 46 | --no-deploy \ 47 | --auto-confirm \ 48 | --dockerfile Dockerfile \ 49 | --path . \ 50 | -r $REGION \ 51 | --copy-config \ 52 | --org $ORG \ 53 | --name $APP_NAME 54 | 55 | flyctl secrets set \ 56 | -a $APP_NAME \ 57 | --stage \ 58 | SOUNDCLOUD_CLIENT_ID=CHANGE_ME \ 59 | SOUNDCLOUD_SECRET=CHANGE_ME 60 | 61 | flyctl deploy \ 62 | --remote-only \ 63 | -a $APP_NAME \ 64 | --config fly.toml \ 65 | --dockerfile Dockerfile 66 | ``` -------------------------------------------------------------------------------- /api/fly.toml: -------------------------------------------------------------------------------- 1 | kill_signal = "SIGINT" 2 | kill_timeout = 5 3 | processes = [] 4 | 5 | [env] 6 | PORT = "8080" 7 | 8 | [experimental] 9 | auto_rollback = true 10 | 11 | [[services]] 12 | internal_port = 8080 13 | processes = ["app"] 14 | protocol = "tcp" 15 | script_checks = [] 16 | 17 | [services.concurrency] 18 | hard_limit = 25 19 | soft_limit = 20 20 | type = "connections" 21 | 22 | [[services.ports]] 23 | force_https = true 24 | handlers = ["http"] 25 | port = 80 26 | 27 | [[services.ports]] 28 | handlers = ["tls", "http"] 29 | port = 443 30 | 31 | [[services.tcp_checks]] 32 | grace_period = "1s" 33 | interval = "15s" 34 | restart_limit = 0 35 | timeout = "2s" 36 | 37 | [[services.http_checks]] 38 | interval = "15s" 39 | grace_period = "5s" 40 | method = "get" 41 | path = "/healthz" 42 | protocol = "http" 43 | restart_limit = 0 44 | timeout = 2000 45 | tls_skip_verify = false 46 | -------------------------------------------------------------------------------- /api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api-proxy", 3 | "version": "1.0.0", 4 | "description": "An application accessible API proxy to the soundcloud API.", 5 | "main": "dist/app.js", 6 | "scripts": { 7 | "clean": "rm -rf dist", 8 | "build": "npm run clean && tsc", 9 | "start": "npm run build && node dist/app.js", 10 | "dev": "npm run with-env npm run start", 11 | "lint": "eslint . --ext .ts", 12 | "with-env": "dotenv -e ../.env --" 13 | }, 14 | "keywords": [], 15 | "author": "", 16 | "license": "ISC", 17 | "dependencies": { 18 | "express": "^4.18.2", 19 | "express-http-proxy": "^2.0.0", 20 | "zod": "^3.22.4", 21 | "zod-fetch": "^0.1.1" 22 | }, 23 | "devDependencies": { 24 | "@types/express": "^4.17.1", 25 | "@types/express-http-proxy": "^1.6.3", 26 | "@typescript-eslint/eslint-plugin": "^6.6.0", 27 | "@typescript-eslint/parser": "^6.6.0", 28 | "dotenv-cli": "^7.2.1", 29 | "eslint": "^8.49.0", 30 | "typescript": "^5.2.2" 31 | } 32 | } -------------------------------------------------------------------------------- /api/src/app.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import proxy from 'express-http-proxy'; 3 | 4 | import { getSoundcloudToken } from './soundcloud'; 5 | 6 | const port = process.env.PORT || 3000; 7 | const app = express(); 8 | 9 | app.use((req, res, next) => { 10 | res.header('Access-Control-Allow-Origin', '*'); 11 | next(); 12 | }); 13 | 14 | /* 15 | Example: 16 | curl "https://api.soundcloud.com/playlists?q=test" -H "Authorization: OAuth " 17 | 18 | will be proxied through: 19 | 20 | curl "localhost:3000/proxy/playlists?q=test" 21 | */ 22 | app.use("/proxy", proxy("https://api.soundcloud.com", { 23 | proxyReqOptDecorator: async (proxyReqOpts, srcReq) => { 24 | console.log(`Proxying request to Soundcloud: ${srcReq.path}`) 25 | const token = await getSoundcloudToken(); 26 | proxyReqOpts.headers = { "Authorization": `OAuth ${token}` }; 27 | return proxyReqOpts; 28 | } 29 | })); 30 | 31 | app.use("/healthz", (req, res) => { 32 | res.status(200).send({ "healthy": true }); 33 | }); 34 | 35 | const server = app.listen(port, () => { 36 | return console.log(`Express is listening at http://localhost:${port}`); 37 | }); 38 | 39 | app.on('error', (err) => { 40 | console.error(err); 41 | }); 42 | 43 | process.on('SIGINT', () => server.close()); 44 | process.on('SIGTERM', () => server.close()); -------------------------------------------------------------------------------- /api/src/soundcloud.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { createZodFetcher } from "zod-fetch"; 3 | 4 | const fetchWithZod = createZodFetcher(); 5 | 6 | function delay(ms: number) { 7 | return new Promise(resolve => setTimeout(resolve, ms)); 8 | } 9 | 10 | class ScTokenFetcher { 11 | private token: string = ""; 12 | private expirationAtMs: number = 0; 13 | private isFetching: boolean = false; 14 | 15 | constructor() { 16 | this.getToken(); 17 | } 18 | 19 | private isTokenValid() { 20 | return this.token && this.token !== "" && Date.now() < this.expirationAtMs; 21 | } 22 | 23 | private async waitForValidToken(timeoutMs: number = 10000, checkIntervalMs: number = 25) { 24 | const end = Date.now() + timeoutMs; 25 | while (Date.now() < end) { 26 | if (this.isTokenValid()) { 27 | return this.token; 28 | } 29 | await delay(checkIntervalMs); 30 | } 31 | throw new Error("timeout waiting for valid token"); 32 | } 33 | 34 | public async getToken() { 35 | if (this.isTokenValid()) { 36 | return this.token; 37 | } 38 | 39 | if (this.isFetching) { 40 | return this.waitForValidToken(); 41 | } 42 | 43 | this.isFetching = true; 44 | console.log("fetching new token from soundcloud"); 45 | 46 | const form = { 47 | client_id: process.env.SOUNDCLOUD_CLIENT_ID!, 48 | client_secret: process.env.SOUNDCLOUD_SECRET!, 49 | grant_type: 'client_credentials', 50 | test: 'true', 51 | }; 52 | 53 | const data = await fetchWithZod( 54 | z.object({ 55 | access_token: z.string(), 56 | expires_in: z.number(), 57 | // refresh_token: z.string(), 58 | // scope: z.string(), 59 | // token_type: z.string(), 60 | }), 61 | 'https://api.soundcloud.com/oauth2/token', 62 | { 63 | method: 'POST', 64 | body: new URLSearchParams(form), 65 | headers: { 66 | 'Content-Type': 'application/x-www-form-urlencoded', 67 | }, 68 | } 69 | ); 70 | 71 | this.token = data.access_token; 72 | this.expirationAtMs = Date.now() + ((data.expires_in - 10) * 1000); 73 | this.isFetching = false; 74 | console.log(`Successfully fetched new token from soundcloud... expires: [${new Date(this.expirationAtMs).toUTCString()}]`); 75 | return this.token; 76 | } 77 | } 78 | 79 | const TOKEN_FETCHER = new ScTokenFetcher(); 80 | 81 | export const getSoundcloudToken = async () => { 82 | return await TOKEN_FETCHER.getToken(); 83 | } 84 | 85 | // headers: { 86 | // 'Authorization': `OAuth ${token}`, 87 | // }, 88 | -------------------------------------------------------------------------------- /api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "esModuleInterop": true, 5 | "target": "es6", 6 | "moduleResolution": "node", 7 | "sourceMap": true, 8 | "strict": true, 9 | "outDir": "dist" 10 | }, 11 | "include": [ 12 | "src/**/*.ts" 13 | ], 14 | "exclude": [ 15 | "node_modules", 16 | "dist", 17 | "notes", 18 | ".dockerignore", 19 | "Dockerfile" 20 | ], 21 | "lib": [ 22 | "es2015" 23 | ] 24 | } -------------------------------------------------------------------------------- /app/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import("eslint").Linter.Config} */ 2 | const config = { 3 | root: true, 4 | parser: "@typescript-eslint/parser", 5 | parserOptions: { 6 | ecmaVersion: "latest", 7 | tsconfigRootDir: __dirname, 8 | project: ["./tsconfig.json"], 9 | }, 10 | ignorePatterns: [ 11 | "**/.eslintrc.cjs", 12 | "**/*.config.js", 13 | "**/*.config.cjs", 14 | "dist", 15 | "pnpm-lock.yaml", 16 | "vite.config.ts", 17 | ], 18 | plugins: ["@typescript-eslint", "import"], 19 | extends: [ 20 | "eslint:recommended", 21 | "plugin:@typescript-eslint/recommended-type-checked", 22 | "plugin:@typescript-eslint/stylistic-type-checked", 23 | "plugin:react/recommended", 24 | "plugin:react-hooks/recommended", 25 | "plugin:jsx-a11y/recommended", 26 | ], 27 | rules: { 28 | "@typescript-eslint/consistent-type-definitions": "off", 29 | "@typescript-eslint/no-unused-vars": [ 30 | "error", 31 | { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, 32 | ], 33 | "@typescript-eslint/consistent-type-imports": [ 34 | "warn", 35 | { prefer: "type-imports", fixStyle: "separate-type-imports" }, 36 | ], 37 | "@typescript-eslint/no-misused-promises": [ 38 | 2, 39 | { checksVoidReturn: { attributes: false } }, 40 | ], 41 | "import/consistent-type-specifier-style": ["error", "prefer-inline"], 42 | "react/prop-types": "off", 43 | "jsx-a11y/click-events-have-key-events": "off", 44 | "jsx-a11y/no-static-element-interactions": "off", 45 | "jsx-a11y/heading-has-content": "off", 46 | "react/no-unknown-property": "off", 47 | "react/display-name": "off", 48 | }, 49 | globals: { 50 | React: "writable", 51 | }, 52 | settings: { 53 | "import/resolver": { 54 | typescript: {}, 55 | alias: { 56 | map: [["@", "./src"]], 57 | }, 58 | }, 59 | react: { 60 | version: "detect", 61 | }, 62 | }, 63 | env: { 64 | browser: true, 65 | }, 66 | }; 67 | 68 | module.exports = config; 69 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | scratch.md 2 | scratch/ 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | pnpm-debug.log* 11 | lerna-debug.log* 12 | 13 | node_modules 14 | dist 15 | dist-ssr 16 | *.local 17 | 18 | # Editor directories and files 19 | .vscode/* 20 | !.vscode/extensions.json 21 | .idea 22 | .DS_Store 23 | *.suo 24 | *.ntvs* 25 | *.njsproj 26 | *.sln 27 | *.sw? 28 | -------------------------------------------------------------------------------- /app/.prettierrc.mjs: -------------------------------------------------------------------------------- 1 | /** @typedef {import("prettier").Config} PrettierConfig */ 2 | /** @typedef {import("prettier-plugin-tailwindcss").PluginOptions} TailwindConfig */ 3 | /** @typedef {import("@ianvs/prettier-plugin-sort-imports").PluginConfig} SortImportsConfig */ 4 | 5 | /** @type { PrettierConfig | SortImportsConfig | TailwindConfig } */ 6 | const config = { 7 | arrowParens: "always", 8 | printWidth: 80, 9 | singleQuote: false, 10 | jsxSingleQuote: false, 11 | semi: true, 12 | trailingComma: "all", 13 | tabWidth: 2, 14 | plugins: [ 15 | "@ianvs/prettier-plugin-sort-imports", 16 | "prettier-plugin-tailwindcss", 17 | ], 18 | importOrder: [ 19 | "^(react/(.*)$)|^(react$)|^(react-native(.*)$)", 20 | "^(next/(.*)$)|^(next$)", 21 | "^(expo(.*)$)|^(expo$)", 22 | "", 23 | "", 24 | "", 25 | "^~/utils/(.*)$", 26 | "^~/components/(.*)$", 27 | "^~/styles/(.*)$", 28 | "^~/", 29 | "^[../]", 30 | "^[./]", 31 | ], 32 | importOrderParserPlugins: ["typescript", "jsx", "decorators-legacy"], 33 | importOrderTypeScriptVersion: "4.4.0", 34 | }; 35 | 36 | export default config; 37 | -------------------------------------------------------------------------------- /app/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 David Young 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 | -------------------------------------------------------------------------------- /app/README.md: -------------------------------------------------------------------------------- 1 | # App 2 | 3 | An interactive react app. 4 | -------------------------------------------------------------------------------- /app/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/style/globals.css", 9 | "baseColor": "stone", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "utils": "@/lib/utils" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Interactive Audio Visualizer 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "r3f-audio-visualizer", 3 | "homepage": "https://dcyoung.github.io/r3f-audio-visualizer/", 4 | "version": "0.2.0", 5 | "private": true, 6 | "scripts": { 7 | "dev": "vite", 8 | "mobile": "vite --host", 9 | "build": "tsc && vite build", 10 | "preview": "vite preview", 11 | "typecheck": "tsc --noEmit", 12 | "lint": "eslint .", 13 | "lint:fix": "eslint --fix .", 14 | "format": "prettier --check \"**/*.{js,cjs,mjs,ts,tsx,md,json}\"", 15 | "format:fix": "prettier --write \"**/*.{js,cjs,mjs,ts,tsx,md,json}\"" 16 | }, 17 | "dependencies": { 18 | "@radix-ui/react-dialog": "^1.1.1", 19 | "@radix-ui/react-label": "^2.1.0", 20 | "@radix-ui/react-popover": "^1.1.2", 21 | "@radix-ui/react-scroll-area": "^1.2.1", 22 | "@radix-ui/react-select": "^2.1.2", 23 | "@radix-ui/react-separator": "^1.1.0", 24 | "@radix-ui/react-slider": "^1.2.1", 25 | "@radix-ui/react-slot": "^1.1.0", 26 | "@radix-ui/react-switch": "^1.1.1", 27 | "@radix-ui/react-tabs": "^1.1.1", 28 | "@radix-ui/react-visually-hidden": "^1.1.0", 29 | "@react-three/drei": "^9.97.4", 30 | "@react-three/fiber": "^8.15.16", 31 | "@react-three/postprocessing": "^2.15.13", 32 | "@tanstack/react-query": "5.62.0", 33 | "class-variance-authority": "^0.7.1", 34 | "clsx": "^2.1.1", 35 | "lucide-react": "^0.462.0", 36 | "react": "^18.3.1", 37 | "react-dom": "^18.3.1", 38 | "react-error-boundary": "^4.1.2", 39 | "react-use": "^17.5.1", 40 | "simplex-noise": "^4.0.3", 41 | "tailwind-merge": "^2.5.5", 42 | "three": "^0.161.0", 43 | "three-stdlib": "^2.29.4", 44 | "vaul": "^1.1.1", 45 | "zod": "^3.23.8", 46 | "zod-fetch": "^0.1.1", 47 | "zustand": "^4.5.0" 48 | }, 49 | "devDependencies": { 50 | "@ianvs/prettier-plugin-sort-imports": "^4.3.1", 51 | "@types/eslint": "^8.56.10", 52 | "@types/node": "^20.14.10", 53 | "@types/react": "^18.3.9", 54 | "@types/react-dom": "^18.3.0", 55 | "@types/three": "^0.161.2", 56 | "@typescript-eslint/eslint-plugin": "^8.16.0", 57 | "@typescript-eslint/parser": "^8.16.0", 58 | "@vitejs/plugin-react": "^4.3.4", 59 | "autoprefixer": "^10.4.20", 60 | "eslint": "^8.57.0", 61 | "eslint-plugin-import": "^2.31.0", 62 | "eslint-plugin-jsx-a11y": "^6.10.2", 63 | "eslint-plugin-react": "^7.37.2", 64 | "eslint-plugin-react-hooks": "^5.0.0", 65 | "gh-pages": "^6.2.0", 66 | "postcss": "^8.4.49", 67 | "prettier": "^3.4.1", 68 | "prettier-plugin-tailwindcss": "0.6.9", 69 | "tailwindcss": "^3.4.15", 70 | "typescript": "5.7.2", 71 | "vite": "^6.0.1" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /app/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /app/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dcyoung/r3f-audio-visualizer/93a8b5dbef37de7e767a444d2209a3e801cea16a/app/public/favicon.ico -------------------------------------------------------------------------------- /app/public/logo180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dcyoung/r3f-audio-visualizer/93a8b5dbef37de7e767a444d2209a3e801cea16a/app/public/logo180.png -------------------------------------------------------------------------------- /app/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "audio_viz", 3 | "name": "3D Audio Visualizer.", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo180.png", 12 | "type": "image/png", 13 | "sizes": "180x180" 14 | } 15 | ], 16 | "start_url": ".", 17 | "display": "standalone", 18 | "theme_color": "#000000", 19 | "background_color": "#ffffff" 20 | } 21 | -------------------------------------------------------------------------------- /app/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /app/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from "react"; 2 | import AudioAnalyzer from "@/components/analyzers/audioAnalyzer"; 3 | import AudioScopeCanvas from "@/components/canvas/AudioScope"; 4 | import Visual3DCanvas from "@/components/canvas/Visual3D"; 5 | import { ControlsPanel } from "@/components/controls/main"; 6 | import { 7 | APPLICATION_MODE, 8 | type TApplicationMode, 9 | } from "@/lib/applicationModes"; 10 | 11 | import { useAppStateActions, useMode } from "./lib/appState"; 12 | 13 | const getAnalyzerComponent = (mode: TApplicationMode) => { 14 | switch (mode) { 15 | case APPLICATION_MODE.AUDIO: 16 | case APPLICATION_MODE.AUDIO_SCOPE: 17 | return ; 18 | case APPLICATION_MODE.WAVE_FORM: 19 | case APPLICATION_MODE.NOISE: 20 | case APPLICATION_MODE.PARTICLE_NOISE: 21 | return null; 22 | default: 23 | return mode satisfies never; 24 | } 25 | }; 26 | 27 | const getCanvasComponent = (mode: TApplicationMode) => { 28 | switch (mode) { 29 | case APPLICATION_MODE.AUDIO_SCOPE: 30 | return ; 31 | case APPLICATION_MODE.WAVE_FORM: 32 | case APPLICATION_MODE.NOISE: 33 | case APPLICATION_MODE.AUDIO: 34 | case APPLICATION_MODE.PARTICLE_NOISE: 35 | return ; 36 | default: 37 | return mode satisfies never; 38 | } 39 | }; 40 | 41 | const App = () => { 42 | const mode = useMode(); 43 | const { noteCanvasInteraction } = useAppStateActions(); 44 | 45 | return ( 46 |
47 |
52 | loading...}> 53 | {getCanvasComponent(mode)} 54 | 55 |
56 |
57 | loading...}> 58 | {getAnalyzerComponent(mode)} 59 | 60 |
61 | 62 |
63 | ); 64 | }; 65 | 66 | export default App; 67 | -------------------------------------------------------------------------------- /app/src/components/analyzers/audioAnalyzer.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { FFTAnalyzerControls } from "@/components/analyzers/fftAnalyzerControls"; 3 | import { ControlledAudioSource } from "@/components/audio/audioSource"; 4 | import { 5 | AUDIO_SOURCE, 6 | buildAudio, 7 | buildAudioContext, 8 | type TAudioSource, 9 | } from "@/components/audio/sourceControls/common"; 10 | import MicrophoneAudioControls from "@/components/audio/sourceControls/mic"; 11 | import ScreenShareControls from "@/components/audio/sourceControls/screenshare"; 12 | import { 13 | useMediaStreamLink, 14 | type TAnalyzerInputControl, 15 | } from "@/lib/analyzers/common"; 16 | import FFTAnalyzer from "@/lib/analyzers/fft"; 17 | import ScopeAnalyzer from "@/lib/analyzers/scope"; 18 | import { APPLICATION_MODE } from "@/lib/applicationModes"; 19 | import { useAudio } from "@/lib/appState"; 20 | 21 | import { AudioScopeAnalyzerControls } from "./scopeAnalyzerControls"; 22 | 23 | const buildScopeAnalyzer = () => { 24 | const audioCtx = buildAudioContext(); 25 | const audio = buildAudio(); 26 | return { 27 | audio, 28 | analyzer: new ScopeAnalyzer(audio, audioCtx), 29 | }; 30 | }; 31 | 32 | const buildFFTAnalyzer = (volume: number) => { 33 | const audioCtx = buildAudioContext(); 34 | const audio = buildAudio(); 35 | return { 36 | audio, 37 | analyzer: new FFTAnalyzer(audio, audioCtx, volume), 38 | }; 39 | }; 40 | 41 | const ControlledMicAnalyzer = ({ 42 | audio, 43 | analyzer, 44 | }: { 45 | audio: HTMLAudioElement; 46 | analyzer: TAnalyzerInputControl; 47 | }) => { 48 | const { onDisabled, onStreamCreated } = useMediaStreamLink(audio, analyzer); 49 | return ( 50 | 55 | ); 56 | }; 57 | 58 | const ControlledScreenShareAnalyzer = ({ 59 | audio, 60 | analyzer, 61 | }: { 62 | audio: HTMLAudioElement; 63 | analyzer: TAnalyzerInputControl; 64 | }) => { 65 | const { onDisabled, onStreamCreated } = useMediaStreamLink(audio, analyzer); 66 | return ( 67 | 72 | ); 73 | }; 74 | 75 | const isMediaStream = (source: TAudioSource) => { 76 | switch (source) { 77 | case AUDIO_SOURCE.MICROPHONE: 78 | case AUDIO_SOURCE.SCREEN_SHARE: 79 | return true; 80 | case AUDIO_SOURCE.SOUNDCLOUD: 81 | case AUDIO_SOURCE.FILE_UPLOAD: 82 | return false; 83 | default: 84 | return source satisfies never; 85 | } 86 | }; 87 | 88 | const ControlledAnalyzer = ({ 89 | mode, 90 | audioSource, 91 | }: { 92 | mode: typeof APPLICATION_MODE.AUDIO | typeof APPLICATION_MODE.AUDIO_SCOPE; 93 | audioSource: TAudioSource; 94 | }) => { 95 | const { audio, analyzer } = useMemo(() => { 96 | switch (mode) { 97 | case APPLICATION_MODE.AUDIO: 98 | return buildFFTAnalyzer(isMediaStream(audioSource) ? 0.0 : 1.0); 99 | case APPLICATION_MODE.AUDIO_SCOPE: 100 | return buildScopeAnalyzer(); 101 | default: 102 | return mode satisfies never; 103 | } 104 | }, [mode, audioSource]); 105 | 106 | return ( 107 | <> 108 | {audioSource === AUDIO_SOURCE.MICROPHONE ? ( 109 | 110 | ) : audioSource === AUDIO_SOURCE.SCREEN_SHARE ? ( 111 | 112 | ) : audioSource === AUDIO_SOURCE.SOUNDCLOUD || 113 | audioSource === AUDIO_SOURCE.FILE_UPLOAD ? ( 114 | 115 | ) : ( 116 | (audioSource satisfies never) 117 | )} 118 | {analyzer instanceof FFTAnalyzer ? ( 119 | 120 | ) : analyzer instanceof ScopeAnalyzer ? ( 121 | 122 | ) : ( 123 | (analyzer satisfies never) 124 | )} 125 | 126 | ); 127 | }; 128 | 129 | const AudioAnalyzer = ({ 130 | mode, 131 | }: { 132 | mode: typeof APPLICATION_MODE.AUDIO | typeof APPLICATION_MODE.AUDIO_SCOPE; 133 | }) => { 134 | const { source } = useAudio(); 135 | 136 | return ; 137 | }; 138 | 139 | export default AudioAnalyzer; 140 | -------------------------------------------------------------------------------- /app/src/components/analyzers/fftAnalyzerControls.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef } from "react"; 2 | import type FFTAnalyzer from "@/lib/analyzers/fft"; 3 | import { useAnalyzerFFT, useMappers } from "@/lib/appState"; 4 | import { COORDINATE_MAPPER_REGISTRY } from "@/lib/mappers/coordinateMappers/registry"; 5 | 6 | export const FFTAnalyzerControls = ({ 7 | analyzer, 8 | }: { 9 | analyzer: FFTAnalyzer; 10 | }) => { 11 | const { octaveBandMode, energyMeasure } = useAnalyzerFFT(); 12 | const { energyTracker } = useMappers(); 13 | const coordinateMapperData = 14 | COORDINATE_MAPPER_REGISTRY.data.hooks.useInstance(); 15 | const { setParams } = COORDINATE_MAPPER_REGISTRY.data.hooks.useActions(); 16 | const animationRequestRef = useRef(null!); 17 | 18 | /** 19 | * Transfers data from the analyzer to the target array 20 | */ 21 | const mapData = useCallback(() => { 22 | const bars = analyzer.getBars(); 23 | 24 | if (coordinateMapperData.data.length != bars.length) { 25 | console.log(`Resizing ${bars.length}`); 26 | setParams({ size: bars.length }); 27 | return; 28 | } 29 | 30 | energyTracker?.set(analyzer.getEnergy(energyMeasure)); 31 | 32 | bars.forEach(({ value }, index) => { 33 | coordinateMapperData.data[index] = value; 34 | }); 35 | }, [coordinateMapperData, analyzer, energyTracker, energyMeasure, setParams]); 36 | 37 | /** 38 | * Re-Synchronize the animation loop if the target data destination changes. 39 | */ 40 | useEffect(() => { 41 | if (animationRequestRef.current) { 42 | cancelAnimationFrame(animationRequestRef.current); 43 | } 44 | const animate = (): void => { 45 | mapData(); 46 | animationRequestRef.current = requestAnimationFrame(animate); 47 | }; 48 | animationRequestRef.current = requestAnimationFrame(animate); 49 | return () => cancelAnimationFrame(animationRequestRef.current); 50 | }, [coordinateMapperData, energyMeasure, mapData]); 51 | 52 | /** 53 | * Make sure an analyzer exists with the correct mode 54 | */ 55 | useEffect(() => { 56 | analyzer.mode = octaveBandMode; 57 | }, [octaveBandMode, analyzer]); 58 | return <>; 59 | }; 60 | 61 | export default FFTAnalyzerControls; 62 | -------------------------------------------------------------------------------- /app/src/components/analyzers/scopeAnalyzerControls.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef } from "react"; 2 | import type ScopeAnalyzer from "@/lib/analyzers/scope"; 3 | import { useAppStateActions, useMappers } from "@/lib/appState"; 4 | 5 | export const AudioScopeAnalyzerControls = ({ 6 | analyzer, 7 | }: { 8 | analyzer: ScopeAnalyzer; 9 | }) => { 10 | const { textureMapper } = useMappers(); 11 | const { setMappers } = useAppStateActions(); 12 | const animationRequestRef = useRef(null!); 13 | 14 | /** 15 | * Transfers data from the analyzer to the target arrays 16 | */ 17 | const mapData = useCallback(() => { 18 | const timeData = textureMapper.samplesX; 19 | const quadData = textureMapper.samplesY; 20 | // Check if the state sizes need to be updated 21 | const targetLength = analyzer.quadSamples.length; 22 | if (timeData.length !== targetLength || quadData.length !== targetLength) { 23 | console.log(`Resizing ${targetLength}`); 24 | setMappers({ 25 | textureMapper: textureMapper.clone({ 26 | size: targetLength, 27 | }), 28 | }); 29 | return; 30 | } 31 | // Copy the data over to state 32 | analyzer.timeSamples.forEach((v, index) => { 33 | timeData[index] = v; 34 | }); 35 | analyzer.quadSamples.forEach((v, index) => { 36 | quadData[index] = v; 37 | }); 38 | }, [analyzer, setMappers, textureMapper]); 39 | 40 | /** 41 | * Re-Synchronize the animation loop if the target data destination changes. 42 | */ 43 | useEffect(() => { 44 | if (animationRequestRef.current) { 45 | cancelAnimationFrame(animationRequestRef.current); 46 | } 47 | const animate = (): void => { 48 | mapData(); 49 | animationRequestRef.current = requestAnimationFrame(animate); 50 | }; 51 | animationRequestRef.current = requestAnimationFrame(animate); 52 | return () => cancelAnimationFrame(animationRequestRef.current); 53 | }, [textureMapper, mapData]); 54 | 55 | return <>; 56 | }; 57 | 58 | export default AudioScopeAnalyzerControls; 59 | -------------------------------------------------------------------------------- /app/src/components/audio/audioSource.tsx: -------------------------------------------------------------------------------- 1 | import { AUDIO_SOURCE } from "@/components/audio/sourceControls/common"; 2 | import FileAudioControls from "@/components/audio/sourceControls/file"; 3 | import { CurrentTrackPlayer } from "@/components/controls/audioSource/soundcloud/player"; 4 | 5 | export const ControlledAudioSource = ({ 6 | audio, 7 | audioSource, 8 | }: { 9 | audio: HTMLAudioElement; 10 | audioSource: "SOUNDCLOUD" | "FILE_UPLOAD"; 11 | }) => { 12 | switch (audioSource) { 13 | case AUDIO_SOURCE.SOUNDCLOUD: 14 | return ; 15 | case AUDIO_SOURCE.FILE_UPLOAD: 16 | return ; 17 | default: 18 | return audioSource satisfies never; 19 | } 20 | }; 21 | export default ControlledAudioSource; 22 | -------------------------------------------------------------------------------- /app/src/components/audio/sourceControls/common.ts: -------------------------------------------------------------------------------- 1 | export interface AudioSourceControlsProps { 2 | audio: HTMLAudioElement; 3 | } 4 | 5 | export const AUDIO_SOURCE = { 6 | FILE_UPLOAD: "FILE_UPLOAD", 7 | MICROPHONE: "MICROPHONE", 8 | SOUNDCLOUD: "SOUNDCLOUD", 9 | SCREEN_SHARE: "SCREEN_SHARE", 10 | } as const; 11 | 12 | type ObjectValues = T[keyof T]; 13 | export type TAudioSource = ObjectValues; 14 | 15 | export const iOS = (): boolean => { 16 | // apple "iP..." device detection. Ex: iPad, iPod, iPhone etc. 17 | if (navigator.platform.toLowerCase().startsWith("ip")) { 18 | return true; 19 | } 20 | // iPad on iOS 13 detection 21 | return ( 22 | navigator.userAgent?.toLowerCase().startsWith("mac") && 23 | "ontouchend" in document 24 | ); 25 | }; 26 | 27 | export const getPlatformSupportedAudioSources = (): TAudioSource[] => { 28 | return [ 29 | AUDIO_SOURCE.SOUNDCLOUD, 30 | AUDIO_SOURCE.MICROPHONE, 31 | AUDIO_SOURCE.FILE_UPLOAD, 32 | AUDIO_SOURCE.SCREEN_SHARE, 33 | ]; 34 | 35 | // Apple devices/browsers using WebKit do NOT support CrossOrigin Audio 36 | // see: https://bugs.webkit.org/show_bug.cgi?id=195043 37 | // return iOS() 38 | // ? [AUDIO_SOURCE.FILE_UPLOAD, AUDIO_SOURCE.MICROPHONE] 39 | // : [ 40 | // AUDIO_SOURCE.SOUNDCLOUD, 41 | // AUDIO_SOURCE.MICROPHONE, 42 | // AUDIO_SOURCE.FILE_UPLOAD, 43 | // ]; 44 | }; 45 | 46 | export const buildAudio = () => { 47 | console.log("Building audio..."); 48 | const out = new Audio(); 49 | out.crossOrigin = "anonymous"; 50 | return out; 51 | }; 52 | 53 | const webAudioTouchUnlock = (context: AudioContext) => { 54 | return new Promise(function (resolve, reject) { 55 | const unlockTriggerNames = ["mousedown", "touchstart", "touchend"] as const; 56 | if (context.state === "suspended" && "ontouchstart" in window) { 57 | const unlock = function () { 58 | context.resume().then( 59 | function () { 60 | unlockTriggerNames.forEach((name) => { 61 | document.body.removeEventListener(name, unlock); 62 | }); 63 | resolve(true); 64 | }, 65 | function (reason) { 66 | /* eslint-disable @typescript-eslint/prefer-promise-reject-errors */ 67 | reject(reason); 68 | }, 69 | ); 70 | }; 71 | unlockTriggerNames.forEach((name) => { 72 | document.body.addEventListener(name, unlock, false); 73 | }); 74 | } else { 75 | resolve(false); 76 | } 77 | }); 78 | }; 79 | 80 | export const buildAudioContext = () => { 81 | console.log("Building audioCtx..."); 82 | const audioCtx = new window.AudioContext(); 83 | if (iOS()) { 84 | console.log("Attempting to unlock AudioContext"); 85 | webAudioTouchUnlock(audioCtx).then( 86 | function (unlocked) { 87 | if (unlocked) { 88 | // AudioContext was unlocked from an explicit user action, 89 | // sound should work now 90 | console.log("Successfully unlocked AudioContext!"); 91 | } else { 92 | // There was no need for unlocking, devices other than iOS 93 | console.log("No need to unlock AudioContext."); 94 | } 95 | }, 96 | function (reason) { 97 | console.error(reason); 98 | }, 99 | ); 100 | } 101 | return audioCtx; 102 | }; 103 | -------------------------------------------------------------------------------- /app/src/components/audio/sourceControls/file.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from "react"; 2 | import { 3 | iOS, 4 | type AudioSourceControlsProps, 5 | } from "@/components/audio/sourceControls/common"; 6 | 7 | import "@/components/audio/sourceControls/overlay.css"; 8 | 9 | const useAudioFile = (audio: HTMLAudioElement) => { 10 | const [isPlaying, setIsPlaying] = useState(false); 11 | const [loaded, setLoaded] = useState(false); 12 | const audioFile = null as null | Blob; 13 | 14 | const playAudio = useCallback(() => { 15 | if (!audioFile) { 16 | return; 17 | } 18 | // Can play immediately on non-ios platforms 19 | const promise = audio.play(); 20 | if (promise !== undefined) { 21 | promise 22 | .then(() => { 23 | setIsPlaying(true); 24 | console.log(`Playing audiofile`); 25 | }) 26 | .catch((error) => { 27 | // Auto-play was prevented 28 | console.error(`Error playing audiofile`, error); 29 | }); 30 | } 31 | }, [audio, audioFile]); 32 | 33 | /** 34 | * Make sure the correct file is playing 35 | */ 36 | useEffect(() => { 37 | console.log("Syncing, start w/ pause..."); 38 | audio.pause(); 39 | setIsPlaying(false); 40 | 41 | if (!audioFile) { 42 | setLoaded(false); 43 | return; 44 | } 45 | 46 | console.log("Setting source..."); 47 | audio.src = URL.createObjectURL(audioFile); 48 | setLoaded(true); 49 | 50 | // Can play immediately on non-ios platforms 51 | if (!iOS()) { 52 | playAudio(); 53 | } 54 | 55 | return () => { 56 | console.log("Pausing..."); 57 | audio.pause(); 58 | setIsPlaying(false); 59 | }; 60 | }, [audio, audioFile, playAudio]); 61 | 62 | return { 63 | loaded, 64 | isPlaying, 65 | playAudio, 66 | }; 67 | }; 68 | const FileAudioControls = ({ audio }: AudioSourceControlsProps) => { 69 | const { loaded, isPlaying, playAudio } = useAudioFile(audio); 70 | 71 | // IOS needs a dedicated play button 72 | return iOS() ? ( 73 | 96 | ) : ( 97 | <> 98 | ); 99 | }; 100 | 101 | export default FileAudioControls; 102 | -------------------------------------------------------------------------------- /app/src/components/audio/sourceControls/mic.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | import { type AudioSourceControlsProps } from "@/components/audio/sourceControls/common"; 3 | 4 | export interface MicrophoneAudioControlsProps extends AudioSourceControlsProps { 5 | onDisabled: () => void; 6 | onStreamCreated: (stream: MediaStream) => void; 7 | } 8 | const MicrophoneAudioControls = ({ 9 | audio, 10 | onDisabled, 11 | onStreamCreated, 12 | }: MicrophoneAudioControlsProps) => { 13 | const mediaStream = useRef(null); 14 | 15 | /** 16 | * Make sure the microphone is enabled 17 | */ 18 | useEffect(() => { 19 | console.log("Disabling mic..."); 20 | onDisabled(); 21 | if (mediaStream?.current) { 22 | mediaStream.current = null; 23 | } 24 | 25 | console.log("Enabling mic..."); 26 | if (navigator.mediaDevices) { 27 | navigator.mediaDevices 28 | .getUserMedia({ 29 | audio: true, 30 | video: false, 31 | }) 32 | .then(onStreamCreated) 33 | .catch((err) => { 34 | console.error(err); 35 | alert("Microphone access denied by user"); 36 | }); 37 | } else { 38 | alert("User mediaDevices not available"); 39 | } 40 | 41 | return () => { 42 | audio.pause(); 43 | if (mediaStream?.current) { 44 | mediaStream.current = null; 45 | } 46 | }; 47 | }, [audio, onDisabled, onStreamCreated]); 48 | 49 | return <>; 50 | }; 51 | 52 | export default MicrophoneAudioControls; 53 | -------------------------------------------------------------------------------- /app/src/components/audio/sourceControls/overlay.css: -------------------------------------------------------------------------------- 1 | #info { 2 | position: absolute; 3 | pointer-events: none; 4 | z-index: 10; 5 | padding: 1.5em; 6 | color: white; 7 | text-shadow: 1px 1px black; 8 | border-radius: 1em; 9 | } 10 | 11 | .contrast #info button { 12 | border-color: black; 13 | } 14 | 15 | .contrast #info button:hover { 16 | background-color: rgb(80, 80, 80); 17 | color: white; 18 | } 19 | 20 | #info button { 21 | color: inherit; 22 | cursor: pointer; 23 | border: 1px solid white; 24 | outline: none; 25 | background: none; 26 | border-radius: 3px; 27 | padding: 0.5em 1em; 28 | margin-right: 0.5em; 29 | pointer-events: auto; 30 | text-shadow: 1px 1px black; 31 | font-weight: bold; 32 | } 33 | 34 | #info button:hover { 35 | background-color: rgb(180, 180, 180); 36 | color: black; 37 | text-shadow: none; 38 | } 39 | 40 | #info a { 41 | pointer-events: auto; 42 | } 43 | 44 | #info p:last-child { 45 | margin-bottom: 0; 46 | } 47 | 48 | .contrast #info *, 49 | .contrast footer * { 50 | text-shadow: none; 51 | color: black; 52 | } 53 | 54 | #info h1 { 55 | font-family: "Montserrat", sans-serif; 56 | margin-bottom: 0.5em; 57 | font-size: 1.5rem; 58 | } 59 | 60 | #info h2 { 61 | font-family: "Montserrat", sans-serif; 62 | font-size: 1rem; 63 | } 64 | 65 | #info p { 66 | margin-bottom: 1em; 67 | } 68 | -------------------------------------------------------------------------------- /app/src/components/audio/sourceControls/screenshare.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | import { type AudioSourceControlsProps } from "@/components/audio/sourceControls/common"; 3 | 4 | export interface ScreenShareControlsProps extends AudioSourceControlsProps { 5 | onDisabled: () => void; 6 | onStreamCreated: (stream: MediaStream) => void; 7 | } 8 | const ScreenShareControls = ({ 9 | audio, 10 | onDisabled, 11 | onStreamCreated, 12 | }: ScreenShareControlsProps) => { 13 | const mediaStream = useRef(null); 14 | 15 | /** 16 | * Make sure the share is enabled 17 | */ 18 | useEffect(() => { 19 | console.log("Disabling share..."); 20 | onDisabled(); 21 | if (mediaStream?.current) { 22 | mediaStream.current = null; 23 | } 24 | 25 | console.log("Enabling share..."); 26 | if (navigator.mediaDevices) { 27 | navigator.mediaDevices 28 | .getDisplayMedia({ 29 | video: { 30 | displaySurface: "browser", 31 | // logicalSurface: "exact", 32 | width: 1, 33 | }, 34 | audio: true, 35 | // selfBrowserSurface: "exclude", 36 | // surfaceSwitching: "exclude", 37 | // systemAudio: "include", 38 | }) 39 | .then((media) => { 40 | console.log(media.getAudioTracks()); 41 | onStreamCreated(media); 42 | }) 43 | .catch((err) => { 44 | console.error(err); 45 | alert("Share access denied by user"); 46 | }); 47 | } else { 48 | alert("User mediaDevices not available"); 49 | } 50 | 51 | return () => { 52 | audio.pause(); 53 | if (mediaStream?.current) { 54 | mediaStream.current = null; 55 | } 56 | }; 57 | }, [audio, onDisabled, onStreamCreated]); 58 | 59 | return <>; 60 | }; 61 | 62 | export default ScreenShareControls; 63 | -------------------------------------------------------------------------------- /app/src/components/canvas/AudioScope.tsx: -------------------------------------------------------------------------------- 1 | import { Canvas } from "@react-three/fiber"; 2 | 3 | import ModalVisual from "../visualizers/visualizerModal"; 4 | 5 | const AudioScopeCanvas = () => { 6 | return ( 7 | 8 | ; 9 | 10 | 11 | ); 12 | }; 13 | export default AudioScopeCanvas; 14 | -------------------------------------------------------------------------------- /app/src/components/canvas/AutoOrbitCamera.tsx: -------------------------------------------------------------------------------- 1 | import { useVisual } from "@/lib/appState"; 2 | import { useFrame, useThree } from "@react-three/fiber"; 3 | import { Spherical, type Vector3 } from "three"; 4 | 5 | const setFromSphericalZUp = (vec: Vector3, s: Spherical) => { 6 | const sinPhiRadius = Math.sin(s.phi) * s.radius; 7 | vec.x = sinPhiRadius * Math.sin(s.theta); 8 | vec.z = Math.cos(s.phi) * s.radius; 9 | vec.y = sinPhiRadius * Math.cos(s.theta); 10 | return vec; 11 | }; 12 | 13 | const useSphericalLimits = () => { 14 | const visual = useVisual(); 15 | // r is the Radius 16 | // theta is the equator angle 17 | // phi is the polar angle 18 | switch (visual.id) { 19 | case "ribbons": 20 | return { 21 | rMin: 10, 22 | rMax: 15, 23 | rSpeed: 0.1, 24 | thetaMin: Math.PI / 8, 25 | thetaMax: 2 * Math.PI - Math.PI / 8, 26 | thetaSpeed: 0.025, 27 | phiMin: Math.PI / 3, 28 | phiMax: Math.PI / 2.1, 29 | phiSpeed: 0.25, 30 | }; 31 | case "sphere": 32 | return { 33 | rMin: 10, 34 | rMax: 15, 35 | rSpeed: 0.1, 36 | thetaMin: 0, 37 | thetaMax: 2 * Math.PI, 38 | thetaSpeed: 0.025, 39 | phiMin: Math.PI / 3, 40 | phiMax: Math.PI / 2, 41 | phiSpeed: 0.25, 42 | }; 43 | case "cube": 44 | return { 45 | rMin: 12, 46 | rMax: 20, 47 | rSpeed: 0.1, 48 | thetaMin: 0, 49 | thetaMax: 2 * Math.PI, 50 | thetaSpeed: 0.025, 51 | phiMin: Math.PI / 4, 52 | phiMax: Math.PI / 2, 53 | phiSpeed: 0.25, 54 | }; 55 | case "diffusedRing": 56 | return { 57 | rMin: 10, 58 | rMax: 18, 59 | rSpeed: 0.1, 60 | thetaMin: 0, 61 | thetaMax: 2 * Math.PI, 62 | thetaSpeed: 0.025, 63 | phiMin: Math.PI / 8, 64 | phiMax: Math.PI / 2.25, 65 | phiSpeed: 0.25, 66 | }; 67 | case "treadmill": 68 | return { 69 | rMin: 15, 70 | rMax: 22, 71 | rSpeed: 0.1, 72 | thetaMin: 0, 73 | thetaMax: 2 * Math.PI, 74 | thetaSpeed: 0.025, 75 | phiMin: Math.PI / 3.5, 76 | phiMax: Math.PI / 2.25, 77 | phiSpeed: 0.25, 78 | }; 79 | case "movingBoxes": 80 | case "dna": 81 | case "grid": 82 | return { 83 | rMin: 15, 84 | rMax: 22, 85 | rSpeed: 0.1, 86 | thetaMin: 0, 87 | thetaMax: 2 * Math.PI, 88 | thetaSpeed: 0.025, 89 | phiMin: Math.PI / 3, 90 | phiMax: Math.PI / 2, 91 | phiSpeed: 0.25, 92 | }; 93 | case "swarm": 94 | return { 95 | rMin: 10, 96 | rMax: 15, 97 | rSpeed: 0.1, 98 | thetaMin: 0, 99 | thetaMax: 2 * Math.PI, 100 | thetaSpeed: 0.025, 101 | phiMin: Math.PI / 3, 102 | phiMax: Math.PI / 2, 103 | phiSpeed: 0.25, 104 | }; 105 | case "scope": 106 | return null; 107 | default: 108 | return visual satisfies never; 109 | } 110 | }; 111 | 112 | export const AutoOrbitCameraControls = () => { 113 | const camera = useThree((state) => state.camera); 114 | // r is the Radius 115 | // theta is the equator angle 116 | // phi is the polar angle 117 | const limits = useSphericalLimits(); 118 | const target = new Spherical(); 119 | 120 | useFrame(({ clock }) => { 121 | if (!limits) { 122 | return; 123 | } 124 | const { 125 | rMin, 126 | rMax, 127 | rSpeed, 128 | thetaMin, 129 | thetaMax, 130 | thetaSpeed, 131 | phiMin, 132 | phiMax, 133 | phiSpeed, 134 | } = limits; 135 | const t = clock.elapsedTime; 136 | 137 | const rAlpha = 0.5 * (1 + Math.sin(t * rSpeed)); 138 | const r = rMin + rAlpha * (rMax - rMin); 139 | 140 | const thetaAlpha = 0.5 * (1 + Math.cos(t * thetaSpeed)); 141 | const theta = thetaMin + thetaAlpha * (thetaMax - thetaMin); 142 | 143 | const phiAlpha = 0.5 * (1 + Math.cos(t * phiSpeed)); 144 | const phi = phiMin + phiAlpha * (phiMax - phiMin); 145 | 146 | setFromSphericalZUp(camera.position, target.set(r, phi, theta)); 147 | camera.lookAt(0, 0, 0); 148 | }); 149 | return null; 150 | }; 151 | -------------------------------------------------------------------------------- /app/src/components/canvas/Visual3D.tsx: -------------------------------------------------------------------------------- 1 | import { BackgroundFog, CanvasBackground } from "@/components/canvas/common"; 2 | import ModalVisual from "@/components/visualizers/visualizerModal"; 3 | import { useAppStateActions, useCameraState, useUser } from "@/lib/appState"; 4 | import { OrbitControls } from "@react-three/drei"; 5 | import { Canvas, useFrame } from "@react-three/fiber"; 6 | 7 | import { AutoOrbitCameraControls } from "./AutoOrbitCamera"; 8 | import { PaletteTracker } from "./paletteTracker"; 9 | 10 | const CameraControls = () => { 11 | const { mode, autoOrbitAfterSleepMs } = useCameraState(); 12 | const { setCamera } = useAppStateActions(); 13 | const { canvasInteractionEventTracker } = useUser(); 14 | 15 | useFrame(() => { 16 | if ( 17 | mode === "ORBIT_CONTROLS" && 18 | autoOrbitAfterSleepMs > 0 && 19 | canvasInteractionEventTracker.msSinceLastEvent > autoOrbitAfterSleepMs 20 | ) { 21 | setCamera({ mode: "AUTO_ORBIT" }); 22 | } else if ( 23 | mode === "AUTO_ORBIT" && 24 | canvasInteractionEventTracker.msSinceLastEvent < autoOrbitAfterSleepMs 25 | ) { 26 | setCamera({ mode: "ORBIT_CONTROLS" }); 27 | } 28 | }); 29 | 30 | switch (mode) { 31 | case "ORBIT_CONTROLS": 32 | return ; 33 | case "AUTO_ORBIT": 34 | return ; 35 | default: 36 | return mode satisfies never; 37 | } 38 | }; 39 | 40 | const Visual3DCanvas = () => { 41 | return ( 42 | 52 | 53 | 54 | 55 | 56 | {/* */} 57 | 58 | 59 | 60 | ); 61 | }; 62 | 63 | export default Visual3DCanvas; 64 | -------------------------------------------------------------------------------- /app/src/components/canvas/common.tsx: -------------------------------------------------------------------------------- 1 | import { useAppearance } from "@/lib/appState"; 2 | import { ColorPalette } from "@/lib/palettes"; 3 | 4 | const useBackgroundColor = () => { 5 | const { colorBackground, palette } = useAppearance(); 6 | return colorBackground 7 | ? ColorPalette.getPalette(palette).calcBackgroundColor(0) 8 | : "#010204"; 9 | }; 10 | 11 | export const CanvasBackground = () => { 12 | const backgroundColor = useBackgroundColor(); 13 | return ; 14 | }; 15 | 16 | export const BackgroundFog = () => { 17 | const backgroundColor = useBackgroundColor(); 18 | return ; 19 | }; 20 | -------------------------------------------------------------------------------- /app/src/components/canvas/paletteTracker.tsx: -------------------------------------------------------------------------------- 1 | import { ScalarMovingAvgEventDetector } from "@/lib/analyzers/scalarEventDetector"; 2 | import { useAppearance, useAppStateActions, useMappers } from "@/lib/appState"; 3 | import { type IScalarTracker } from "@/lib/mappers/valueTracker/common"; 4 | import { useFrame } from "@react-three/fiber"; 5 | 6 | const PaletteUpdater = ({ 7 | scalarTracker, 8 | }: { 9 | scalarTracker: IScalarTracker; 10 | }) => { 11 | const { paletteTrackEnergy: enabled } = useAppearance(); 12 | const detector = new ScalarMovingAvgEventDetector(0.5, 50, 500); 13 | const { nextPalette } = useAppStateActions(); 14 | 15 | useFrame(() => { 16 | if (!enabled) { 17 | return; 18 | } 19 | 20 | if (detector.step(scalarTracker.get())) { 21 | nextPalette(); 22 | } 23 | }); 24 | 25 | return <>; 26 | }; 27 | 28 | export const PaletteTracker = () => { 29 | const { energyTracker } = useMappers(); 30 | return energyTracker ? ( 31 | 32 | ) : null; 33 | }; 34 | -------------------------------------------------------------------------------- /app/src/components/controls/audioSource/fileUpload.tsx: -------------------------------------------------------------------------------- 1 | // import { Input } from "@/components/ui/input"; 2 | import { Label } from "@/components/ui/label"; 3 | 4 | export const FileUploadControls = () => { 5 | return ; 6 | // return ( 7 | // 13 | // ); 14 | }; 15 | -------------------------------------------------------------------------------- /app/src/components/controls/audioSource/soundcloud/controls.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense, useState } from "react"; 2 | import { SearchFilterInput } from "@/components/controls/searchFilterInput"; 3 | import { 4 | SearchFiltersContextProvider, 5 | useSearchFiltersContext, 6 | } from "@/context/searchFilters"; 7 | import { useSoundcloudContextSetters } from "@/context/soundcloud"; 8 | import { getUsers } from "@/lib/soundcloud/api"; 9 | import { type SoundcloudUser } from "@/lib/soundcloud/models"; 10 | import { useSuspenseQuery } from "@tanstack/react-query"; 11 | 12 | import { UserTrackList } from "./track"; 13 | import { UserList } from "./user"; 14 | 15 | const SouncloudUserSearch = ({ query }: { query: string }) => { 16 | const { data: users } = useSuspenseQuery({ 17 | queryKey: ["soundcloud-user-search", query], 18 | queryFn: async () => { 19 | return await getUsers({ 20 | query: query, 21 | limit: 20, 22 | }); 23 | }, 24 | }); 25 | 26 | const [user, setUser] = useState(null); 27 | const { setTrack } = useSoundcloudContextSetters(); 28 | 29 | return ( 30 |
31 | (u.track_count ?? 0) > 0)} 33 | onUserSelected={setUser} 34 | selectedUserId={user?.id} 35 | /> 36 | {user && ( 37 | Loading...}> 38 | 39 | 40 | )} 41 |
42 | ); 43 | }; 44 | 45 | const SearchedUserList = () => { 46 | const { query } = useSearchFiltersContext(); 47 | 48 | if (!query) { 49 | return No results...; 50 | } 51 | return ( 52 | Searching...}> 53 | 54 | 55 | ); 56 | }; 57 | 58 | const SoundcloudUserSearch = () => { 59 | return ( 60 | 61 | 62 | 63 | 64 | ); 65 | }; 66 | 67 | export const SoundcloudControls = () => { 68 | return ; 69 | }; 70 | -------------------------------------------------------------------------------- /app/src/components/controls/audioSource/soundcloud/player.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useEffect, 3 | useState, 4 | type ComponentPropsWithoutRef, 5 | type HTMLAttributes, 6 | } from "react"; 7 | import { useSoundcloudContext } from "@/context/soundcloud"; 8 | import { useAppearance } from "@/lib/appState"; 9 | import { getTrackStreamUrl } from "@/lib/soundcloud/api"; 10 | import { type SoundcloudTrack } from "@/lib/soundcloud/models"; 11 | import { cn } from "@/lib/utils"; 12 | import { useSuspenseQuery } from "@tanstack/react-query"; 13 | import { PauseCircle, PlayCircle } from "lucide-react"; 14 | 15 | export const TrackPlayer = ({ 16 | audio, 17 | track, 18 | className, 19 | ...props 20 | }: HTMLAttributes & { 21 | audio: HTMLAudioElement; 22 | track: SoundcloudTrack; 23 | }) => { 24 | const { showUI } = useAppearance(); 25 | 26 | const { data: streamUrl } = useSuspenseQuery({ 27 | queryKey: ["soundcloud-stream-url", track.id], 28 | queryFn: async () => { 29 | return await getTrackStreamUrl(track.id); 30 | }, 31 | }); 32 | 33 | const [play, setPlay] = useState(true); 34 | 35 | useEffect(() => { 36 | if (!streamUrl) { 37 | audio.pause(); 38 | } else { 39 | audio.src = streamUrl; 40 | const promise = audio.play(); 41 | if (promise !== undefined) { 42 | promise 43 | .then(() => console.log(`Playing ${track.title}`)) 44 | .catch((_) => { 45 | // Auto-play was prevented 46 | console.error(`Error playing ${track.title}`); 47 | }); 48 | } 49 | } 50 | return () => { 51 | audio.pause(); 52 | }; 53 | }, [audio, streamUrl, track]); 54 | 55 | useEffect(() => { 56 | if (play) { 57 | const promise = audio.play(); 58 | if (promise !== undefined) { 59 | promise 60 | .then(() => console.log(`Playing...`)) 61 | .catch((_) => { 62 | // Auto-play was prevented 63 | console.error(`Error playing!`); 64 | }); 65 | } 66 | } else { 67 | audio.pause(); 68 | } 69 | return () => { 70 | audio.pause(); 71 | }; 72 | }, [audio, play]); 73 | 74 | return ( 75 |
83 |
setPlay((curr) => !curr)} 86 | > 87 | {/* TODO: Artwork */} 88 | {play ? : } 89 |
90 |
91 | 92 | {track.title} 93 | 94 | 95 | {track.user?.username ?? "Unknown Artist"} 96 | 97 |
98 |
99 | ); 100 | }; 101 | 102 | export const CurrentTrackPlayer = ({ 103 | ...props 104 | }: Omit, "track">) => { 105 | const { track } = useSoundcloudContext(); 106 | 107 | if (!track) { 108 | return <>; 109 | } 110 | 111 | return ; 112 | }; 113 | -------------------------------------------------------------------------------- /app/src/components/controls/audioSource/soundcloud/track.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | type ComponentPropsWithoutRef, 3 | type Dispatch, 4 | type HTMLAttributes, 5 | } from "react"; 6 | import { ScrollArea } from "@/components/ui/scroll-area"; 7 | import { getUserTracks } from "@/lib/soundcloud/api"; 8 | import { type SoundcloudTrack } from "@/lib/soundcloud/models"; 9 | import { cn } from "@/lib/utils"; 10 | import { useSuspenseQuery } from "@tanstack/react-query"; 11 | import { Image } from "lucide-react"; 12 | 13 | export const TrackCard = ({ 14 | track, 15 | className, 16 | ...props 17 | }: HTMLAttributes & { track: SoundcloudTrack }) => { 18 | return ( 19 |
26 | {track.artwork_url ? ( 27 | Artwork 32 | ) : ( 33 | 34 | )} 35 |
36 | {track.title} 37 | 38 | playcount:{" "} 39 | {track.playback_count?.toLocaleString("en-US", { 40 | maximumFractionDigits: 0, 41 | })} 42 | 43 |
44 |
45 | ); 46 | }; 47 | 48 | export const TrackList = ({ 49 | tracks, 50 | onTrackSelected, 51 | // pageSize = 10, 52 | className, 53 | ...props 54 | }: ComponentPropsWithoutRef & { 55 | tracks: SoundcloudTrack[]; 56 | onTrackSelected: Dispatch; 57 | // pageSize?: number; 58 | }) => { 59 | // const [pageIdx, setPageIndex] = useState(0); 60 | // const maxPageIdx = Math.ceil(tracks.length / pageSize); 61 | 62 | if (tracks.length === 0) { 63 | return ( 64 | 65 | This artist has no playable tracks. 66 | 67 | ); 68 | } 69 | return ( 70 | 77 | {tracks.map((track) => ( 78 | { 82 | onTrackSelected(track); 83 | }} 84 | /> 85 | ))} 86 | 87 | ); 88 | }; 89 | 90 | export const UserTrackList = ({ 91 | userId, 92 | limit = 10, 93 | ...props 94 | }: Omit, "tracks"> & { 95 | userId: number; 96 | limit?: number; 97 | }) => { 98 | const { data: tracks } = useSuspenseQuery({ 99 | queryKey: ["soundcloud-user-track-search", userId], 100 | queryFn: async () => { 101 | return await getUserTracks({ 102 | userId: userId, 103 | limit: limit, 104 | }); 105 | }, 106 | }); 107 | 108 | return ; 109 | }; 110 | -------------------------------------------------------------------------------- /app/src/components/controls/audioSource/soundcloud/user.tsx: -------------------------------------------------------------------------------- 1 | import { type Dispatch, type HTMLAttributes } from "react"; 2 | import { type SoundcloudUser } from "@/lib/soundcloud/models"; 3 | import { cn } from "@/lib/utils"; 4 | import { Image } from "lucide-react"; 5 | 6 | export const UserCard = ({ 7 | user, 8 | className, 9 | ...props 10 | }: HTMLAttributes & { user: SoundcloudUser }) => { 11 | return ( 12 |
19 | {user.avatar_url ? ( 20 | User avatar 25 | ) : ( 26 | 27 | )} 28 | 29 | {user.username} 30 | 31 |
32 | ); 33 | }; 34 | 35 | export const UserList = ({ 36 | users, 37 | selectedUserId = undefined, 38 | onUserSelected, 39 | className, 40 | ...props 41 | }: HTMLAttributes & { 42 | users: SoundcloudUser[]; 43 | selectedUserId?: number; 44 | onUserSelected: Dispatch; 45 | }) => { 46 | return ( 47 |
54 | {users?.map((user) => ( 55 | { 60 | onUserSelected(user); 61 | }} 62 | /> 63 | ))} 64 |
65 | ); 66 | }; 67 | -------------------------------------------------------------------------------- /app/src/components/controls/common.tsx: -------------------------------------------------------------------------------- 1 | import { type HTMLAttributes, type HTMLProps, type ReactNode } from "react"; 2 | import { Label } from "@/components/ui/label"; 3 | import { 4 | Popover, 5 | PopoverContent, 6 | PopoverTrigger, 7 | } from "@/components/ui/popover"; 8 | import { cn } from "@/lib/utils"; 9 | 10 | export const ValueLabel = ({ 11 | label, 12 | value, 13 | className, 14 | ...props 15 | }: HTMLProps & { 16 | label: string; 17 | value: string | number; 18 | }) => { 19 | return ( 20 |
24 | 25 | 26 | {value} 27 | 28 |
29 | ); 30 | }; 31 | 32 | export const ToolbarItem = ({ 33 | children, 34 | className, 35 | ...props 36 | }: HTMLAttributes) => { 37 | return ( 38 |
45 | {children} 46 |
47 | ); 48 | }; 49 | 50 | export const ToolbarPopover = ({ 51 | trigger, 52 | ...props 53 | }: HTMLAttributes & { 54 | trigger: ReactNode; 55 | align?: "start" | "end" | "center"; 56 | }) => { 57 | return ( 58 | 59 | {trigger} 60 | 61 | 62 | ); 63 | }; 64 | -------------------------------------------------------------------------------- /app/src/components/controls/dock.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef, useMemo, useState, type HTMLProps } from "react"; 2 | import { VISUAL_REGISTRY } from "@/components/visualizers/registry"; 3 | import { useClientDetails } from "@/hooks/use-client-details"; 4 | import { useAppStateActions, useMode, useVisual } from "@/lib/appState"; 5 | import { cn } from "@/lib/utils"; 6 | import { Settings } from "lucide-react"; 7 | 8 | import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; 9 | import { Sheet, SheetContent } from "../ui/sheet"; 10 | import { MobileDrawer } from "./mobile-drawer"; 11 | import { ModeSheetContent } from "./modeSheet"; 12 | import { VisualSettingsSheetContent } from "./visualSettingsSheet"; 13 | 14 | export const SettingsPanelTrigger = () => { 15 | const [open, setOpen] = useState(false); 16 | const { isSmallScreen } = useClientDetails(); 17 | return ( 18 | <> 19 | setOpen((prev) => !prev)} 22 | > 23 | 24 | 25 | {isSmallScreen ? ( 26 | 27 | 28 | 29 | 30 | ) : ( 31 | 32 | 37 | 38 | 39 | 40 | 41 | )} 42 | 43 | ); 44 | }; 45 | 46 | const DockItem = forwardRef>( 47 | ({ className, ...props }, ref) => { 48 | return ( 49 |
57 | ); 58 | }, 59 | ); 60 | 61 | const VisualSelector = () => { 62 | const activeVisual = useVisual(); 63 | const { setVisual } = useAppStateActions(); 64 | const mode = useMode(); 65 | 66 | const supportedVisuals = useMemo(() => { 67 | return Object.values(VISUAL_REGISTRY).filter((visual) => 68 | [...visual.supportedApplicationModes].includes(mode), 69 | ); 70 | }, [mode]); 71 | return ( 72 | 73 | 74 | 75 | 76 | 77 | 78 | 82 |
83 | {supportedVisuals.map((visual) => ( 84 | setVisual(visual.id)} 88 | className="aria-selected:animate-bounce" 89 | > 90 | 91 | 92 | ))} 93 |
94 |
95 |
96 | ); 97 | }; 98 | 99 | export const ApplicationDock = () => { 100 | return ( 101 |
102 |
103 |
104 | 105 | 106 |
107 |
108 |
109 | ); 110 | }; 111 | 112 | export default ApplicationDock; 113 | -------------------------------------------------------------------------------- /app/src/components/controls/main.tsx: -------------------------------------------------------------------------------- 1 | import VisualsDock from "@/components/controls/dock"; 2 | import { Switch } from "@/components/ui/switch"; 3 | import { useAppearance, useAppStateActions } from "@/lib/appState"; 4 | 5 | export const ControlsPanel = () => { 6 | const { showUI } = useAppearance(); 7 | const { setAppearance } = useAppStateActions(); 8 | return ( 9 | <> 10 |
11 | { 16 | setAppearance({ showUI: e }); 17 | }} 18 | /> 19 |
20 | {showUI && } 21 | 22 | ); 23 | }; 24 | 25 | export default ControlsPanel; 26 | -------------------------------------------------------------------------------- /app/src/components/controls/mobile-drawer.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | type Dispatch, 5 | type PropsWithChildren, 6 | type SetStateAction, 7 | } from "react"; 8 | import { cn } from "@/lib/utils"; 9 | import { GripHorizontal } from "lucide-react"; 10 | import { Drawer } from "vaul"; 11 | 12 | export const MobileDrawer = ({ 13 | open, 14 | onOpenChange, 15 | children, 16 | className, 17 | }: PropsWithChildren<{ 18 | open: boolean; 19 | onOpenChange: Dispatch>; 20 | className?: string; 21 | }>) => { 22 | return ( 23 | 30 | 31 | 38 |
39 | 40 |
41 |