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:
parent
ff9d9593a5
commit
5ed84192d5
121
app/globals.css
121
app/globals.css
@ -1,26 +1,125 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
:root {
|
@custom-variant dark (&:is(.dark *));
|
||||||
--background: #ffffff;
|
|
||||||
--foreground: #171717;
|
|
||||||
}
|
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--font-sans: var(--font-geist-sans);
|
--font-sans: var(--font-geist-sans);
|
||||||
--font-mono: var(--font-geist-mono);
|
--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 {
|
:root {
|
||||||
--background: #0a0a0a;
|
--radius: 0.75rem;
|
||||||
--foreground: #ededed;
|
/* 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 {
|
/* Custom scrollbar for lofi aesthetic */
|
||||||
background: var(--background);
|
::-webkit-scrollbar {
|
||||||
color: var(--foreground);
|
width: 8px;
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "Lofi Generator",
|
||||||
description: "Generated by create next app",
|
description: "Web-based lofi hip hop beat generator - beats to relax/study to",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|||||||
64
app/page.tsx
64
app/page.tsx
@ -1,65 +1,5 @@
|
|||||||
import Image from "next/image";
|
import { LofiGenerator } from '@/components/lofi-generator/LofiGenerator';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return <LofiGenerator />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
72
bun.lock
72
bun.lock
@ -5,9 +5,18 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "lofi-generator",
|
"name": "lofi-generator",
|
||||||
"dependencies": {
|
"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",
|
"next": "16.1.4",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"tone": "^15.1.22",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
@ -17,6 +26,7 @@
|
|||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.4",
|
"eslint-config-next": "16.1.4",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5",
|
"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/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/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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
||||||
|
|
||||||
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"@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/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=="],
|
"@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=="],
|
"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=="],
|
"@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
22
components.json
Normal 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": {}
|
||||||
|
}
|
||||||
71
components/lofi-generator/LayerMixer.tsx
Normal file
71
components/lofi-generator/LayerMixer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
components/lofi-generator/LofiGenerator.tsx
Normal file
110
components/lofi-generator/LofiGenerator.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
components/lofi-generator/TransportControls.tsx
Normal file
45
components/lofi-generator/TransportControls.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
components/lofi-generator/Visualizer.tsx
Normal file
35
components/lofi-generator/Visualizer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
components/lofi-generator/VolumeControl.tsx
Normal file
37
components/lofi-generator/VolumeControl.tsx
Normal 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
62
components/ui/button.tsx
Normal 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
92
components/ui/card.tsx
Normal 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
24
components/ui/label.tsx
Normal 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
63
components/ui/slider.tsx
Normal 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
47
components/ui/toggle.tsx
Normal 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
160
hooks/useAudioEngine.ts
Normal 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
97
lib/audio/ambientLayer.ts
Normal 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
238
lib/audio/audioEngine.ts
Normal 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
139
lib/audio/chordEngine.ts
Normal 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
165
lib/audio/drumMachine.ts
Normal 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
145
lib/audio/patterns.ts
Normal 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
6
lib/utils.ts
Normal 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))
|
||||||
|
}
|
||||||
12
package.json
12
package.json
@ -9,9 +9,18 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"next": "16.1.4",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3"
|
"react-dom": "19.2.3",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"tone": "^15.1.22"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
@ -21,6 +30,7 @@
|
|||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.4",
|
"eslint-config-next": "16.1.4",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
},
|
},
|
||||||
"ignoreScripts": [
|
"ignoreScripts": [
|
||||||
|
|||||||
38
types/audio.ts
Normal file
38
types/audio.ts
Normal 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;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user