From 5ed84192d54e633bf737fc45aa498fbef846fbc2 Mon Sep 17 00:00:00 2001 From: Avery Felts Date: Tue, 20 Jan 2026 17:29:28 -0700 Subject: [PATCH] Implement lofi hip hop generator with Tone.js - Set up Next.js project with shadcn/ui and Tailwind CSS - Created audio engine with MembraneSynth drums, FMSynth chords, and ambient noise layers - Implemented 16-step drum sequencer with boom bap patterns - Added jazz chord progressions (ii-V-I, minor key, neo soul) - Built React hook for audio state management - Created UI components: transport controls, volume sliders, layer mixer, beat visualizer - Applied lofi-themed dark color scheme with oklch colors Co-Authored-By: Claude Opus 4.5 --- app/globals.css | 123 ++++++++- app/layout.tsx | 4 +- app/page.tsx | 64 +---- bun.lock | 72 ++++++ components.json | 22 ++ components/lofi-generator/LayerMixer.tsx | 71 ++++++ components/lofi-generator/LofiGenerator.tsx | 110 ++++++++ .../lofi-generator/TransportControls.tsx | 45 ++++ components/lofi-generator/Visualizer.tsx | 35 +++ components/lofi-generator/VolumeControl.tsx | 37 +++ components/ui/button.tsx | 62 +++++ components/ui/card.tsx | 92 +++++++ components/ui/label.tsx | 24 ++ components/ui/slider.tsx | 63 +++++ components/ui/toggle.tsx | 47 ++++ hooks/useAudioEngine.ts | 160 ++++++++++++ lib/audio/ambientLayer.ts | 97 +++++++ lib/audio/audioEngine.ts | 238 ++++++++++++++++++ lib/audio/chordEngine.ts | 139 ++++++++++ lib/audio/drumMachine.ts | 165 ++++++++++++ lib/audio/patterns.ts | 145 +++++++++++ lib/utils.ts | 6 + package.json | 12 +- types/audio.ts | 38 +++ 24 files changed, 1794 insertions(+), 77 deletions(-) create mode 100644 components.json create mode 100644 components/lofi-generator/LayerMixer.tsx create mode 100644 components/lofi-generator/LofiGenerator.tsx create mode 100644 components/lofi-generator/TransportControls.tsx create mode 100644 components/lofi-generator/Visualizer.tsx create mode 100644 components/lofi-generator/VolumeControl.tsx create mode 100644 components/ui/button.tsx create mode 100644 components/ui/card.tsx create mode 100644 components/ui/label.tsx create mode 100644 components/ui/slider.tsx create mode 100644 components/ui/toggle.tsx create mode 100644 hooks/useAudioEngine.ts create mode 100644 lib/audio/ambientLayer.ts create mode 100644 lib/audio/audioEngine.ts create mode 100644 lib/audio/chordEngine.ts create mode 100644 lib/audio/drumMachine.ts create mode 100644 lib/audio/patterns.ts create mode 100644 lib/utils.ts create mode 100644 types/audio.ts diff --git a/app/globals.css b/app/globals.css index a2dc41e..3c0816e 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,26 +1,125 @@ @import "tailwindcss"; +@import "tw-animate-css"; -:root { - --background: #ffffff; - --foreground: #171717; -} +@custom-variant dark (&:is(.dark *)); @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --radius-2xl: calc(var(--radius) + 8px); + --radius-3xl: calc(var(--radius) + 12px); + --radius-4xl: calc(var(--radius) + 16px); + --color-lofi-purple: var(--lofi-purple); + --color-lofi-orange: var(--lofi-orange); + --color-lofi-pink: var(--lofi-pink); } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; +/* Lofi dark theme - always dark mode */ +:root { + --radius: 0.75rem; + /* Deep purple-tinted dark background */ + --background: oklch(0.15 0.02 280); + --foreground: oklch(0.92 0.01 280); + /* Card with subtle purple tint */ + --card: oklch(0.18 0.025 280); + --card-foreground: oklch(0.92 0.01 280); + --popover: oklch(0.18 0.025 280); + --popover-foreground: oklch(0.92 0.01 280); + /* Warm orange primary for lofi vibes */ + --primary: oklch(0.75 0.15 50); + --primary-foreground: oklch(0.15 0.02 280); + /* Muted purple secondary */ + --secondary: oklch(0.25 0.04 280); + --secondary-foreground: oklch(0.85 0.02 280); + --muted: oklch(0.22 0.03 280); + --muted-foreground: oklch(0.65 0.02 280); + /* Accent with warm pink */ + --accent: oklch(0.65 0.12 350); + --accent-foreground: oklch(0.95 0.01 280); + --destructive: oklch(0.55 0.2 25); + --border: oklch(0.28 0.03 280); + --input: oklch(0.22 0.03 280); + --ring: oklch(0.75 0.15 50); + /* Lofi color palette */ + --lofi-purple: oklch(0.55 0.15 280); + --lofi-orange: oklch(0.75 0.15 50); + --lofi-pink: oklch(0.65 0.12 350); + /* Chart colors */ + --chart-1: oklch(0.75 0.15 50); + --chart-2: oklch(0.65 0.12 350); + --chart-3: oklch(0.55 0.15 280); + --chart-4: oklch(0.7 0.1 180); + --chart-5: oklch(0.6 0.15 140); + /* Sidebar */ + --sidebar: oklch(0.12 0.02 280); + --sidebar-foreground: oklch(0.92 0.01 280); + --sidebar-primary: oklch(0.75 0.15 50); + --sidebar-primary-foreground: oklch(0.15 0.02 280); + --sidebar-accent: oklch(0.22 0.03 280); + --sidebar-accent-foreground: oklch(0.92 0.01 280); + --sidebar-border: oklch(0.28 0.03 280); + --sidebar-ring: oklch(0.75 0.15 50); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; } } -body { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; +/* Custom scrollbar for lofi aesthetic */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: oklch(0.15 0.02 280); +} + +::-webkit-scrollbar-thumb { + background: oklch(0.35 0.04 280); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: oklch(0.45 0.05 280); } diff --git a/app/layout.tsx b/app/layout.tsx index f7fa87e..df0051d 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -13,8 +13,8 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Lofi Generator", + description: "Web-based lofi hip hop beat generator - beats to relax/study to", }; export default function RootLayout({ diff --git a/app/page.tsx b/app/page.tsx index 295f8fd..5b4fe3e 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,65 +1,5 @@ -import Image from "next/image"; +import { LofiGenerator } from '@/components/lofi-generator/LofiGenerator'; export default function Home() { - return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
-
- - Vercel logomark - Deploy Now - - - Documentation - -
-
-
- ); + return ; } diff --git a/bun.lock b/bun.lock index 9823ca4..fb817bd 100644 --- a/bun.lock +++ b/bun.lock @@ -5,9 +5,18 @@ "": { "name": "lofi-generator", "dependencies": { + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-slider": "^1.3.6", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-toggle": "^1.1.10", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.562.0", "next": "16.1.4", "react": "19.2.3", "react-dom": "19.2.3", + "tailwind-merge": "^3.4.0", + "tone": "^15.1.22", }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -17,6 +26,7 @@ "eslint": "^9", "eslint-config-next": "16.1.4", "tailwindcss": "^4", + "tw-animate-css": "^1.4.0", "typescript": "^5", }, }, @@ -54,6 +64,8 @@ "@babel/parser": ["@babel/parser@7.28.6", "", { "dependencies": { "@babel/types": "^7.28.6" }, "bin": "./bin/babel-parser.js" }, "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ=="], + "@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="], + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], "@babel/traverse": ["@babel/traverse@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/types": "^7.28.6", "debug": "^4.3.1" } }, "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg=="], @@ -182,6 +194,38 @@ "@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="], + "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], + + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + + "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], + + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], + + "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="], + + "@radix-ui/react-label": ["@radix-ui/react-label@2.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A=="], + + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], + + "@radix-ui/react-slider": ["@radix-ui/react-slider@1.3.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw=="], + + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], + + "@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ=="], + + "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], + + "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="], + + "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + + "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="], + + "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="], + "@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], @@ -320,6 +364,8 @@ "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], + "automation-events": ["automation-events@7.1.15", "", { "dependencies": { "@babel/runtime": "^7.28.6", "tslib": "^2.8.1" } }, "sha512-NsHJlve3twcgs8IyP4iEYph7Fzpnh6klN7G5LahwvypakBjFbsiGHJxrqTmeHKREdu/Tx6oZboqNI0tD4MnFlA=="], + "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], "axe-core": ["axe-core@4.11.1", "", {}, "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A=="], @@ -348,8 +394,12 @@ "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], @@ -636,6 +686,8 @@ "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -762,6 +814,8 @@ "stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="], + "standardized-audio-context": ["standardized-audio-context@25.3.77", "", { "dependencies": { "@babel/runtime": "^7.25.6", "automation-events": "^7.0.9", "tslib": "^2.7.0" } }, "sha512-Ki9zNz6pKcC5Pi+QPjPyVsD9GwJIJWgryji0XL9cAJXMGyn+dPOf6Qik1AHei0+UNVcc4BOCa0hWLBzlwqsW/A=="], + "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], "string.prototype.includes": ["string.prototype.includes@2.0.1", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="], @@ -786,6 +840,8 @@ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + "tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="], + "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], @@ -794,12 +850,16 @@ "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "tone": ["tone@15.1.22", "", { "dependencies": { "standardized-audio-context": "^25.3.70", "tslib": "^2.3.1" } }, "sha512-TCScAGD4sLsama5DjvTUXlLDXSqPealhL64nsdV1hhr6frPWve0DeSo63AKnSJwgfg55fhvxj0iPPRwPN5o0ag=="], + "ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="], "tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], @@ -850,6 +910,14 @@ "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + "@radix-ui/react-collection/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-slider/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-toggle/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], @@ -886,6 +954,10 @@ "sharp/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "@radix-ui/react-slider/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-toggle/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], } } diff --git a/components.json b/components.json new file mode 100644 index 0000000..b7b9791 --- /dev/null +++ b/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/components/lofi-generator/LayerMixer.tsx b/components/lofi-generator/LayerMixer.tsx new file mode 100644 index 0000000..3e9edbf --- /dev/null +++ b/components/lofi-generator/LayerMixer.tsx @@ -0,0 +1,71 @@ +'use client'; + +import { Toggle } from '@/components/ui/toggle'; +import { VolumeControl } from './VolumeControl'; +import { Volume2, VolumeX, Drum, Music, Cloud } from 'lucide-react'; +import { LayerName } from '@/types/audio'; + +interface LayerMixerProps { + volumes: { + drums: number; + chords: number; + ambient: number; + }; + muted: { + drums: boolean; + chords: boolean; + ambient: boolean; + }; + onVolumeChange: (layer: LayerName, volume: number) => void; + onToggleMute: (layer: LayerName) => void; +} + +const layers: { name: LayerName; label: string; icon: React.ReactNode }[] = [ + { name: 'drums', label: 'Drums', icon: }, + { name: 'chords', label: 'Chords', icon: }, + { name: 'ambient', label: 'Ambient', icon: }, +]; + +export function LayerMixer({ + volumes, + muted, + onVolumeChange, + onToggleMute, +}: LayerMixerProps) { + return ( +
+

+ Layers +

+
+ {layers.map(({ name, label, icon }) => ( +
+ onToggleMute(name)} + size="sm" + className="shrink-0" + aria-label={`Toggle ${label}`} + > + {muted[name] ? ( + + ) : ( + + )} + +
+ {icon} + {label} +
+ onVolumeChange(name, v)} + className="flex-1" + /> +
+ ))} +
+
+ ); +} diff --git a/components/lofi-generator/LofiGenerator.tsx b/components/lofi-generator/LofiGenerator.tsx new file mode 100644 index 0000000..cc71a92 --- /dev/null +++ b/components/lofi-generator/LofiGenerator.tsx @@ -0,0 +1,110 @@ +'use client'; + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { TransportControls } from './TransportControls'; +import { VolumeControl } from './VolumeControl'; +import { LayerMixer } from './LayerMixer'; +import { Visualizer } from './Visualizer'; +import { useAudioEngine } from '@/hooks/useAudioEngine'; +import { Slider } from '@/components/ui/slider'; +import { Label } from '@/components/ui/label'; + +export function LofiGenerator() { + const { + state, + currentStep, + togglePlayback, + generateNewBeat, + setMasterVolume, + setLayerVolume, + toggleMute, + setBpm, + setSwing, + } = useAudioEngine(); + + return ( +
+ + + + lofi generator + +

+ beats to relax/study to +

+
+ + + {/* Visualizer */} + + + {/* Transport Controls */} +
+ +
+ + {/* Master Volume */} +
+ +
+ + {/* BPM and Swing Controls */} +
+
+
+ + + {state.bpm} + +
+ setBpm(v)} + min={60} + max={100} + step={1} + /> +
+
+
+ + + {Math.round(state.swing * 100)}% + +
+ setSwing(v)} + min={0} + max={0.5} + step={0.01} + /> +
+
+ + {/* Layer Mixer */} + + + {/* Footer */} +

+ Click play to start the audio engine +

+
+
+
+ ); +} diff --git a/components/lofi-generator/TransportControls.tsx b/components/lofi-generator/TransportControls.tsx new file mode 100644 index 0000000..908e319 --- /dev/null +++ b/components/lofi-generator/TransportControls.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import { Play, Pause, Shuffle } from 'lucide-react'; + +interface TransportControlsProps { + isPlaying: boolean; + isInitialized: boolean; + onTogglePlayback: () => void; + onGenerateNewBeat: () => void; +} + +export function TransportControls({ + isPlaying, + isInitialized, + onTogglePlayback, + onGenerateNewBeat, +}: TransportControlsProps) { + return ( +
+ + + +
+ ); +} diff --git a/components/lofi-generator/Visualizer.tsx b/components/lofi-generator/Visualizer.tsx new file mode 100644 index 0000000..877829d --- /dev/null +++ b/components/lofi-generator/Visualizer.tsx @@ -0,0 +1,35 @@ +'use client'; + +interface VisualizerProps { + currentStep: number; + isPlaying: boolean; +} + +export function Visualizer({ currentStep, isPlaying }: VisualizerProps) { + return ( +
+ {Array.from({ length: 16 }, (_, i) => { + const isActive = isPlaying && currentStep === i; + const isBeat = i % 4 === 0; + + return ( +
+ ); + })} +
+ ); +} diff --git a/components/lofi-generator/VolumeControl.tsx b/components/lofi-generator/VolumeControl.tsx new file mode 100644 index 0000000..746b561 --- /dev/null +++ b/components/lofi-generator/VolumeControl.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { Slider } from '@/components/ui/slider'; +import { Label } from '@/components/ui/label'; + +interface VolumeControlProps { + label: string; + value: number; + onChange: (value: number) => void; + className?: string; +} + +export function VolumeControl({ + label, + value, + onChange, + className = '', +}: VolumeControlProps) { + return ( +
+
+ + + {Math.round(value * 100)}% + +
+ onChange(v)} + min={0} + max={1} + step={0.01} + className="w-full" + /> +
+ ); +} diff --git a/components/ui/button.tsx b/components/ui/button.tsx new file mode 100644 index 0000000..37a7d4b --- /dev/null +++ b/components/ui/button.tsx @@ -0,0 +1,62 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all 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", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + "icon-sm": "size-8", + "icon-lg": "size-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant = "default", + size = "default", + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot : "button" + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/components/ui/card.tsx b/components/ui/card.tsx new file mode 100644 index 0000000..681ad98 --- /dev/null +++ b/components/ui/card.tsx @@ -0,0 +1,92 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/components/ui/label.tsx b/components/ui/label.tsx new file mode 100644 index 0000000..fb5fbc3 --- /dev/null +++ b/components/ui/label.tsx @@ -0,0 +1,24 @@ +"use client" + +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" + +import { cn } from "@/lib/utils" + +function Label({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Label } diff --git a/components/ui/slider.tsx b/components/ui/slider.tsx new file mode 100644 index 0000000..1a1bd73 --- /dev/null +++ b/components/ui/slider.tsx @@ -0,0 +1,63 @@ +"use client" + +import * as React from "react" +import * as SliderPrimitive from "@radix-ui/react-slider" + +import { cn } from "@/lib/utils" + +function Slider({ + className, + defaultValue, + value, + min = 0, + max = 100, + ...props +}: React.ComponentProps) { + const _values = React.useMemo( + () => + Array.isArray(value) + ? value + : Array.isArray(defaultValue) + ? defaultValue + : [min, max], + [value, defaultValue, min, max] + ) + + return ( + + + + + {Array.from({ length: _values.length }, (_, index) => ( + + ))} + + ) +} + +export { Slider } diff --git a/components/ui/toggle.tsx b/components/ui/toggle.tsx new file mode 100644 index 0000000..94ec8f5 --- /dev/null +++ b/components/ui/toggle.tsx @@ -0,0 +1,47 @@ +"use client" + +import * as React from "react" +import * as TogglePrimitive from "@radix-ui/react-toggle" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const toggleVariants = cva( + "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap", + { + variants: { + variant: { + default: "bg-transparent", + outline: + "border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground", + }, + size: { + default: "h-9 px-2 min-w-9", + sm: "h-8 px-1.5 min-w-8", + lg: "h-10 px-2.5 min-w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Toggle({ + className, + variant, + size, + ...props +}: React.ComponentProps & + VariantProps) { + return ( + + ) +} + +export { Toggle, toggleVariants } diff --git a/hooks/useAudioEngine.ts b/hooks/useAudioEngine.ts new file mode 100644 index 0000000..557f210 --- /dev/null +++ b/hooks/useAudioEngine.ts @@ -0,0 +1,160 @@ +'use client'; + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { EngineState, LayerName } from '@/types/audio'; + +const defaultState: EngineState = { + isPlaying: false, + isInitialized: false, + bpm: 78, + swing: 0.12, + currentStep: 0, + volumes: { + master: 0.8, + drums: 0.8, + chords: 0.6, + ambient: 0.4, + }, + muted: { + drums: false, + chords: false, + ambient: false, + }, +}; + +export function useAudioEngine() { + const [state, setState] = useState(defaultState); + const [currentStep, setCurrentStep] = useState(0); + const engineRef = useRef(null); + const isInitializingRef = useRef(false); + + // Dynamically import the audio engine (client-side only) + const getEngine = useCallback(async () => { + if (typeof window === 'undefined') return null; + + if (!engineRef.current) { + const { default: audioEngine } = await import('@/lib/audio/audioEngine'); + engineRef.current = audioEngine; + } + return engineRef.current; + }, []); + + // Initialize engine and set up callbacks + const initialize = useCallback(async () => { + if (isInitializingRef.current) return; + isInitializingRef.current = true; + + try { + const engine = await getEngine(); + if (!engine) return; + + engine.setCallbacks({ + onStepChange: (step) => { + setCurrentStep(step); + }, + onStateChange: (newState) => { + setState(newState); + }, + }); + + await engine.initialize(); + setState(engine.getState()); + } finally { + isInitializingRef.current = false; + } + }, [getEngine]); + + // Play/pause toggle + const togglePlayback = useCallback(async () => { + const engine = await getEngine(); + if (!engine) return; + + if (!state.isInitialized) { + await initialize(); + } + + const currentState = engine.getState(); + if (currentState.isPlaying) { + engine.pause(); + } else { + await engine.play(); + } + }, [getEngine, state.isInitialized, initialize]); + + // Stop playback + const stop = useCallback(async () => { + const engine = await getEngine(); + if (!engine) return; + engine.stop(); + setCurrentStep(0); + }, [getEngine]); + + // Generate new beat + const generateNewBeat = useCallback(async () => { + const engine = await getEngine(); + if (!engine) return; + + if (!state.isInitialized) { + await initialize(); + } + + engine.generateNewBeat(); + }, [getEngine, state.isInitialized, initialize]); + + // Set BPM + const setBpm = useCallback(async (bpm: number) => { + const engine = await getEngine(); + if (!engine) return; + engine.setBpm(bpm); + }, [getEngine]); + + // Set swing + const setSwing = useCallback(async (swing: number) => { + const engine = await getEngine(); + if (!engine) return; + engine.setSwing(swing); + }, [getEngine]); + + // Set master volume + const setMasterVolume = useCallback(async (volume: number) => { + const engine = await getEngine(); + if (!engine) return; + engine.setMasterVolume(volume); + }, [getEngine]); + + // Set layer volume + const setLayerVolume = useCallback(async (layer: LayerName, volume: number) => { + const engine = await getEngine(); + if (!engine) return; + engine.setLayerVolume(layer, volume); + }, [getEngine]); + + // Toggle layer mute + const toggleMute = useCallback(async (layer: LayerName) => { + const engine = await getEngine(); + if (!engine) return; + engine.toggleMute(layer); + }, [getEngine]); + + // Cleanup on unmount + useEffect(() => { + return () => { + // Don't dispose on unmount to allow seamless navigation + // The engine is a singleton that persists + }; + }, []); + + return { + state, + currentStep, + initialize, + togglePlayback, + stop, + generateNewBeat, + setBpm, + setSwing, + setMasterVolume, + setLayerVolume, + toggleMute, + }; +} diff --git a/lib/audio/ambientLayer.ts b/lib/audio/ambientLayer.ts new file mode 100644 index 0000000..38bd565 --- /dev/null +++ b/lib/audio/ambientLayer.ts @@ -0,0 +1,97 @@ +import * as Tone from 'tone'; + +export class AmbientLayer { + private rainNoise: Tone.Noise; + private vinylNoise: Tone.Noise; + private rainFilter: Tone.Filter; + private vinylFilter: Tone.Filter; + private rainGain: Tone.Gain; + private vinylGain: Tone.Gain; + private output: Tone.Gain; + private lfo: Tone.LFO; + + constructor(destination: Tone.InputNode) { + this.output = new Tone.Gain(0.4); + + // Rain sound - filtered pink noise + this.rainNoise = new Tone.Noise('pink'); + this.rainFilter = new Tone.Filter({ + frequency: 3000, + type: 'lowpass', + rolloff: -24, + }); + this.rainGain = new Tone.Gain(0.15); + + // Vinyl crackle - filtered brown noise with modulation + this.vinylNoise = new Tone.Noise('brown'); + this.vinylFilter = new Tone.Filter({ + frequency: 1500, + type: 'bandpass', + Q: 2, + }); + this.vinylGain = new Tone.Gain(0.1); + + // LFO for subtle rain intensity variation + this.lfo = new Tone.LFO({ + frequency: 0.1, + min: 0.1, + max: 0.2, + }); + this.lfo.connect(this.rainGain.gain); + + // Chain rain: noise -> filter -> gain -> output + this.rainNoise.connect(this.rainFilter); + this.rainFilter.connect(this.rainGain); + this.rainGain.connect(this.output); + + // Chain vinyl: noise -> filter -> gain -> output + this.vinylNoise.connect(this.vinylFilter); + this.vinylFilter.connect(this.vinylGain); + this.vinylGain.connect(this.output); + + // Output to destination + this.output.connect(destination); + } + + start(): void { + this.rainNoise.start(); + this.vinylNoise.start(); + this.lfo.start(); + } + + stop(): void { + this.rainNoise.stop(); + this.vinylNoise.stop(); + this.lfo.stop(); + } + + setVolume(volume: number): void { + this.output.gain.rampTo(volume, 0.1); + } + + mute(muted: boolean): void { + this.output.gain.rampTo(muted ? 0 : 0.4, 0.1); + } + + setRainIntensity(intensity: number): void { + this.rainGain.gain.rampTo(intensity * 0.2, 0.5); + } + + setVinylIntensity(intensity: number): void { + this.vinylGain.gain.rampTo(intensity * 0.15, 0.5); + } + + dispose(): void { + this.rainNoise.stop(); + this.vinylNoise.stop(); + this.lfo.stop(); + this.rainNoise.dispose(); + this.vinylNoise.dispose(); + this.rainFilter.dispose(); + this.vinylFilter.dispose(); + this.rainGain.dispose(); + this.vinylGain.dispose(); + this.lfo.dispose(); + this.output.dispose(); + } +} diff --git a/lib/audio/audioEngine.ts b/lib/audio/audioEngine.ts new file mode 100644 index 0000000..06c5770 --- /dev/null +++ b/lib/audio/audioEngine.ts @@ -0,0 +1,238 @@ +import * as Tone from 'tone'; +import { DrumMachine } from './drumMachine'; +import { ChordEngine } from './chordEngine'; +import { AmbientLayer } from './ambientLayer'; +import { EngineState, AudioEngineCallbacks, LayerName } from '@/types/audio'; + +class AudioEngine { + private static instance: AudioEngine | null = null; + + private drumMachine: DrumMachine | null = null; + private chordEngine: ChordEngine | null = null; + private ambientLayer: AmbientLayer | null = null; + + private masterGain: Tone.Gain | null = null; + private masterCompressor: Tone.Compressor | null = null; + private masterLimiter: Tone.Limiter | null = null; + private masterReverb: Tone.Reverb | null = null; + + private callbacks: AudioEngineCallbacks = {}; + + private state: EngineState = { + isPlaying: false, + isInitialized: false, + bpm: 78, + swing: 0.12, + currentStep: 0, + volumes: { + master: 0.8, + drums: 0.8, + chords: 0.6, + ambient: 0.4, + }, + muted: { + drums: false, + chords: false, + ambient: false, + }, + }; + + private constructor() {} + + static getInstance(): AudioEngine { + if (!AudioEngine.instance) { + AudioEngine.instance = new AudioEngine(); + } + return AudioEngine.instance; + } + + async initialize(): Promise { + if (this.state.isInitialized) return; + + // Start Tone.js audio context (requires user gesture) + await Tone.start(); + + // Set up transport + Tone.getTransport().bpm.value = this.state.bpm; + Tone.getTransport().swing = this.state.swing; + Tone.getTransport().swingSubdivision = '16n'; + + // Master chain + this.masterGain = new Tone.Gain(this.state.volumes.master); + this.masterCompressor = new Tone.Compressor({ + threshold: -20, + ratio: 4, + attack: 0.003, + release: 0.25, + }); + this.masterLimiter = new Tone.Limiter(-1); + this.masterReverb = new Tone.Reverb({ + decay: 2, + wet: 0.15, + }); + + // Chain: gain -> reverb -> compressor -> limiter -> destination + this.masterGain.connect(this.masterReverb); + this.masterReverb.connect(this.masterCompressor); + this.masterCompressor.connect(this.masterLimiter); + this.masterLimiter.toDestination(); + + // Initialize layers + this.drumMachine = new DrumMachine(this.masterGain); + this.chordEngine = new ChordEngine(this.masterGain); + this.ambientLayer = new AmbientLayer(this.masterGain); + + // Create sequences + this.drumMachine.createSequence((step) => { + this.state.currentStep = step; + this.callbacks.onStepChange?.(step); + }); + this.chordEngine.createSequence(); + + this.state.isInitialized = true; + this.notifyStateChange(); + } + + setCallbacks(callbacks: AudioEngineCallbacks): void { + this.callbacks = callbacks; + } + + private notifyStateChange(): void { + this.callbacks.onStateChange?.({ ...this.state }); + } + + async play(): Promise { + if (!this.state.isInitialized) { + await this.initialize(); + } + + this.ambientLayer?.start(); + Tone.getTransport().start(); + this.state.isPlaying = true; + this.notifyStateChange(); + } + + pause(): void { + Tone.getTransport().pause(); + this.ambientLayer?.stop(); + this.state.isPlaying = false; + this.notifyStateChange(); + } + + stop(): void { + Tone.getTransport().stop(); + this.ambientLayer?.stop(); + this.state.isPlaying = false; + this.state.currentStep = 0; + this.notifyStateChange(); + } + + generateNewBeat(): void { + this.drumMachine?.randomize(); + this.chordEngine?.randomize(); + this.notifyStateChange(); + } + + setBpm(bpm: number): void { + this.state.bpm = Math.max(60, Math.min(100, bpm)); + Tone.getTransport().bpm.value = this.state.bpm; + this.notifyStateChange(); + } + + setSwing(swing: number): void { + this.state.swing = Math.max(0, Math.min(0.5, swing)); + Tone.getTransport().swing = this.state.swing; + this.notifyStateChange(); + } + + setMasterVolume(volume: number): void { + this.state.volumes.master = Math.max(0, Math.min(1, volume)); + this.masterGain?.gain.rampTo(this.state.volumes.master, 0.1); + this.notifyStateChange(); + } + + setLayerVolume(layer: LayerName, volume: number): void { + const normalizedVolume = Math.max(0, Math.min(1, volume)); + this.state.volumes[layer] = normalizedVolume; + + switch (layer) { + case 'drums': + this.drumMachine?.setVolume(normalizedVolume); + break; + case 'chords': + this.chordEngine?.setVolume(normalizedVolume); + break; + case 'ambient': + this.ambientLayer?.setVolume(normalizedVolume); + break; + } + + this.notifyStateChange(); + } + + toggleMute(layer: LayerName): void { + this.state.muted[layer] = !this.state.muted[layer]; + + switch (layer) { + case 'drums': + this.drumMachine?.mute(this.state.muted[layer]); + break; + case 'chords': + this.chordEngine?.mute(this.state.muted[layer]); + break; + case 'ambient': + this.ambientLayer?.mute(this.state.muted[layer]); + break; + } + + this.notifyStateChange(); + } + + setMuted(layer: LayerName, muted: boolean): void { + this.state.muted[layer] = muted; + + switch (layer) { + case 'drums': + this.drumMachine?.mute(muted); + break; + case 'chords': + this.chordEngine?.mute(muted); + break; + case 'ambient': + this.ambientLayer?.mute(muted); + break; + } + + this.notifyStateChange(); + } + + getState(): EngineState { + return { ...this.state }; + } + + dispose(): void { + this.stop(); + this.drumMachine?.dispose(); + this.chordEngine?.dispose(); + this.ambientLayer?.dispose(); + this.masterGain?.dispose(); + this.masterCompressor?.dispose(); + this.masterLimiter?.dispose(); + this.masterReverb?.dispose(); + + this.drumMachine = null; + this.chordEngine = null; + this.ambientLayer = null; + this.masterGain = null; + this.masterCompressor = null; + this.masterLimiter = null; + this.masterReverb = null; + + this.state.isInitialized = false; + this.state.isPlaying = false; + AudioEngine.instance = null; + } +} + +export const audioEngine = AudioEngine.getInstance(); +export default audioEngine; diff --git a/lib/audio/chordEngine.ts b/lib/audio/chordEngine.ts new file mode 100644 index 0000000..45d7786 --- /dev/null +++ b/lib/audio/chordEngine.ts @@ -0,0 +1,139 @@ +import * as Tone from 'tone'; +import { ChordProgression } from '@/types/audio'; +import { getRandomProgression } from './patterns'; + +export class ChordEngine { + private synth: Tone.PolySynth; + private sequence: Tone.Sequence | null = null; + private progression: ChordProgression; + private output: Tone.Gain; + private filter: Tone.Filter; + private reverb: Tone.Reverb; + private chorus: Tone.Chorus; + + constructor(destination: Tone.InputNode) { + this.output = new Tone.Gain(0.6); + + // Warm lofi filter + this.filter = new Tone.Filter({ + frequency: 2000, + type: 'lowpass', + rolloff: -24, + }); + + // Dreamy reverb + this.reverb = new Tone.Reverb({ + decay: 3, + wet: 0.4, + }); + + // Subtle chorus for width + this.chorus = new Tone.Chorus({ + frequency: 0.5, + delayTime: 3.5, + depth: 0.5, + wet: 0.3, + }).start(); + + // FM Synth for warm, evolving pad sound + this.synth = new Tone.PolySynth(Tone.FMSynth, { + harmonicity: 2, + modulationIndex: 1.5, + oscillator: { + type: 'sine', + }, + envelope: { + attack: 0.3, + decay: 0.3, + sustain: 0.8, + release: 1.5, + }, + modulation: { + type: 'sine', + }, + modulationEnvelope: { + attack: 0.5, + decay: 0.2, + sustain: 0.5, + release: 0.5, + }, + }); + + // Lower the overall synth volume to prevent clipping + this.synth.volume.value = -12; + + // Chain: synth -> filter -> chorus -> reverb -> output -> destination + this.synth.connect(this.filter); + this.filter.connect(this.chorus); + this.chorus.connect(this.reverb); + this.reverb.connect(this.output); + this.output.connect(destination); + + // Initialize with a random progression + this.progression = getRandomProgression(); + } + + createSequence(): void { + if (this.sequence) { + this.sequence.dispose(); + } + + const steps = Array.from({ length: this.progression.chords.length }, (_, i) => i); + + this.sequence = new Tone.Sequence( + (time, step) => { + const chord = this.progression.chords[step]; + const duration = this.progression.durations[step]; + + // Release previous notes and play new chord + this.synth.releaseAll(time); + this.synth.triggerAttackRelease(chord, duration, time, 0.5); + }, + steps, + '2n' + ); + + this.sequence.start(0); + } + + setProgression(progression: ChordProgression): void { + this.progression = progression; + // Recreate sequence with new progression + if (this.sequence) { + this.createSequence(); + } + } + + randomize(): ChordProgression { + this.progression = getRandomProgression(); + if (this.sequence) { + this.createSequence(); + } + return this.progression; + } + + setVolume(volume: number): void { + this.output.gain.rampTo(volume, 0.1); + } + + mute(muted: boolean): void { + this.output.gain.rampTo(muted ? 0 : 0.6, 0.1); + } + + setFilterFrequency(freq: number): void { + this.filter.frequency.rampTo(freq, 0.1); + } + + getProgression(): ChordProgression { + return this.progression; + } + + dispose(): void { + this.sequence?.dispose(); + this.synth.dispose(); + this.filter.dispose(); + this.reverb.dispose(); + this.chorus.dispose(); + this.output.dispose(); + } +} diff --git a/lib/audio/drumMachine.ts b/lib/audio/drumMachine.ts new file mode 100644 index 0000000..4292859 --- /dev/null +++ b/lib/audio/drumMachine.ts @@ -0,0 +1,165 @@ +import * as Tone from 'tone'; +import { DrumPattern } from '@/types/audio'; +import { getRandomPattern, generateRandomPattern } from './patterns'; + +export class DrumMachine { + private kick: Tone.MembraneSynth; + private snare: Tone.NoiseSynth; + private hihat: Tone.NoiseSynth; + private openhat: Tone.NoiseSynth; + private sequence: Tone.Sequence | null = null; + private pattern: DrumPattern; + private output: Tone.Gain; + private lowpass: Tone.Filter; + + constructor(destination: Tone.InputNode) { + this.output = new Tone.Gain(0.8); + this.lowpass = new Tone.Filter({ + frequency: 8000, + type: 'lowpass', + rolloff: -12, + }); + + // Kick drum - deep and punchy + this.kick = new Tone.MembraneSynth({ + pitchDecay: 0.05, + octaves: 6, + oscillator: { type: 'sine' }, + envelope: { + attack: 0.001, + decay: 0.4, + sustain: 0.01, + release: 0.4, + }, + }); + + // Snare - filtered noise + this.snare = new Tone.NoiseSynth({ + noise: { type: 'white' }, + envelope: { + attack: 0.001, + decay: 0.2, + sustain: 0, + release: 0.1, + }, + }); + const snareFilter = new Tone.Filter({ + frequency: 5000, + type: 'bandpass', + Q: 1, + }); + this.snare.connect(snareFilter); + snareFilter.connect(this.lowpass); + + // Closed hi-hat - high filtered noise + this.hihat = new Tone.NoiseSynth({ + noise: { type: 'white' }, + envelope: { + attack: 0.001, + decay: 0.05, + sustain: 0, + release: 0.02, + }, + }); + const hihatFilter = new Tone.Filter({ + frequency: 10000, + type: 'highpass', + }); + this.hihat.connect(hihatFilter); + hihatFilter.connect(this.lowpass); + + // Open hi-hat + this.openhat = new Tone.NoiseSynth({ + noise: { type: 'white' }, + envelope: { + attack: 0.001, + decay: 0.3, + sustain: 0, + release: 0.15, + }, + }); + const openhatFilter = new Tone.Filter({ + frequency: 8000, + type: 'highpass', + }); + this.openhat.connect(openhatFilter); + openhatFilter.connect(this.lowpass); + + // Connect kick directly to lowpass + this.kick.connect(this.lowpass); + + // Chain: lowpass -> output -> destination + this.lowpass.connect(this.output); + this.output.connect(destination); + + // Initialize with a random pattern + this.pattern = getRandomPattern(); + } + + createSequence(onStep?: (step: number) => void): void { + if (this.sequence) { + this.sequence.dispose(); + } + + const steps = Array.from({ length: 16 }, (_, i) => i); + + this.sequence = new Tone.Sequence( + (time, step) => { + if (this.pattern.kick[step]) { + this.kick.triggerAttackRelease('C1', '8n', time, 0.8); + } + if (this.pattern.snare[step]) { + this.snare.triggerAttackRelease('8n', time, 0.5); + } + if (this.pattern.hihat[step]) { + this.hihat.triggerAttackRelease('32n', time, 0.3); + } + if (this.pattern.openhat[step]) { + this.openhat.triggerAttackRelease('16n', time, 0.25); + } + + // Call step callback on main thread + if (onStep) { + Tone.getDraw().schedule(() => { + onStep(step); + }, time); + } + }, + steps, + '16n' + ); + + this.sequence.start(0); + } + + setPattern(pattern: DrumPattern): void { + this.pattern = pattern; + } + + randomize(): DrumPattern { + this.pattern = Math.random() > 0.5 ? getRandomPattern() : generateRandomPattern(); + return this.pattern; + } + + setVolume(volume: number): void { + this.output.gain.rampTo(volume, 0.1); + } + + mute(muted: boolean): void { + this.output.gain.rampTo(muted ? 0 : 0.8, 0.1); + } + + getPattern(): DrumPattern { + return this.pattern; + } + + dispose(): void { + this.sequence?.dispose(); + this.kick.dispose(); + this.snare.dispose(); + this.hihat.dispose(); + this.openhat.dispose(); + this.lowpass.dispose(); + this.output.dispose(); + } +} diff --git a/lib/audio/patterns.ts b/lib/audio/patterns.ts new file mode 100644 index 0000000..7e6a25e --- /dev/null +++ b/lib/audio/patterns.ts @@ -0,0 +1,145 @@ +import { DrumPattern, ChordProgression } from '@/types/audio'; + +// Classic boom bap patterns +export const drumPatterns: DrumPattern[] = [ + { + // Classic boom bap + kick: [true, false, false, false, false, false, false, false, true, false, true, false, false, false, false, false], + snare: [false, false, false, false, true, false, false, false, false, false, false, false, true, false, false, false], + hihat: [true, false, true, false, true, false, true, false, true, false, true, false, true, false, true, false], + openhat: [false, false, false, false, false, false, false, true, false, false, false, false, false, false, false, true], + }, + { + // Laid back groove + kick: [true, false, false, false, false, false, true, false, true, false, false, false, false, false, false, false], + snare: [false, false, false, false, true, false, false, false, false, false, false, false, true, false, false, true], + hihat: [true, true, true, false, true, true, true, false, true, true, true, false, true, true, true, false], + openhat: [false, false, false, true, false, false, false, true, false, false, false, true, false, false, false, false], + }, + { + // Minimal chill + kick: [true, false, false, false, false, false, false, false, true, false, false, false, false, false, true, false], + snare: [false, false, false, false, true, false, false, false, false, false, false, false, true, false, false, false], + hihat: [true, false, true, true, true, false, true, true, true, false, true, true, true, false, true, true], + openhat: [false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, true], + }, + { + // Jazzy swing + kick: [true, false, false, true, false, false, true, false, false, false, true, false, false, false, false, false], + snare: [false, false, false, false, true, false, false, false, false, true, false, false, true, false, false, true], + hihat: [true, false, true, false, true, false, true, false, true, false, true, false, true, false, true, false], + openhat: [false, false, false, false, false, true, false, false, false, false, false, false, false, true, false, false], + }, + { + // Deep pocket + kick: [true, false, false, false, false, false, false, true, false, false, true, false, false, false, false, false], + snare: [false, false, false, false, true, false, false, false, false, false, false, false, true, false, false, false], + hihat: [true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true], + openhat: [false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, true], + }, +]; + +// Jazz chord progressions in lofi style +export const chordProgressions: ChordProgression[] = [ + { + name: 'Classic ii-V-I', + chords: [ + ['D3', 'F3', 'A3', 'C4'], // Dm7 + ['G2', 'B2', 'D3', 'F3'], // G7 + ['C3', 'E3', 'G3', 'B3'], // Cmaj7 + ['C3', 'E3', 'G3', 'B3'], // Cmaj7 + ], + durations: ['2n', '2n', '2n', '2n'], + }, + { + name: 'Minor Key Chill', + chords: [ + ['A2', 'C3', 'E3', 'G3'], // Am7 + ['D3', 'F3', 'A3', 'C4'], // Dm7 + ['E2', 'G#2', 'B2', 'D3'], // E7 + ['A2', 'C3', 'E3', 'G3'], // Am7 + ], + durations: ['2n', '2n', '2n', '2n'], + }, + { + name: 'Neo Soul', + chords: [ + ['F3', 'A3', 'C4', 'E4'], // Fmaj7 + ['E3', 'G3', 'B3', 'D4'], // Em7 + ['D3', 'F3', 'A3', 'C4'], // Dm7 + ['G2', 'B2', 'D3', 'F3'], // G7 + ], + durations: ['2n', '2n', '2n', '2n'], + }, + { + name: 'Dreamy', + chords: [ + ['C3', 'E3', 'G3', 'B3'], // Cmaj7 + ['A2', 'C3', 'E3', 'G3'], // Am7 + ['F3', 'A3', 'C4', 'E4'], // Fmaj7 + ['G2', 'B2', 'D3', 'F3'], // G7 + ], + durations: ['2n', '2n', '2n', '2n'], + }, + { + name: 'Melancholy', + chords: [ + ['D3', 'F3', 'A3', 'C4'], // Dm7 + ['G2', 'Bb2', 'D3', 'F3'], // Gm7 + ['C3', 'Eb3', 'G3', 'Bb3'],// Cm7 + ['F2', 'A2', 'C3', 'Eb3'], // F7 + ], + durations: ['2n', '2n', '2n', '2n'], + }, +]; + +export function getRandomPattern(): DrumPattern { + return drumPatterns[Math.floor(Math.random() * drumPatterns.length)]; +} + +export function getRandomProgression(): ChordProgression { + return chordProgressions[Math.floor(Math.random() * chordProgressions.length)]; +} + +export function generateRandomPattern(): DrumPattern { + const pattern: DrumPattern = { + kick: new Array(16).fill(false), + snare: new Array(16).fill(false), + hihat: new Array(16).fill(false), + openhat: new Array(16).fill(false), + }; + + // Kick on 1 and somewhere in second half + pattern.kick[0] = true; + pattern.kick[8 + Math.floor(Math.random() * 4)] = true; + if (Math.random() > 0.5) { + pattern.kick[6 + Math.floor(Math.random() * 2)] = true; + } + + // Snare on 2 and 4 + pattern.snare[4] = true; + pattern.snare[12] = true; + // Ghost notes + if (Math.random() > 0.6) { + pattern.snare[Math.floor(Math.random() * 16)] = true; + } + + // Hi-hats with variation + for (let i = 0; i < 16; i++) { + if (i % 2 === 0) { + pattern.hihat[i] = Math.random() > 0.1; + } else { + pattern.hihat[i] = Math.random() > 0.5; + } + } + + // Open hi-hat occasionally + if (Math.random() > 0.3) { + pattern.openhat[7] = true; + } + if (Math.random() > 0.5) { + pattern.openhat[15] = true; + } + + return pattern; +} diff --git a/lib/utils.ts b/lib/utils.ts new file mode 100644 index 0000000..bd0c391 --- /dev/null +++ b/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/package.json b/package.json index 0462cdb..c4ee16a 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,18 @@ "lint": "eslint" }, "dependencies": { + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-slider": "^1.3.6", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-toggle": "^1.1.10", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.562.0", "next": "16.1.4", "react": "19.2.3", - "react-dom": "19.2.3" + "react-dom": "19.2.3", + "tailwind-merge": "^3.4.0", + "tone": "^15.1.22" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -21,6 +30,7 @@ "eslint": "^9", "eslint-config-next": "16.1.4", "tailwindcss": "^4", + "tw-animate-css": "^1.4.0", "typescript": "^5" }, "ignoreScripts": [ diff --git a/types/audio.ts b/types/audio.ts new file mode 100644 index 0000000..78c1af3 --- /dev/null +++ b/types/audio.ts @@ -0,0 +1,38 @@ +export interface DrumPattern { + kick: boolean[]; + snare: boolean[]; + hihat: boolean[]; + openhat: boolean[]; +} + +export interface ChordProgression { + name: string; + chords: string[][]; + durations: string[]; +} + +export interface EngineState { + isPlaying: boolean; + isInitialized: boolean; + bpm: number; + swing: number; + currentStep: number; + volumes: { + master: number; + drums: number; + chords: number; + ambient: number; + }; + muted: { + drums: boolean; + chords: boolean; + ambient: boolean; + }; +} + +export type LayerName = 'drums' | 'chords' | 'ambient'; + +export interface AudioEngineCallbacks { + onStepChange?: (step: number) => void; + onStateChange?: (state: EngineState) => void; +}