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 <noreply@anthropic.com>
This commit is contained in:
Avery Felts 2026-01-20 17:29:28 -07:00
parent ff9d9593a5
commit 5ed84192d5
24 changed files with 1794 additions and 77 deletions

View File

@ -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) {
/* Lofi dark theme - always dark mode */
:root {
--background: #0a0a0a;
--foreground: #ededed;
--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);
}

View File

@ -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({

View File

@ -1,65 +1,5 @@
import Image from "next/image";
import { LofiGenerator } from '@/components/lofi-generator/LofiGenerator';
export default function Home() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
return <LofiGenerator />;
}

View File

@ -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=="],
}
}

22
components.json Normal file
View File

@ -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": {}
}

View File

@ -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: <Drum className="h-4 w-4" /> },
{ name: 'chords', label: 'Chords', icon: <Music className="h-4 w-4" /> },
{ name: 'ambient', label: 'Ambient', icon: <Cloud className="h-4 w-4" /> },
];
export function LayerMixer({
volumes,
muted,
onVolumeChange,
onToggleMute,
}: LayerMixerProps) {
return (
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
Layers
</h3>
<div className="space-y-4">
{layers.map(({ name, label, icon }) => (
<div key={name} className="flex items-center gap-3">
<Toggle
pressed={!muted[name]}
onPressedChange={() => onToggleMute(name)}
size="sm"
className="shrink-0"
aria-label={`Toggle ${label}`}
>
{muted[name] ? (
<VolumeX className="h-4 w-4" />
) : (
<Volume2 className="h-4 w-4" />
)}
</Toggle>
<div className="flex items-center gap-2 shrink-0 w-20">
{icon}
<span className="text-sm">{label}</span>
</div>
<VolumeControl
label=""
value={volumes[name]}
onChange={(v) => onVolumeChange(name, v)}
className="flex-1"
/>
</div>
))}
</div>
</div>
);
}

View File

@ -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 (
<div className="min-h-screen flex items-center justify-center p-4">
<Card className="w-full max-w-md bg-card/80 backdrop-blur-sm border-border/50">
<CardHeader className="text-center pb-2">
<CardTitle className="text-2xl font-light tracking-wide">
lofi generator
</CardTitle>
<p className="text-sm text-muted-foreground">
beats to relax/study to
</p>
</CardHeader>
<CardContent className="space-y-6">
{/* Visualizer */}
<Visualizer currentStep={currentStep} isPlaying={state.isPlaying} />
{/* Transport Controls */}
<div className="flex justify-center">
<TransportControls
isPlaying={state.isPlaying}
isInitialized={state.isInitialized}
onTogglePlayback={togglePlayback}
onGenerateNewBeat={generateNewBeat}
/>
</div>
{/* Master Volume */}
<div className="pt-2">
<VolumeControl
label="Master Volume"
value={state.volumes.master}
onChange={setMasterVolume}
/>
</div>
{/* BPM and Swing Controls */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm text-muted-foreground">BPM</Label>
<span className="text-xs text-muted-foreground font-mono">
{state.bpm}
</span>
</div>
<Slider
value={[state.bpm]}
onValueChange={([v]) => setBpm(v)}
min={60}
max={100}
step={1}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm text-muted-foreground">Swing</Label>
<span className="text-xs text-muted-foreground font-mono">
{Math.round(state.swing * 100)}%
</span>
</div>
<Slider
value={[state.swing]}
onValueChange={([v]) => setSwing(v)}
min={0}
max={0.5}
step={0.01}
/>
</div>
</div>
{/* Layer Mixer */}
<LayerMixer
volumes={state.volumes}
muted={state.muted}
onVolumeChange={setLayerVolume}
onToggleMute={toggleMute}
/>
{/* Footer */}
<p className="text-center text-xs text-muted-foreground/60 pt-4">
Click play to start the audio engine
</p>
</CardContent>
</Card>
</div>
);
}

View File

@ -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 (
<div className="flex items-center gap-4">
<Button
size="lg"
onClick={onTogglePlayback}
className="h-14 w-14 rounded-full"
>
{isPlaying ? (
<Pause className="h-6 w-6" />
) : (
<Play className="h-6 w-6 ml-1" />
)}
</Button>
<Button
variant="secondary"
size="lg"
onClick={onGenerateNewBeat}
disabled={!isInitialized && !isPlaying}
className="gap-2"
>
<Shuffle className="h-4 w-4" />
New Beat
</Button>
</div>
);
}

View File

@ -0,0 +1,35 @@
'use client';
interface VisualizerProps {
currentStep: number;
isPlaying: boolean;
}
export function Visualizer({ currentStep, isPlaying }: VisualizerProps) {
return (
<div className="flex items-center justify-center gap-1.5 py-4">
{Array.from({ length: 16 }, (_, i) => {
const isActive = isPlaying && currentStep === i;
const isBeat = i % 4 === 0;
return (
<div
key={i}
className={`
transition-all duration-75
${isBeat ? 'w-3 h-8' : 'w-2 h-6'}
rounded-full
${
isActive
? 'bg-primary scale-110 shadow-lg shadow-primary/50'
: isBeat
? 'bg-muted-foreground/40'
: 'bg-muted-foreground/20'
}
`}
/>
);
})}
</div>
);
}

View File

@ -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 (
<div className={`space-y-2 ${className}`}>
<div className="flex items-center justify-between">
<Label className="text-sm text-muted-foreground">{label}</Label>
<span className="text-xs text-muted-foreground font-mono">
{Math.round(value * 100)}%
</span>
</div>
<Slider
value={[value]}
onValueChange={([v]) => onChange(v)}
min={0}
max={1}
step={0.01}
className="w-full"
/>
</div>
);
}

62
components/ui/button.tsx Normal file
View File

@ -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<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

92
components/ui/card.tsx Normal file
View File

@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

24
components/ui/label.tsx Normal file
View File

@ -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<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

63
components/ui/slider.tsx Normal file
View File

@ -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<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max]
)
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
)
}
export { Slider }

47
components/ui/toggle.tsx Normal file
View File

@ -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<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }

160
hooks/useAudioEngine.ts Normal file
View File

@ -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<EngineState>(defaultState);
const [currentStep, setCurrentStep] = useState(0);
const engineRef = useRef<typeof import('@/lib/audio/audioEngine').default | null>(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,
};
}

97
lib/audio/ambientLayer.ts Normal file
View File

@ -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();
}
}

238
lib/audio/audioEngine.ts Normal file
View File

@ -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<void> {
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<void> {
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;

139
lib/audio/chordEngine.ts Normal file
View File

@ -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();
}
}

165
lib/audio/drumMachine.ts Normal file
View File

@ -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();
}
}

145
lib/audio/patterns.ts Normal file
View File

@ -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;
}

6
lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@ -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": [

38
types/audio.ts Normal file
View File

@ -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;
}