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 (
-
-
-
-
-
- To get started, edit the page.tsx file.
-
-
- Looking for a starting point or more instructions? Head over to{" "}
-
- Templates
- {" "}
- or the{" "}
-
- Learning
- {" "}
- center.
-
-
-
-
-
- );
+ 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;
+}