Compare commits

..

1 Commits

Author SHA1 Message Date
d3158e4c6a feat(timeline-mixer): WIP timeline and mixer components
work in progress implementation of:
- mixer with channel strips, faders, pan knobs, level meters
- timeline with ruler, playhead, sections, keyframe tracks
- pattern and progression pickers for drums/chords
- automation lanes and mute tracks
- loop bracket for loop region selection
- export modal placeholder

known issues:
- drum pattern changes don't update audio engine
- timeline keyframes not connected to scheduler
- some UI bugs remain

this is a checkpoint commit for further iteration
2026-01-20 18:22:10 -07:00
46 changed files with 3822 additions and 1817 deletions

View File

@ -123,3 +123,19 @@
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: oklch(0.45 0.05 280); background: oklch(0.45 0.05 280);
} }
/* Override vertical slider min-height for mixer faders */
[data-orientation="vertical"][data-slot="slider"] {
min-height: unset !important;
}
/* Make vertical slider tracks visible */
[data-orientation="vertical"][data-slot="slider-track"] {
background: oklch(0.35 0.03 280) !important;
width: 6px !important;
border-radius: 3px;
}
[data-orientation="vertical"][data-slot="slider-range"] {
background: oklch(0.75 0.15 50) !important;
}

View File

@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Beat Generator", title: "Lofi Generator",
description: "Create custom beats across Hip Hop, Classical, Trap, and Pop genres", description: "Web-based lofi hip hop beat generator - beats to relax/study to",
}; };
export default function RootLayout({ export default function RootLayout({

View File

@ -6,7 +6,6 @@
"name": "lofi-generator", "name": "lofi-generator",
"dependencies": { "dependencies": {
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle": "^1.1.10",
@ -97,14 +96,6 @@
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
"@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="],
"@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="],
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.6", "", { "dependencies": { "@floating-ui/dom": "^1.7.4" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw=="],
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
@ -207,8 +198,6 @@
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.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-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
"@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-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-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=="],
@ -217,50 +206,26 @@
"@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-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-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "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-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "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-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
"@radix-ui/react-id": ["@radix-ui/react-id@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-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
"@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-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-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@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-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "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-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "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-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
"@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-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-select": ["@radix-ui/react-select@2.2.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-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@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-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.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-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="],
"@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-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-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-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-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
"@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-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-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-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
"@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-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-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-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
"@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=="], "@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=="],
"@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.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-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="],
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
"@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=="],
@ -377,8 +342,6 @@
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
"array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="], "array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="],
@ -467,8 +430,6 @@
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
@ -567,8 +528,6 @@
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="],
@ -807,12 +766,6 @@
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
@ -931,10 +884,6 @@
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
@ -961,30 +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-arrow/@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-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-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-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-dismissable-layer/@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-focus-scope/@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-popper/@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-portal/@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-select/@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-select/@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-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=="], "@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=="],
"@radix-ui/react-visually-hidden/@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=="],
@ -1021,22 +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-arrow/@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-dismissable-layer/@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-focus-scope/@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-popper/@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-portal/@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-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-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=="], "@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=="],
"@radix-ui/react-visually-hidden/@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=="],
} }
} }

View File

@ -1,143 +0,0 @@
'use client';
import { Toggle } from '@/components/ui/toggle';
import { Slider } from '@/components/ui/slider';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Volume2, VolumeX } from 'lucide-react';
import { ReactNode } from 'react';
interface InstrumentOption {
value: string;
label: string;
}
interface LayerBoxProps {
title: string;
icon: ReactNode;
volume: number;
muted: boolean;
instrument: string;
instrumentOptions: InstrumentOption[];
onVolumeChange: (volume: number) => void;
onToggleMute: () => void;
onInstrumentChange: (instrument: string) => void;
accentColor: 'orange' | 'pink' | 'purple' | 'blue' | 'green' | 'yellow';
}
const accentStyles = {
orange: {
border: 'border-lofi-orange/30 hover:border-lofi-orange/50',
bg: 'bg-lofi-orange/5',
icon: 'text-lofi-orange',
slider: '[&_[data-slot=slider-range]]:bg-lofi-orange [&_[data-slot=slider-thumb]]:border-lofi-orange',
},
pink: {
border: 'border-lofi-pink/30 hover:border-lofi-pink/50',
bg: 'bg-lofi-pink/5',
icon: 'text-lofi-pink',
slider: '[&_[data-slot=slider-range]]:bg-lofi-pink [&_[data-slot=slider-thumb]]:border-lofi-pink',
},
purple: {
border: 'border-lofi-purple/30 hover:border-lofi-purple/50',
bg: 'bg-lofi-purple/5',
icon: 'text-lofi-purple',
slider: '[&_[data-slot=slider-range]]:bg-lofi-purple [&_[data-slot=slider-thumb]]:border-lofi-purple',
},
blue: {
border: 'border-blue-400/30 hover:border-blue-400/50',
bg: 'bg-blue-400/5',
icon: 'text-blue-400',
slider: '[&_[data-slot=slider-range]]:bg-blue-400 [&_[data-slot=slider-thumb]]:border-blue-400',
},
green: {
border: 'border-emerald-400/30 hover:border-emerald-400/50',
bg: 'bg-emerald-400/5',
icon: 'text-emerald-400',
slider: '[&_[data-slot=slider-range]]:bg-emerald-400 [&_[data-slot=slider-thumb]]:border-emerald-400',
},
yellow: {
border: 'border-yellow-400/30 hover:border-yellow-400/50',
bg: 'bg-yellow-400/5',
icon: 'text-yellow-400',
slider: '[&_[data-slot=slider-range]]:bg-yellow-400 [&_[data-slot=slider-thumb]]:border-yellow-400',
},
};
export function LayerBox({
title,
icon,
volume,
muted,
instrument,
instrumentOptions,
onVolumeChange,
onToggleMute,
onInstrumentChange,
accentColor,
}: LayerBoxProps) {
const styles = accentStyles[accentColor];
return (
<div
className={`
rounded-xl border-2 p-4 transition-all duration-200
${styles.border} ${styles.bg}
`}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className={styles.icon}>{icon}</span>
<span className="font-medium text-sm">{title}</span>
</div>
<div className="flex items-center gap-2">
<Select value={instrument} onValueChange={onInstrumentChange}>
<SelectTrigger className="h-7 w-28 text-xs bg-secondary/50 border-border/50">
<SelectValue />
</SelectTrigger>
<SelectContent>
{instrumentOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Toggle
pressed={!muted}
onPressedChange={onToggleMute}
size="sm"
className="h-7 w-7 p-0"
aria-label={`Toggle ${title}`}
>
{muted ? (
<VolumeX className="h-3.5 w-3.5 text-muted-foreground" />
) : (
<Volume2 className={`h-3.5 w-3.5 ${styles.icon}`} />
)}
</Toggle>
</div>
</div>
<div className="flex items-center gap-3">
<Slider
value={[volume]}
onValueChange={([v]) => onVolumeChange(v)}
min={0}
max={1}
step={0.01}
className={`flex-1 ${styles.slider}`}
disabled={muted}
/>
<span className="text-xs text-muted-foreground font-mono w-10 text-right">
{Math.round(volume * 100)}%
</span>
</div>
</div>
);
}

View File

@ -0,0 +1,71 @@
'use client';
import { Toggle } from '@/components/ui/toggle';
import { VolumeControl } from './VolumeControl';
import { Volume2, VolumeX, Drum, Music, Cloud } from 'lucide-react';
import { LayerName } from '@/types/audio';
interface LayerMixerProps {
volumes: {
drums: number;
chords: number;
ambient: number;
};
muted: {
drums: boolean;
chords: boolean;
ambient: boolean;
};
onVolumeChange: (layer: LayerName, volume: number) => void;
onToggleMute: (layer: LayerName) => void;
}
const layers: { name: LayerName; label: string; icon: React.ReactNode }[] = [
{ name: 'drums', label: 'Drums', icon: <Drum className="h-4 w-4" /> },
{ name: 'chords', label: 'Chords', icon: <Music className="h-4 w-4" /> },
{ name: 'ambient', label: 'Ambient', icon: <Cloud className="h-4 w-4" /> },
];
export function LayerMixer({
volumes,
muted,
onVolumeChange,
onToggleMute,
}: LayerMixerProps) {
return (
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
Layers
</h3>
<div className="space-y-4">
{layers.map(({ name, label, icon }) => (
<div key={name} className="flex items-center gap-3">
<Toggle
pressed={!muted[name]}
onPressedChange={() => onToggleMute(name)}
size="sm"
className="shrink-0"
aria-label={`Toggle ${label}`}
>
{muted[name] ? (
<VolumeX className="h-4 w-4" />
) : (
<Volume2 className="h-4 w-4" />
)}
</Toggle>
<div className="flex items-center gap-2 shrink-0 w-20">
{icon}
<span className="text-sm">{label}</span>
</div>
<VolumeControl
label=""
value={volumes[name]}
onChange={(v) => onVolumeChange(name, v)}
className="flex-1"
/>
</div>
))}
</div>
</div>
);
}

View File

@ -1,159 +1,125 @@
'use client'; 'use client';
import { Card, CardContent } from '@/components/ui/card'; import { useState, useCallback, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import { TransportControls } from './TransportControls';
Select, import { Visualizer } from './Visualizer';
SelectContent, import { Mixer } from '@/components/mixer';
SelectItem, import { Timeline, ExportModal } from '@/components/timeline';
SelectTrigger, import { useAudioEngine } from '@/hooks/useAudioEngine';
SelectValue, import { useTimeline } from '@/hooks/useTimeline';
} from '@/components/ui/select';
import { Slider } from '@/components/ui/slider'; import { Slider } from '@/components/ui/slider';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { LayerBox } from './LayerBox'; import { Download, ChevronDown, ChevronUp } from 'lucide-react';
import { Visualizer } from './Visualizer'; import { LayerName } from '@/types/audio';
import { useAudioEngine } from '@/hooks/useAudioEngine'; import { cn } from '@/lib/utils';
import {
Play,
Pause,
Shuffle,
Drum,
Music,
Cloud,
Guitar,
Waves,
Piano,
} from 'lucide-react';
import { Genre, GENRE_CONFIG, INSTRUMENT_OPTIONS } from '@/types/audio';
export function LofiGenerator() { export function LofiGenerator() {
const { const {
state, state,
currentStep, currentStep,
meterLevels,
pans,
soloed,
playheadPosition,
togglePlayback, togglePlayback,
generateNewBeat, generateNewBeat,
setMasterVolume, setMasterVolume,
setLayerVolume, setLayerVolume,
toggleMute, toggleMute,
setGenre, setPan,
setDuration, toggleSolo,
setInstrument,
setBpm, setBpm,
setSwing, setSwing,
seek,
setLoopRegion,
} = useAudioEngine(); } = useAudioEngine();
const genres: { value: Genre; label: string }[] = [ const timeline = useTimeline();
{ value: 'hiphop', label: 'Hip Hop' },
{ value: 'classical', label: 'Classical' }, const [showTimeline, setShowTimeline] = useState(false);
{ value: 'trap', label: 'Trap' }, const [showExportModal, setShowExportModal] = useState(false);
{ value: 'pop', label: 'Pop' },
]; useEffect(() => {
timeline.setPlayheadPosition(playheadPosition.bar, playheadPosition.beat);
}, [playheadPosition, timeline.setPlayheadPosition]);
const handleVolumeChange = useCallback(
(layer: LayerName | 'master', volume: number) => {
if (layer === 'master') {
setMasterVolume(volume);
} else {
setLayerVolume(layer, volume);
}
},
[setMasterVolume, setLayerVolume]
);
const handleExport = useCallback(async (): Promise<Blob> => {
return new Blob(['placeholder'], { type: 'audio/wav' });
}, []);
const handleLoopRegionChange = useCallback(
(start: number, end: number, enabled: boolean) => {
timeline.setLoopRegion(start, end, enabled);
setLoopRegion(start, end, enabled);
},
[timeline.setLoopRegion, setLoopRegion]
);
const volumes = {
master: state.volumes.master,
drums: state.volumes.drums,
chords: state.volumes.chords,
ambient: state.volumes.ambient,
};
return ( return (
<div className="min-h-screen flex flex-col items-center p-4 pt-8"> <div className="min-h-screen flex items-center justify-center p-4">
{/* Modern Header */} <Card className="w-full max-w-4xl bg-card/80 backdrop-blur-sm border-border/50">
<header className="w-full max-w-2xl mb-8"> <CardHeader className="text-center pb-2">
<div className="flex items-center justify-center gap-3 mb-2"> <CardTitle className="text-2xl font-light tracking-wide">
<div className="h-10 w-10 rounded-xl bg-gradient-to-br from-lofi-orange via-lofi-pink to-lofi-purple flex items-center justify-center"> lofi generator
<Waves className="h-5 w-5 text-white" /> </CardTitle>
</div> <p className="text-sm text-muted-foreground">beats to relax/study to</p>
<h1 className="text-3xl font-bold tracking-tight bg-gradient-to-r from-lofi-orange via-lofi-pink to-lofi-purple bg-clip-text text-transparent"> </CardHeader>
Beat Generator
</h1>
</div>
<p className="text-center text-sm text-muted-foreground">
Create custom beats across multiple genres
</p>
</header>
<Card className="w-full max-w-2xl bg-card/80 backdrop-blur-sm border-border/50"> <CardContent className="space-y-4">
<CardContent className="p-6 space-y-6">
{/* Top Controls: Duration & Genre */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-xs text-muted-foreground uppercase tracking-wider">
Duration
</Label>
<div className="flex items-center gap-2">
<Slider
value={[state.duration]}
onValueChange={([v]) => setDuration(v)}
min={1}
max={10}
step={1}
className="flex-1"
/>
<span className="text-sm font-mono w-12 text-right">
{state.duration} min
</span>
</div>
</div>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground uppercase tracking-wider">
Genre
</Label>
<Select value={state.genre} onValueChange={(v) => setGenre(v as Genre)}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{genres.map((g) => (
<SelectItem key={g.value} value={g.value}>
{g.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Transport Controls */}
<div className="flex items-center justify-center gap-4">
<Button
size="lg"
onClick={togglePlayback}
className="h-14 w-14 rounded-full bg-gradient-to-br from-lofi-orange to-lofi-pink hover:opacity-90"
>
{state.isPlaying ? (
<Pause className="h-6 w-6" />
) : (
<Play className="h-6 w-6 ml-1" />
)}
</Button>
<Button
variant="secondary"
size="lg"
onClick={generateNewBeat}
className="gap-2"
>
<Shuffle className="h-4 w-4" />
Generate
</Button>
</div>
{/* Visualizer */}
<Visualizer currentStep={currentStep} isPlaying={state.isPlaying} /> <Visualizer currentStep={currentStep} isPlaying={state.isPlaying} />
{/* Master Controls */} <div className="flex justify-center items-center gap-2">
<div className="grid grid-cols-3 gap-4"> <TransportControls
<div className="space-y-2"> isPlaying={state.isPlaying}
<div className="flex items-center justify-between"> isInitialized={state.isInitialized}
<Label className="text-xs text-muted-foreground">Master</Label> onTogglePlayback={togglePlayback}
<span className="text-xs text-muted-foreground font-mono"> onGenerateNewBeat={generateNewBeat}
{Math.round(state.volumes.master * 100)}% />
</span> <Button
</div> variant={showTimeline ? 'default' : 'outline'}
<Slider size="sm"
value={[state.volumes.master]} onClick={() => setShowTimeline(!showTimeline)}
onValueChange={([v]) => setMasterVolume(v)} className="gap-1 h-9"
min={0} >
max={1} Timeline
step={0.01} {showTimeline ? (
/> <ChevronUp className="h-4 w-4" />
</div> ) : (
<div className="space-y-2"> <ChevronDown className="h-4 w-4" />
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowExportModal(true)}
className="h-9 w-9 p-0"
>
<Download className="h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">BPM</Label> <Label className="text-xs text-muted-foreground">BPM</Label>
<span className="text-xs text-muted-foreground font-mono"> <span className="text-xs text-muted-foreground font-mono">
@ -164,11 +130,11 @@ export function LofiGenerator() {
value={[state.bpm]} value={[state.bpm]}
onValueChange={([v]) => setBpm(v)} onValueChange={([v]) => setBpm(v)}
min={60} min={60}
max={180} max={100}
step={1} step={1}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-1">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">Swing</Label> <Label className="text-xs text-muted-foreground">Swing</Label>
<span className="text-xs text-muted-foreground font-mono"> <span className="text-xs text-muted-foreground font-mono">
@ -185,99 +151,58 @@ export function LofiGenerator() {
</div> </div>
</div> </div>
{/* Instrument Layers */} <Mixer
<div className="space-y-3"> volumes={volumes}
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider"> pans={pans}
Instruments muted={state.muted}
</h3> soloed={soloed}
levels={meterLevels}
onVolumeChange={handleVolumeChange}
onPanChange={setPan}
onMuteToggle={toggleMute}
onSoloToggle={toggleSolo}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> <div
<LayerBox className={cn(
title="Drums" 'overflow-hidden transition-all duration-300 ease-in-out',
icon={<Drum className="h-4 w-4" />} showTimeline ? 'max-h-[500px] opacity-100' : 'max-h-0 opacity-0'
volume={state.volumes.drums} )}
muted={state.muted.drums} >
instrument={state.instruments.drums} <Timeline
instrumentOptions={INSTRUMENT_OPTIONS.drums} state={timeline.state}
onVolumeChange={(v) => setLayerVolume('drums', v)} bpm={state.bpm}
onToggleMute={() => toggleMute('drums')} isPlaying={state.isPlaying}
onInstrumentChange={(v) => setInstrument('drums', v)} onDurationChange={timeline.setDuration}
accentColor="orange" onSeek={seek}
onLoopRegionChange={handleLoopRegionChange}
onSectionAdd={timeline.addSection}
onSectionResize={timeline.resizeSection}
onSectionMove={timeline.moveSection}
onSectionDelete={timeline.deleteSection}
onDrumKeyframeAdd={timeline.addDrumKeyframe}
onDrumKeyframeUpdate={timeline.updateDrumKeyframe}
onDrumKeyframeDelete={timeline.deleteDrumKeyframe}
onChordKeyframeAdd={timeline.addChordKeyframe}
onChordKeyframeUpdate={timeline.updateChordKeyframe}
onChordKeyframeDelete={timeline.deleteChordKeyframe}
onMuteKeyframeToggle={timeline.toggleMuteKeyframe}
onAutomationPointAdd={timeline.addAutomationPoint}
onAutomationPointUpdate={timeline.updateAutomationPoint}
onAutomationPointDelete={timeline.deleteAutomationPoint}
/> />
<LayerBox
title="Bass"
icon={<Guitar className="h-4 w-4" />}
volume={state.volumes.bass}
muted={state.muted.bass}
instrument={state.instruments.bass}
instrumentOptions={INSTRUMENT_OPTIONS.bass}
onVolumeChange={(v) => setLayerVolume('bass', v)}
onToggleMute={() => toggleMute('bass')}
onInstrumentChange={(v) => setInstrument('bass', v)}
accentColor="blue"
/>
<LayerBox
title="Piano"
icon={<Piano className="h-4 w-4" />}
volume={state.volumes.piano}
muted={state.muted.piano}
instrument={state.instruments.piano}
instrumentOptions={INSTRUMENT_OPTIONS.piano}
onVolumeChange={(v) => setLayerVolume('piano', v)}
onToggleMute={() => toggleMute('piano')}
onInstrumentChange={(v) => setInstrument('piano', v)}
accentColor="green"
/>
<LayerBox
title="Brass"
icon={<Music className="h-4 w-4" />}
volume={state.volumes.brass}
muted={state.muted.brass}
instrument={state.instruments.brass}
instrumentOptions={INSTRUMENT_OPTIONS.brass}
onVolumeChange={(v) => setLayerVolume('brass', v)}
onToggleMute={() => toggleMute('brass')}
onInstrumentChange={(v) => setInstrument('brass', v)}
accentColor="yellow"
/>
<LayerBox
title="Chords"
icon={<Waves className="h-4 w-4" />}
volume={state.volumes.chords}
muted={state.muted.chords}
instrument={state.instruments.chords}
instrumentOptions={INSTRUMENT_OPTIONS.chords}
onVolumeChange={(v) => setLayerVolume('chords', v)}
onToggleMute={() => toggleMute('chords')}
onInstrumentChange={(v) => setInstrument('chords', v)}
accentColor="pink"
/>
<LayerBox
title="Ambient"
icon={<Cloud className="h-4 w-4" />}
volume={state.volumes.ambient}
muted={state.muted.ambient}
instrument={state.instruments.ambient}
instrumentOptions={INSTRUMENT_OPTIONS.ambient}
onVolumeChange={(v) => setLayerVolume('ambient', v)}
onToggleMute={() => toggleMute('ambient')}
onInstrumentChange={(v) => setInstrument('ambient', v)}
accentColor="purple"
/>
</div>
</div> </div>
{/* Footer */}
<p className="text-center text-xs text-muted-foreground/60 pt-2">
Click play to initialize audio engine Genre: {GENRE_CONFIG[state.genre].name}
</p>
</CardContent> </CardContent>
</Card> </Card>
{showExportModal && (
<ExportModal
durationBars={timeline.state.durationBars}
bpm={state.bpm}
onExport={handleExport}
onClose={() => setShowExportModal(false)}
/>
)}
</div> </div>
); );
} }

View File

@ -0,0 +1,45 @@
'use client';
import { Button } from '@/components/ui/button';
import { Play, Pause, Shuffle } from 'lucide-react';
interface TransportControlsProps {
isPlaying: boolean;
isInitialized: boolean;
onTogglePlayback: () => void;
onGenerateNewBeat: () => void;
}
export function TransportControls({
isPlaying,
isInitialized,
onTogglePlayback,
onGenerateNewBeat,
}: TransportControlsProps) {
return (
<div className="flex items-center gap-4">
<Button
size="lg"
onClick={onTogglePlayback}
className="h-14 w-14 rounded-full"
>
{isPlaying ? (
<Pause className="h-6 w-6" />
) : (
<Play className="h-6 w-6 ml-1" />
)}
</Button>
<Button
variant="secondary"
size="lg"
onClick={onGenerateNewBeat}
disabled={!isInitialized && !isPlaying}
className="gap-2"
>
<Shuffle className="h-4 w-4" />
New Beat
</Button>
</div>
);
}

View File

@ -7,31 +7,25 @@ interface VisualizerProps {
export function Visualizer({ currentStep, isPlaying }: VisualizerProps) { export function Visualizer({ currentStep, isPlaying }: VisualizerProps) {
return ( return (
<div className="flex items-end justify-center gap-1 h-16 px-4 py-3 bg-secondary/50 rounded-lg border border-border/50"> <div className="flex items-center justify-center gap-1.5 py-4">
{Array.from({ length: 16 }, (_, i) => { {Array.from({ length: 16 }, (_, i) => {
const isActive = isPlaying && currentStep === i; const isActive = isPlaying && currentStep === i;
const isBeat = i % 4 === 0; const isBeat = i % 4 === 0;
const isOffbeat = i % 2 === 1;
// Vary heights for visual interest
const baseHeight = isBeat ? 'h-12' : isOffbeat ? 'h-6' : 'h-8';
const activeHeight = 'h-14';
return ( return (
<div <div
key={i} key={i}
className={` className={`
transition-all duration-50 ease-out transition-all duration-75
${isBeat ? 'w-4' : 'w-2.5'} ${isBeat ? 'w-3 h-8' : 'w-2 h-6'}
${isActive ? activeHeight : baseHeight} rounded-full
${ ${
isActive isActive
? 'bg-gradient-to-t from-lofi-orange via-lofi-pink to-lofi-purple shadow-[0_0_12px_rgba(255,150,100,0.6)]' ? 'bg-primary scale-110 shadow-lg shadow-primary/50'
: isBeat : isBeat
? 'bg-gradient-to-t from-lofi-orange/60 to-lofi-orange/30' ? 'bg-muted-foreground/40'
: 'bg-muted-foreground/30' : 'bg-muted-foreground/20'
} }
${isBeat ? 'rounded-sm' : 'rounded-[2px]'}
`} `}
/> />
); );

View File

@ -0,0 +1,37 @@
'use client';
import { Slider } from '@/components/ui/slider';
import { Label } from '@/components/ui/label';
interface VolumeControlProps {
label: string;
value: number;
onChange: (value: number) => void;
className?: string;
}
export function VolumeControl({
label,
value,
onChange,
className = '',
}: VolumeControlProps) {
return (
<div className={`space-y-2 ${className}`}>
<div className="flex items-center justify-between">
<Label className="text-sm text-muted-foreground">{label}</Label>
<span className="text-xs text-muted-foreground font-mono">
{Math.round(value * 100)}%
</span>
</div>
<Slider
value={[value]}
onValueChange={([v]) => onChange(v)}
min={0}
max={1}
step={0.01}
className="w-full"
/>
</div>
);
}

View File

@ -0,0 +1,102 @@
'use client';
import { Button } from '@/components/ui/button';
import { VerticalFader } from './VerticalFader';
import { LevelMeter } from './LevelMeter';
import { PanKnob } from './PanKnob';
import { cn } from '@/lib/utils';
interface ChannelStripProps {
name: string;
icon?: React.ReactNode;
volume: number;
pan: number;
muted: boolean;
soloed: boolean;
level: number;
onVolumeChange: (volume: number) => void;
onPanChange: (pan: number) => void;
onMuteToggle: () => void;
onSoloToggle: () => void;
showPan?: boolean;
showSolo?: boolean;
className?: string;
}
export function ChannelStrip({
name,
icon,
volume,
pan,
muted,
soloed,
level,
onVolumeChange,
onPanChange,
onMuteToggle,
onSoloToggle,
showPan = true,
showSolo = true,
className,
}: ChannelStripProps) {
return (
<div
className={cn(
'flex flex-col items-center gap-1.5 p-2 rounded-lg bg-card/50 border border-border/50',
'min-w-[64px]',
className
)}
>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
{icon}
<span className="font-medium">{name}</span>
</div>
{showPan && <PanKnob value={pan} onChange={onPanChange} size={24} />}
<div className="flex gap-1.5 items-end">
<div className="flex flex-col items-center gap-1">
<VerticalFader
value={volume}
onChange={onVolumeChange}
min={0}
max={1}
height={64}
/>
<span className="text-[9px] font-mono text-muted-foreground">
{Math.round(volume * 100)}
</span>
</div>
<LevelMeter level={muted ? 0 : level} className="h-16" />
</div>
<div className="flex gap-1">
{showSolo && (
<Button
variant={soloed ? 'default' : 'outline'}
size="sm"
className={cn(
'h-6 w-6 p-0 text-[10px] font-bold',
soloed && 'bg-yellow-500 hover:bg-yellow-600 text-black'
)}
onClick={onSoloToggle}
>
S
</Button>
)}
<Button
variant={muted ? 'default' : 'outline'}
size="sm"
className={cn(
'h-6 w-6 p-0 text-[10px] font-bold',
muted && 'bg-red-500 hover:bg-red-600'
)}
onClick={onMuteToggle}
>
M
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,52 @@
'use client';
import { useMemo } from 'react';
import { cn } from '@/lib/utils';
interface LevelMeterProps {
level: number;
className?: string;
orientation?: 'vertical' | 'horizontal';
}
export function LevelMeter({
level,
className,
orientation = 'vertical',
}: LevelMeterProps) {
const segments = useMemo(() => {
const count = 8;
const filled = Math.round(level * count);
return Array.from({ length: count }, (_, i) => {
const segmentLevel = (i + 1) / count;
const isActive = i < filled;
let color = 'bg-emerald-500';
if (segmentLevel > 0.85) color = 'bg-red-500';
else if (segmentLevel > 0.7) color = 'bg-yellow-500';
return { isActive, color };
});
}, [level]);
const isVertical = orientation === 'vertical';
return (
<div
className={cn(
'flex gap-0.5',
isVertical ? 'flex-col-reverse w-2' : 'flex-row h-2 w-16',
className
)}
>
{segments.map((seg, i) => (
<div
key={i}
className={cn(
'flex-1 rounded-sm transition-opacity duration-75',
seg.isActive ? seg.color : 'bg-muted/30',
seg.isActive ? 'opacity-100' : 'opacity-50'
)}
/>
))}
</div>
);
}

View File

@ -0,0 +1,78 @@
'use client';
import { Drum, Music, Cloud, Volume2 } from 'lucide-react';
import { ChannelStrip } from './ChannelStrip';
import { LayerName, MeterLevels } from '@/types/audio';
interface MixerProps {
volumes: Record<LayerName | 'master', number>;
pans: Record<LayerName, number>;
muted: Record<LayerName, boolean>;
soloed: Record<LayerName, boolean>;
levels: MeterLevels;
onVolumeChange: (layer: LayerName | 'master', volume: number) => void;
onPanChange: (layer: LayerName, pan: number) => void;
onMuteToggle: (layer: LayerName) => void;
onSoloToggle: (layer: LayerName) => void;
}
const layerConfig: {
name: LayerName;
label: string;
icon: React.ReactNode;
}[] = [
{ name: 'drums', label: 'Drums', icon: <Drum className="h-3 w-3" /> },
{ name: 'chords', label: 'Chords', icon: <Music className="h-3 w-3" /> },
{ name: 'ambient', label: 'Ambient', icon: <Cloud className="h-3 w-3" /> },
];
export function Mixer({
volumes,
pans,
muted,
soloed,
levels,
onVolumeChange,
onPanChange,
onMuteToggle,
onSoloToggle,
}: MixerProps) {
return (
<div className="flex gap-2 overflow-x-auto pb-2">
{layerConfig.map(({ name, label, icon }) => (
<ChannelStrip
key={name}
name={label}
icon={icon}
volume={volumes[name]}
pan={pans[name]}
muted={muted[name]}
soloed={soloed[name]}
level={levels[name]}
onVolumeChange={(v) => onVolumeChange(name, v)}
onPanChange={(p) => onPanChange(name, p)}
onMuteToggle={() => onMuteToggle(name)}
onSoloToggle={() => onSoloToggle(name)}
/>
))}
<div className="w-px bg-border/50 mx-1" />
<ChannelStrip
name="Master"
icon={<Volume2 className="h-3 w-3" />}
volume={volumes.master}
pan={0}
muted={false}
soloed={false}
level={levels.master}
onVolumeChange={(v) => onVolumeChange('master', v)}
onPanChange={() => {}}
onMuteToggle={() => {}}
onSoloToggle={() => {}}
showPan={false}
showSolo={false}
/>
</div>
);
}

View File

@ -0,0 +1,81 @@
'use client';
import { useCallback, useRef, useState } from 'react';
import { cn } from '@/lib/utils';
interface PanKnobProps {
value: number;
onChange: (value: number) => void;
className?: string;
size?: number;
}
export function PanKnob({ value, onChange, className, size = 32 }: PanKnobProps) {
const knobRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const startY = useRef(0);
const startValue = useRef(0);
const rotation = value * 135;
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
setIsDragging(true);
startY.current = e.clientY;
startValue.current = value;
const handleMouseMove = (e: MouseEvent) => {
const delta = (startY.current - e.clientY) / 100;
const newValue = Math.max(-1, Math.min(1, startValue.current + delta));
onChange(newValue);
};
const handleMouseUp = () => {
setIsDragging(false);
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
},
[value, onChange]
);
const handleDoubleClick = useCallback(() => {
onChange(0);
}, [onChange]);
const label = value === 0 ? 'C' : value < 0 ? 'L' : 'R';
return (
<div className={cn('flex flex-col items-center gap-1', className)}>
<div
ref={knobRef}
className={cn(
'relative rounded-full bg-muted border-2 border-border cursor-ns-resize',
'hover:border-primary/50 transition-colors',
isDragging && 'border-primary'
)}
style={{ width: size, height: size }}
onMouseDown={handleMouseDown}
onDoubleClick={handleDoubleClick}
>
<div
className="absolute top-1 left-1/2 w-0.5 h-2 bg-primary rounded-full -translate-x-1/2 origin-bottom"
style={{
transform: `translateX(-50%) rotate(${rotation}deg)`,
transformOrigin: `50% ${size / 2 - 4}px`,
}}
/>
<div
className="absolute inset-0 flex items-center justify-center text-[8px] font-mono text-muted-foreground"
>
{label}
</div>
</div>
<span className="text-[10px] text-muted-foreground uppercase">Pan</span>
</div>
);
}

View File

@ -0,0 +1,113 @@
'use client';
import { useCallback, useRef, useState } from 'react';
import { cn } from '@/lib/utils';
interface VerticalFaderProps {
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
height?: number;
className?: string;
}
export function VerticalFader({
value,
onChange,
min = 0,
max = 1,
height = 64,
className,
}: VerticalFaderProps) {
const trackRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const normalizedValue = (value - min) / (max - min);
const handleMove = useCallback(
(clientY: number) => {
if (!trackRef.current) return;
const rect = trackRef.current.getBoundingClientRect();
const y = clientY - rect.top;
const percentage = 1 - Math.max(0, Math.min(1, y / rect.height));
const newValue = min + percentage * (max - min);
onChange(newValue);
},
[min, max, onChange]
);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
setIsDragging(true);
handleMove(e.clientY);
const handleMouseMove = (e: MouseEvent) => {
handleMove(e.clientY);
};
const handleMouseUp = () => {
setIsDragging(false);
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
},
[handleMove]
);
const handleTouchStart = useCallback(
(e: React.TouchEvent) => {
setIsDragging(true);
handleMove(e.touches[0].clientY);
const handleTouchMove = (e: TouchEvent) => {
handleMove(e.touches[0].clientY);
};
const handleTouchEnd = () => {
setIsDragging(false);
window.removeEventListener('touchmove', handleTouchMove);
window.removeEventListener('touchend', handleTouchEnd);
};
window.addEventListener('touchmove', handleTouchMove);
window.addEventListener('touchend', handleTouchEnd);
},
[handleMove]
);
return (
<div
ref={trackRef}
className={cn(
'relative w-3 rounded-full cursor-pointer select-none',
'bg-border/60',
className
)}
style={{ height }}
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
>
{/* Fill */}
<div
className="absolute bottom-0 left-0 right-0 rounded-full bg-primary transition-all duration-75"
style={{ height: `${normalizedValue * 100}%` }}
/>
{/* Thumb */}
<div
className={cn(
'absolute left-1/2 -translate-x-1/2 w-4 h-4 rounded-full',
'bg-white border-2 border-primary shadow-md',
'transition-transform duration-75',
isDragging && 'scale-110'
)}
style={{ bottom: `calc(${normalizedValue * 100}% - 8px)` }}
/>
</div>
);
}

View File

@ -0,0 +1,5 @@
export { Mixer } from './Mixer';
export { ChannelStrip } from './ChannelStrip';
export { LevelMeter } from './LevelMeter';
export { PanKnob } from './PanKnob';
export { VerticalFader } from './VerticalFader';

View File

@ -0,0 +1,197 @@
'use client';
import { useCallback, useMemo, useRef, useState } from 'react';
import { cn } from '@/lib/utils';
import { AutomationPoint } from '@/types/audio';
interface AutomationLaneProps {
label: string;
icon?: React.ReactNode;
points: AutomationPoint[];
durationBars: number;
pixelsPerBar: number;
height?: number;
onAddPoint: (bar: number, beat: number, value: number) => void;
onUpdatePoint: (id: string, bar: number, beat: number, value: number) => void;
onDeletePoint: (id: string) => void;
className?: string;
}
export function AutomationLane({
label,
icon,
points,
durationBars,
pixelsPerBar,
height = 48,
onAddPoint,
onUpdatePoint,
onDeletePoint,
className,
}: AutomationLaneProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [draggingId, setDraggingId] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const pixelsPerBeat = pixelsPerBar / 4;
const width = durationBars * pixelsPerBar;
const sortedPoints = useMemo(() => {
return [...points].sort((a, b) => {
if (a.bar !== b.bar) return a.bar - b.bar;
return a.beat - b.beat;
});
}, [points]);
const pathD = useMemo(() => {
if (sortedPoints.length === 0) return '';
const toX = (bar: number, beat: number) => bar * pixelsPerBar + beat * pixelsPerBeat;
const toY = (value: number) => height - value * height;
let d = `M 0 ${toY(sortedPoints[0]?.value ?? 0.5)}`;
if (sortedPoints.length > 0) {
d = `M ${toX(sortedPoints[0].bar, sortedPoints[0].beat)} ${toY(sortedPoints[0].value)}`;
for (let i = 1; i < sortedPoints.length; i++) {
const p = sortedPoints[i];
d += ` L ${toX(p.bar, p.beat)} ${toY(p.value)}`;
}
}
return d;
}, [sortedPoints, pixelsPerBar, pixelsPerBeat, height]);
const handleContainerClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (draggingId) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const totalBeats = x / pixelsPerBeat;
const bar = Math.floor(totalBeats / 4);
const beat = Math.floor(totalBeats % 4);
const value = Math.max(0, Math.min(1, 1 - y / height));
if (bar >= 0 && bar < durationBars) {
onAddPoint(bar, beat, value);
}
},
[draggingId, pixelsPerBeat, height, durationBars, onAddPoint]
);
const handlePointMouseDown = useCallback(
(point: AutomationPoint, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setDraggingId(point.id);
setSelectedId(point.id);
const handleMouseMove = (e: MouseEvent) => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const totalBeats = Math.max(0, x / pixelsPerBeat);
const bar = Math.min(durationBars - 1, Math.floor(totalBeats / 4));
const beat = Math.floor(totalBeats % 4);
const value = Math.max(0, Math.min(1, 1 - y / height));
onUpdatePoint(point.id, bar, beat, value);
};
const handleMouseUp = () => {
setDraggingId(null);
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
},
[pixelsPerBeat, height, durationBars, onUpdatePoint]
);
const handlePointContextMenu = useCallback(
(point: AutomationPoint, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onDeletePoint(point.id);
},
[onDeletePoint]
);
return (
<div className={cn('relative', className)}>
<div className="absolute left-0 top-0 bottom-0 w-20 flex items-center gap-1.5 px-2 bg-card/50 border-r border-border/50 z-10">
{icon}
<span className="text-xs font-medium text-muted-foreground truncate">
{label}
</span>
</div>
<div
ref={containerRef}
className="ml-20 bg-muted/10 border-b border-border/30 cursor-crosshair relative"
style={{ width, height }}
onClick={handleContainerClick}
>
{Array.from({ length: durationBars }, (_, i) => (
<div
key={i}
className={cn(
'absolute top-0 bottom-0 border-l',
i % 4 === 0 ? 'border-border/30' : 'border-border/10'
)}
style={{ left: i * pixelsPerBar }}
/>
))}
<svg
className="absolute inset-0 pointer-events-none"
width={width}
height={height}
>
{pathD && (
<path
d={pathD}
fill="none"
stroke="var(--lofi-orange)"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
)}
</svg>
{sortedPoints.map((point) => {
const x = point.bar * pixelsPerBar + point.beat * pixelsPerBeat;
const y = height - point.value * height;
return (
<div
key={point.id}
className={cn(
'absolute w-3 h-3 rounded-full cursor-move',
'transform -translate-x-1/2 -translate-y-1/2',
'border-2 transition-transform',
selectedId === point.id
? 'bg-lofi-orange border-white scale-125'
: 'bg-lofi-orange/80 border-lofi-orange hover:scale-110',
draggingId === point.id && 'scale-125'
)}
style={{ left: x, top: y }}
onMouseDown={(e) => handlePointMouseDown(point, e)}
onContextMenu={(e) => handlePointContextMenu(point, e)}
/>
);
})}
</div>
</div>
);
}

View File

@ -0,0 +1,32 @@
'use client';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
interface DurationSelectorProps {
value: number;
onChange: (bars: number) => void;
className?: string;
}
const presets = [8, 16, 32, 64];
export function DurationSelector({ value, onChange, className }: DurationSelectorProps) {
return (
<div className={cn('flex items-center gap-1', className)}>
<span className="text-xs text-muted-foreground mr-1">Duration:</span>
{presets.map((bars) => (
<Button
key={bars}
variant={value === bars ? 'default' : 'outline'}
size="sm"
className="h-6 px-2 text-xs"
onClick={() => onChange(bars)}
>
{bars}
</Button>
))}
<span className="text-xs text-muted-foreground ml-1">bars</span>
</div>
);
}

View File

@ -0,0 +1,163 @@
'use client';
import { useState, useCallback } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Download, X, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
interface ExportModalProps {
durationBars: number;
bpm: number;
onExport: () => Promise<Blob>;
onClose: () => void;
className?: string;
}
export function ExportModal({
durationBars,
bpm,
onExport,
onClose,
className,
}: ExportModalProps) {
const [filename, setFilename] = useState('lofi-beat');
const [isExporting, setIsExporting] = useState(false);
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const secondsPerBar = (60 / bpm) * 4;
const totalSeconds = durationBars * secondsPerBar;
const estimatedSizeKB = Math.round(totalSeconds * 44.1 * 2 * 2);
const estimatedSizeMB = (estimatedSizeKB / 1024).toFixed(1);
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const handleExport = useCallback(async () => {
setIsExporting(true);
setError(null);
setProgress(0);
const progressInterval = setInterval(() => {
setProgress((p) => Math.min(p + Math.random() * 10, 90));
}, 200);
try {
const blob = await onExport();
setProgress(100);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${filename}.wav`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
setTimeout(onClose, 500);
} catch (err) {
setError(err instanceof Error ? err.message : 'Export failed');
} finally {
clearInterval(progressInterval);
setIsExporting(false);
}
}, [filename, onExport, onClose]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<Card className={cn('w-full max-w-sm', className)}>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-lg">Export Audio</CardTitle>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="filename">Filename</Label>
<div className="flex items-center gap-2">
<input
id="filename"
type="text"
value={filename}
onChange={(e) => setFilename(e.target.value)}
className={cn(
'flex-1 px-3 py-2 text-sm rounded-md bg-muted border border-border',
'focus:outline-none focus:ring-2 focus:ring-primary'
)}
/>
<span className="text-sm text-muted-foreground">.wav</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Duration:</span>
<span className="ml-2 font-mono">{formatDuration(totalSeconds)}</span>
</div>
<div>
<span className="text-muted-foreground">Size:</span>
<span className="ml-2 font-mono">~{estimatedSizeMB} MB</span>
</div>
<div>
<span className="text-muted-foreground">Format:</span>
<span className="ml-2">WAV 44.1kHz 16-bit</span>
</div>
<div>
<span className="text-muted-foreground">Bars:</span>
<span className="ml-2 font-mono">{durationBars}</span>
</div>
</div>
{isExporting && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Rendering...</span>
<span className="font-mono">{Math.round(progress)}%</span>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-200"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)}
{error && (
<div className="p-2 text-sm text-red-500 bg-red-500/10 rounded-md">
{error}
</div>
)}
<Button
className="w-full"
onClick={handleExport}
disabled={isExporting || !filename}
>
{isExporting ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Rendering...
</>
) : (
<>
<Download className="h-4 w-4 mr-2" />
Export
</>
)}
</Button>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,56 @@
'use client';
import { cn } from '@/lib/utils';
interface KeyframeMarkerProps {
bar: number;
pixelsPerBar: number;
color?: string;
selected?: boolean;
onSelect?: () => void;
onDelete?: () => void;
className?: string;
}
export function KeyframeMarker({
bar,
pixelsPerBar,
color = 'bg-primary',
selected = false,
onSelect,
onDelete,
className,
}: KeyframeMarkerProps) {
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onSelect?.();
};
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onDelete?.();
};
return (
<div
className={cn(
'absolute top-1/2 -translate-y-1/2 cursor-pointer',
'transition-transform hover:scale-125',
selected && 'scale-125',
className
)}
style={{ left: bar * pixelsPerBar }}
onClick={handleClick}
onContextMenu={handleContextMenu}
>
<div
className={cn(
'w-3 h-3 rotate-45 border-2',
color,
selected ? 'border-white shadow-lg' : 'border-transparent'
)}
/>
</div>
);
}

View File

@ -0,0 +1,137 @@
'use client';
import { useCallback, useState } from 'react';
import { KeyframeMarker } from './KeyframeMarker';
import { cn } from '@/lib/utils';
interface Keyframe {
id: string;
bar: number;
}
interface KeyframeTrackProps<T extends Keyframe> {
label: string;
icon?: React.ReactNode;
keyframes: T[];
durationBars: number;
pixelsPerBar: number;
selectedId: string | null;
onSelect: (id: string | null) => void;
onAdd: (bar: number) => void;
onDelete: (id: string) => void;
markerColor?: string;
renderPicker?: (props: {
keyframe: T;
onClose: () => void;
position: { x: number; y: number };
}) => React.ReactNode;
className?: string;
}
export function KeyframeTrack<T extends Keyframe>({
label,
icon,
keyframes,
durationBars,
pixelsPerBar,
selectedId,
onSelect,
onAdd,
onDelete,
markerColor = 'bg-primary',
renderPicker,
className,
}: KeyframeTrackProps<T>) {
const [pickerPosition, setPickerPosition] = useState<{ x: number; y: number } | null>(
null
);
const handleTrackClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const bar = Math.floor(x / pixelsPerBar);
if (bar >= 0 && bar < durationBars) {
const existingKeyframe = keyframes.find((kf) => kf.bar === bar);
if (existingKeyframe) {
onSelect(existingKeyframe.id);
setPickerPosition({ x: bar * pixelsPerBar, y: 0 });
} else {
onAdd(bar);
}
}
},
[pixelsPerBar, durationBars, keyframes, onSelect, onAdd]
);
const handleMarkerSelect = useCallback(
(keyframe: T) => {
onSelect(keyframe.id);
setPickerPosition({ x: keyframe.bar * pixelsPerBar, y: 0 });
},
[onSelect, pixelsPerBar]
);
const handleClosePicker = useCallback(() => {
onSelect(null);
setPickerPosition(null);
}, [onSelect]);
const selectedKeyframe = selectedId
? keyframes.find((kf) => kf.id === selectedId)
: null;
return (
<div className={cn('relative', className)}>
<div className="absolute left-0 top-0 bottom-0 w-20 flex items-center gap-1.5 px-2 bg-card/50 border-r border-border/50 z-10">
{icon}
<span className="text-xs font-medium text-muted-foreground truncate">
{label}
</span>
</div>
<div
className="ml-20 h-8 bg-muted/10 border-b border-border/30 cursor-pointer relative"
style={{ width: durationBars * pixelsPerBar }}
onClick={handleTrackClick}
>
{Array.from({ length: durationBars }, (_, i) => (
<div
key={i}
className={cn(
'absolute top-0 bottom-0 border-l',
i % 4 === 0 ? 'border-border/30' : 'border-border/10'
)}
style={{ left: i * pixelsPerBar }}
/>
))}
{keyframes.map((kf) => (
<KeyframeMarker
key={kf.id}
bar={kf.bar}
pixelsPerBar={pixelsPerBar}
color={markerColor}
selected={kf.id === selectedId}
onSelect={() => handleMarkerSelect(kf)}
onDelete={() => onDelete(kf.id)}
/>
))}
{selectedKeyframe && pickerPosition && renderPicker && (
<div
className="absolute top-full mt-1"
style={{ left: Math.min(pickerPosition.x, durationBars * pixelsPerBar - 256) }}
>
{renderPicker({
keyframe: selectedKeyframe,
onClose: handleClosePicker,
position: pickerPosition,
})}
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,127 @@
'use client';
import { useCallback, useRef, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Repeat } from 'lucide-react';
import { cn } from '@/lib/utils';
import { LoopRegion } from '@/types/audio';
interface LoopBracketProps {
region: LoopRegion;
durationBars: number;
pixelsPerBar: number;
onChange: (region: LoopRegion) => void;
className?: string;
}
export function LoopBracket({
region,
durationBars,
pixelsPerBar,
onChange,
className,
}: LoopBracketProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [dragging, setDragging] = useState<'start' | 'end' | 'region' | null>(null);
const dragStart = useRef({ x: 0, region: { ...region } });
const left = region.start * pixelsPerBar;
const width = (region.end - region.start) * pixelsPerBar;
const handleMouseDown = useCallback(
(type: 'start' | 'end' | 'region', e: React.MouseEvent) => {
e.preventDefault();
setDragging(type);
dragStart.current = { x: e.clientX, region: { ...region } };
const handleMouseMove = (e: MouseEvent) => {
if (!containerRef.current) return;
const deltaX = e.clientX - dragStart.current.x;
const deltaBars = Math.round(deltaX / pixelsPerBar);
const { start, end, enabled } = dragStart.current.region;
let newStart = start;
let newEnd = end;
if (type === 'start') {
newStart = Math.max(0, Math.min(end - 1, start + deltaBars));
} else if (type === 'end') {
newEnd = Math.max(start + 1, Math.min(durationBars, end + deltaBars));
} else if (type === 'region') {
const duration = end - start;
newStart = Math.max(0, Math.min(durationBars - duration, start + deltaBars));
newEnd = newStart + duration;
}
onChange({ start: newStart, end: newEnd, enabled });
};
const handleMouseUp = () => {
setDragging(null);
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
},
[region, pixelsPerBar, durationBars, onChange]
);
const toggleEnabled = useCallback(() => {
onChange({ ...region, enabled: !region.enabled });
}, [region, onChange]);
return (
<div
ref={containerRef}
className={cn('relative h-5', className)}
style={{ width: durationBars * pixelsPerBar }}
>
<div
className={cn(
'absolute top-0 h-full rounded-sm transition-colors',
region.enabled
? 'bg-lofi-orange/20 border border-lofi-orange/50'
: 'bg-muted/20 border border-border/50'
)}
style={{ left, width }}
>
<div
className={cn(
'absolute left-0 top-0 bottom-0 w-2 cursor-ew-resize',
'hover:bg-lofi-orange/30',
dragging === 'start' && 'bg-lofi-orange/40'
)}
onMouseDown={(e) => handleMouseDown('start', e)}
/>
<div
className="absolute inset-x-2 inset-y-0 cursor-move"
onMouseDown={(e) => handleMouseDown('region', e)}
/>
<div
className={cn(
'absolute right-0 top-0 bottom-0 w-2 cursor-ew-resize',
'hover:bg-lofi-orange/30',
dragging === 'end' && 'bg-lofi-orange/40'
)}
onMouseDown={(e) => handleMouseDown('end', e)}
/>
</div>
<Button
variant={region.enabled ? 'default' : 'outline'}
size="sm"
className={cn(
'absolute -right-8 top-0 h-5 w-6 p-0',
region.enabled && 'bg-lofi-orange hover:bg-lofi-orange/80'
)}
onClick={toggleEnabled}
>
<Repeat className="h-3 w-3" />
</Button>
</div>
);
}

View File

@ -0,0 +1,117 @@
'use client';
import { useCallback, useMemo } from 'react';
import { cn } from '@/lib/utils';
import { MuteKeyframe } from '@/types/audio';
interface MuteTrackProps {
label: string;
icon?: React.ReactNode;
keyframes: MuteKeyframe[];
durationBars: number;
pixelsPerBar: number;
onToggle: (bar: number) => void;
className?: string;
}
interface MuteRegion {
startBar: number;
endBar: number;
}
export function MuteTrack({
label,
icon,
keyframes,
durationBars,
pixelsPerBar,
onToggle,
className,
}: MuteTrackProps) {
const mutedRegions = useMemo(() => {
const sorted = [...keyframes].sort((a, b) => a.bar - b.bar);
const regions: MuteRegion[] = [];
let currentMuted = false;
let regionStart = 0;
sorted.forEach((kf) => {
if (kf.muted && !currentMuted) {
regionStart = kf.bar;
currentMuted = true;
} else if (!kf.muted && currentMuted) {
regions.push({ startBar: regionStart, endBar: kf.bar });
currentMuted = false;
}
});
if (currentMuted) {
regions.push({ startBar: regionStart, endBar: durationBars });
}
return regions;
}, [keyframes, durationBars]);
const handleClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const bar = Math.floor(x / pixelsPerBar);
if (bar >= 0 && bar < durationBars) {
onToggle(bar);
}
},
[pixelsPerBar, durationBars, onToggle]
);
return (
<div className={cn('relative', className)}>
<div className="absolute left-0 top-0 bottom-0 w-20 flex items-center gap-1.5 px-2 bg-card/50 border-r border-border/50 z-10">
{icon}
<span className="text-xs font-medium text-muted-foreground truncate">
{label}
</span>
</div>
<div
className="ml-20 h-6 bg-muted/10 border-b border-border/30 cursor-pointer relative"
style={{ width: durationBars * pixelsPerBar }}
onClick={handleClick}
>
{Array.from({ length: durationBars }, (_, i) => (
<div
key={i}
className={cn(
'absolute top-0 bottom-0 border-l',
i % 4 === 0 ? 'border-border/30' : 'border-border/10'
)}
style={{ left: i * pixelsPerBar }}
/>
))}
{mutedRegions.map((region, i) => (
<div
key={i}
className="absolute top-1 bottom-1 bg-red-500/30 rounded-sm border border-red-500/50"
style={{
left: region.startBar * pixelsPerBar,
width: (region.endBar - region.startBar) * pixelsPerBar,
}}
/>
))}
{keyframes.map((kf) => (
<div
key={kf.id}
className={cn(
'absolute top-1/2 -translate-y-1/2 w-1 h-4 rounded-full',
kf.muted ? 'bg-red-500' : 'bg-emerald-500'
)}
style={{ left: kf.bar * pixelsPerBar - 2 }}
/>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,108 @@
'use client';
import { useMemo } from 'react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Shuffle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { drumPatterns } from '@/lib/audio/patterns';
interface PatternPickerProps {
selectedIndex: number | null;
onSelect: (index: number) => void;
onRandom: () => void;
onClose: () => void;
className?: string;
}
const patternNames = [
'Classic Boom Bap',
'Laid Back Groove',
'Minimal Chill',
'Jazzy Swing',
'Deep Pocket',
];
function PatternPreview({ pattern }: { pattern: typeof drumPatterns[0] }) {
return (
<div className="grid grid-cols-16 gap-px w-full h-8 bg-muted/30 rounded">
{Array.from({ length: 16 }, (_, i) => {
const hasKick = pattern.kick[i];
const hasSnare = pattern.snare[i];
const hasHihat = pattern.hihat[i];
const hasOpenhat = pattern.openhat[i];
return (
<div
key={i}
className={cn(
'flex flex-col gap-px justify-center',
i % 4 === 0 && 'bg-muted/20'
)}
>
{hasKick && <div className="w-full h-1.5 bg-lofi-orange/80 rounded-sm" />}
{hasSnare && <div className="w-full h-1.5 bg-lofi-pink/80 rounded-sm" />}
{(hasHihat || hasOpenhat) && (
<div
className={cn(
'w-full h-1 rounded-sm',
hasOpenhat ? 'bg-yellow-500/60' : 'bg-muted-foreground/40'
)}
/>
)}
</div>
);
})}
</div>
);
}
export function PatternPicker({
selectedIndex,
onSelect,
onRandom,
onClose,
className,
}: PatternPickerProps) {
return (
<Card
className={cn(
'absolute z-50 p-3 w-64 bg-card border border-border shadow-xl',
className
)}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-muted-foreground uppercase">
Drum Patterns
</span>
<Button variant="ghost" size="sm" className="h-6 px-2" onClick={onRandom}>
<Shuffle className="h-3 w-3 mr-1" />
Random
</Button>
</div>
<div className="space-y-2">
{drumPatterns.map((pattern, i) => (
<button
key={i}
className={cn(
'w-full p-2 rounded-md border transition-colors text-left',
selectedIndex === i
? 'border-primary bg-primary/10'
: 'border-border/50 hover:border-border hover:bg-muted/30'
)}
onClick={() => onSelect(i)}
>
<span className="text-xs font-medium block mb-1">{patternNames[i]}</span>
<PatternPreview pattern={pattern} />
</button>
))}
</div>
<Button variant="outline" size="sm" className="w-full mt-2" onClick={onClose}>
Close
</Button>
</Card>
);
}

View File

@ -0,0 +1,94 @@
'use client';
import { useCallback, useState } from 'react';
import { cn } from '@/lib/utils';
interface PlayheadProps {
bar: number;
beat: number;
pixelsPerBar: number;
height: number;
durationBars: number;
onSeek?: (bar: number, beat: number) => void;
className?: string;
}
export function Playhead({
bar,
beat,
pixelsPerBar,
height,
durationBars,
onSeek,
className,
}: PlayheadProps) {
const [isDragging, setIsDragging] = useState(false);
const pixelsPerBeat = pixelsPerBar / 4;
const position = bar * pixelsPerBar + beat * pixelsPerBeat;
const handleDrag = useCallback(
(clientX: number, containerLeft: number) => {
if (!onSeek) return;
const x = clientX - containerLeft;
const totalBeats = x / pixelsPerBeat;
const newBar = Math.floor(totalBeats / 4);
const newBeat = Math.floor(totalBeats % 4);
if (newBar >= 0 && newBar < durationBars) {
onSeek(newBar, Math.max(0, Math.min(3, newBeat)));
}
},
[onSeek, pixelsPerBeat, durationBars]
);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if (!onSeek) return;
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
const container = (e.currentTarget as HTMLElement).parentElement;
if (!container) return;
const containerRect = container.getBoundingClientRect();
const handleMouseMove = (e: MouseEvent) => {
handleDrag(e.clientX, containerRect.left);
};
const handleMouseUp = () => {
setIsDragging(false);
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
},
[onSeek, handleDrag]
);
return (
<div
className={cn(
'absolute top-0 z-20',
'flex flex-col items-center',
className
)}
style={{ left: position, height }}
>
<div
className={cn(
'w-4 h-3 bg-lofi-orange rounded-sm -mt-0.5 cursor-grab',
isDragging && 'cursor-grabbing scale-110'
)}
style={{
clipPath: 'polygon(50% 100%, 0% 0%, 100% 0%)',
}}
onMouseDown={handleMouseDown}
/>
<div className="w-0.5 flex-1 bg-lofi-orange shadow-[0_0_8px_var(--lofi-orange)] pointer-events-none" />
</div>
);
}

View File

@ -0,0 +1,91 @@
'use client';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Shuffle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { chordProgressions } from '@/lib/audio/patterns';
interface ProgressionPickerProps {
selectedIndex: number | null;
onSelect: (index: number) => void;
onRandom: () => void;
onClose: () => void;
className?: string;
}
function chordToName(notes: string[]): string {
if (notes.length === 0) return '';
const noteMap: Record<string, string> = {
'C': 'C', 'D': 'D', 'E': 'E', 'F': 'F', 'G': 'G', 'A': 'A', 'B': 'B',
};
const root = notes[0].replace(/[0-9]/g, '');
const rootName = root.replace('#', '♯').replace('b', '♭');
if (notes.some((n) => n.includes('b') || n.includes('Eb') || n.includes('Bb'))) {
return `${rootName}m7`;
}
return `${rootName}maj7`;
}
export function ProgressionPicker({
selectedIndex,
onSelect,
onRandom,
onClose,
className,
}: ProgressionPickerProps) {
return (
<Card
className={cn(
'absolute z-50 p-3 w-64 bg-card border border-border shadow-xl',
className
)}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-muted-foreground uppercase">
Chord Progressions
</span>
<Button variant="ghost" size="sm" className="h-6 px-2" onClick={onRandom}>
<Shuffle className="h-3 w-3 mr-1" />
Random
</Button>
</div>
<div className="space-y-2">
{chordProgressions.map((prog, i) => (
<button
key={i}
className={cn(
'w-full p-2 rounded-md border transition-colors text-left',
selectedIndex === i
? 'border-primary bg-primary/10'
: 'border-border/50 hover:border-border hover:bg-muted/30'
)}
onClick={() => onSelect(i)}
>
<span className="text-xs font-medium block mb-1">{prog.name}</span>
<div className="flex gap-1 text-[10px] text-muted-foreground font-mono">
{prog.chords.map((chord, j) => (
<span
key={j}
className="px-1.5 py-0.5 bg-muted/50 rounded"
>
{chordToName(chord)}
</span>
))}
</div>
</button>
))}
</div>
<Button variant="outline" size="sm" className="w-full mt-2" onClick={onClose}>
Close
</Button>
</Card>
);
}

View File

@ -0,0 +1,130 @@
'use client';
import { useCallback, useRef, useState } from 'react';
import { cn } from '@/lib/utils';
import { Section, SECTION_COLORS } from '@/types/audio';
interface SectionBlockProps {
section: Section;
pixelsPerBar: number;
durationBars: number;
onResize: (id: string, startBar: number, endBar: number) => void;
onMove: (id: string, startBar: number) => void;
onSelect: (id: string) => void;
onDelete: (id: string) => void;
selected?: boolean;
}
export function SectionBlock({
section,
pixelsPerBar,
durationBars,
onResize,
onMove,
onSelect,
onDelete,
selected = false,
}: SectionBlockProps) {
const [dragging, setDragging] = useState<'move' | 'start' | 'end' | null>(null);
const dragStart = useRef({ x: 0, startBar: 0, endBar: 0 });
const left = section.startBar * pixelsPerBar;
const width = (section.endBar - section.startBar) * pixelsPerBar;
const color = SECTION_COLORS[section.type];
const handleMouseDown = useCallback(
(type: 'move' | 'start' | 'end', e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setDragging(type);
dragStart.current = {
x: e.clientX,
startBar: section.startBar,
endBar: section.endBar,
};
onSelect(section.id);
const handleMouseMove = (e: MouseEvent) => {
const deltaX = e.clientX - dragStart.current.x;
const deltaBars = Math.round(deltaX / pixelsPerBar);
const { startBar, endBar } = dragStart.current;
if (type === 'start') {
const newStart = Math.max(0, Math.min(endBar - 1, startBar + deltaBars));
onResize(section.id, newStart, endBar);
} else if (type === 'end') {
const newEnd = Math.max(startBar + 1, Math.min(durationBars, endBar + deltaBars));
onResize(section.id, startBar, newEnd);
} else {
const duration = endBar - startBar;
const newStart = Math.max(
0,
Math.min(durationBars - duration, startBar + deltaBars)
);
onMove(section.id, newStart);
}
};
const handleMouseUp = () => {
setDragging(null);
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
},
[section, pixelsPerBar, durationBars, onResize, onMove, onSelect]
);
const handleContextMenu = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
onDelete(section.id);
},
[section.id, onDelete]
);
return (
<div
className={cn(
'absolute top-1 bottom-1 rounded-md cursor-move',
'transition-shadow',
selected && 'ring-2 ring-white/50 shadow-lg'
)}
style={{
left,
width,
backgroundColor: color,
}}
onContextMenu={handleContextMenu}
>
<div
className={cn(
'absolute left-0 top-0 bottom-0 w-2 cursor-ew-resize',
'hover:bg-white/20 rounded-l-md',
dragging === 'start' && 'bg-white/30'
)}
onMouseDown={(e) => handleMouseDown('start', e)}
/>
<div
className="absolute inset-x-2 inset-y-0 flex items-center px-1 overflow-hidden"
onMouseDown={(e) => handleMouseDown('move', e)}
>
<span className="text-[10px] font-medium text-white/90 truncate">
{section.name || section.type}
</span>
</div>
<div
className={cn(
'absolute right-0 top-0 bottom-0 w-2 cursor-ew-resize',
'hover:bg-white/20 rounded-r-md',
dragging === 'end' && 'bg-white/30'
)}
onMouseDown={(e) => handleMouseDown('end', e)}
/>
</div>
);
}

View File

@ -0,0 +1,63 @@
'use client';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { SectionType, SECTION_COLORS } from '@/types/audio';
interface SectionPickerProps {
onSelect: (type: SectionType) => void;
onClose: () => void;
className?: string;
}
const sectionTypes: { type: SectionType; label: string }[] = [
{ type: 'intro', label: 'Intro' },
{ type: 'verse', label: 'Verse' },
{ type: 'chorus', label: 'Chorus' },
{ type: 'drop', label: 'Drop' },
{ type: 'bridge', label: 'Bridge' },
{ type: 'outro', label: 'Outro' },
];
export function SectionPicker({ onSelect, onClose, className }: SectionPickerProps) {
return (
<Card
className={cn(
'absolute z-50 p-3 bg-card border border-border shadow-xl min-w-[180px]',
className
)}
onClick={(e) => e.stopPropagation()}
>
<div className="text-xs font-medium text-muted-foreground uppercase mb-2">
Add Section
</div>
<div className="grid grid-cols-2 gap-2">
{sectionTypes.map(({ type, label }) => (
<button
key={type}
className={cn(
'px-4 py-2 rounded-md text-sm font-medium text-white text-center',
'hover:brightness-110 transition-all'
)}
style={{ backgroundColor: SECTION_COLORS[type] }}
onClick={() => {
onSelect(type);
onClose();
}}
>
{label}
</button>
))}
</div>
<Button
variant="ghost"
size="sm"
className="w-full mt-2 h-8 text-xs"
onClick={onClose}
>
Cancel
</Button>
</Card>
);
}

View File

@ -0,0 +1,112 @@
'use client';
import { useCallback, useState } from 'react';
import { Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { SectionBlock } from './SectionBlock';
import { SectionPicker } from './SectionPicker';
import { cn } from '@/lib/utils';
import { Section, SectionType } from '@/types/audio';
interface SectionTrackProps {
sections: Section[];
durationBars: number;
pixelsPerBar: number;
selectedId: string | null;
onSelect: (id: string | null) => void;
onAdd: (type: SectionType, startBar: number, endBar: number) => void;
onResize: (id: string, startBar: number, endBar: number) => void;
onMove: (id: string, startBar: number) => void;
onDelete: (id: string) => void;
className?: string;
}
export function SectionTrack({
sections,
durationBars,
pixelsPerBar,
selectedId,
onSelect,
onAdd,
onResize,
onMove,
onDelete,
className,
}: SectionTrackProps) {
const [showPicker, setShowPicker] = useState(false);
const [pickerPosition, setPickerPosition] = useState({ x: 0, bar: 0 });
const handleTrackClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const bar = Math.floor(x / pixelsPerBar);
if (bar >= 0 && bar < durationBars) {
setPickerPosition({ x, bar });
setShowPicker(true);
}
},
[pixelsPerBar, durationBars]
);
const handleSelectType = useCallback(
(type: SectionType) => {
const endBar = Math.min(durationBars, pickerPosition.bar + 4);
onAdd(type, pickerPosition.bar, endBar);
},
[pickerPosition.bar, durationBars, onAdd]
);
return (
<div className={cn('relative', className)}>
<div className="absolute left-0 top-0 bottom-0 w-20 flex items-center gap-1.5 px-2 bg-card/50 border-r border-border/50 z-10">
<Plus className="h-3 w-3 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground">Sections</span>
</div>
<div
className="ml-20 h-8 bg-muted/10 border-b border-border/30 cursor-pointer relative"
style={{ width: durationBars * pixelsPerBar }}
onClick={handleTrackClick}
>
{Array.from({ length: durationBars }, (_, i) => (
<div
key={i}
className={cn(
'absolute top-0 bottom-0 border-l',
i % 4 === 0 ? 'border-border/30' : 'border-border/10'
)}
style={{ left: i * pixelsPerBar }}
/>
))}
{sections.map((section) => (
<SectionBlock
key={section.id}
section={section}
pixelsPerBar={pixelsPerBar}
durationBars={durationBars}
onResize={onResize}
onMove={onMove}
onSelect={onSelect}
onDelete={onDelete}
selected={section.id === selectedId}
/>
))}
{showPicker && (
<div
className="absolute top-full mt-1"
style={{ left: Math.min(pickerPosition.x, durationBars * pixelsPerBar - 128) }}
>
<SectionPicker
onSelect={handleSelectType}
onClose={() => setShowPicker(false)}
/>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,55 @@
'use client';
import { useMemo } from 'react';
import { cn } from '@/lib/utils';
interface TimeDisplayProps {
bar: number;
beat: number;
durationBars: number;
bpm: number;
className?: string;
}
export function TimeDisplay({
bar,
beat,
durationBars,
bpm,
className,
}: TimeDisplayProps) {
const { currentTime, totalTime } = useMemo(() => {
const secondsPerBeat = 60 / bpm;
const secondsPerBar = secondsPerBeat * 4;
const currentSeconds = bar * secondsPerBar + beat * secondsPerBeat;
const totalSeconds = durationBars * secondsPerBar;
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
return {
currentTime: formatTime(currentSeconds),
totalTime: formatTime(totalSeconds),
};
}, [bar, beat, durationBars, bpm]);
return (
<div className={cn('flex items-center gap-3 font-mono text-sm', className)}>
<div className="flex items-center gap-1">
<span className="text-muted-foreground">Bar</span>
<span className="text-foreground font-medium w-6 text-right">{bar + 1}</span>
<span className="text-muted-foreground/50">|</span>
<span className="text-muted-foreground">Beat</span>
<span className="text-foreground font-medium w-4">{beat + 1}</span>
</div>
<div className="text-muted-foreground/50">|</div>
<div className="text-muted-foreground">
{currentTime} / {totalTime}
</div>
</div>
);
}

View File

@ -0,0 +1,305 @@
'use client';
import { useCallback, useRef, useState } from 'react';
import { Drum, Music, Cloud, Volume2, VolumeX } from 'lucide-react';
import { Toggle } from '@/components/ui/toggle';
import { TimelineRuler } from './TimelineRuler';
import { Playhead } from './Playhead';
import { TimeDisplay } from './TimeDisplay';
import { DurationSelector } from './DurationSelector';
import { LoopBracket } from './LoopBracket';
import { SectionTrack } from './SectionTrack';
import { KeyframeTrack } from './KeyframeTrack';
import { PatternPicker } from './PatternPicker';
import { ProgressionPicker } from './ProgressionPicker';
import { MuteTrack } from './MuteTrack';
import { AutomationLane } from './AutomationLane';
import { cn } from '@/lib/utils';
import {
TimelineState,
LayerName,
PatternKeyframe,
ChordKeyframe,
SectionType,
} from '@/types/audio';
interface TimelineProps {
state: TimelineState;
bpm: number;
isPlaying: boolean;
pixelsPerBar?: number;
onDurationChange: (bars: number) => void;
onSeek: (bar: number, beat: number) => void;
onLoopRegionChange: (start: number, end: number, enabled: boolean) => void;
onSectionAdd: (type: SectionType, startBar: number, endBar: number) => void;
onSectionResize: (id: string, startBar: number, endBar: number) => void;
onSectionMove: (id: string, startBar: number) => void;
onSectionDelete: (id: string) => void;
onDrumKeyframeAdd: (bar: number, patternIndex: number) => void;
onDrumKeyframeUpdate: (id: string, patternIndex: number) => void;
onDrumKeyframeDelete: (id: string) => void;
onChordKeyframeAdd: (bar: number, progressionIndex: number) => void;
onChordKeyframeUpdate: (id: string, progressionIndex: number) => void;
onChordKeyframeDelete: (id: string) => void;
onMuteKeyframeToggle: (layer: LayerName, bar: number) => void;
onAutomationPointAdd: (
layer: LayerName,
bar: number,
beat: number,
value: number
) => void;
onAutomationPointUpdate: (
layer: LayerName,
id: string,
bar: number,
beat: number,
value: number
) => void;
onAutomationPointDelete: (layer: LayerName, id: string) => void;
className?: string;
}
const PIXELS_PER_BAR = 48;
export function Timeline({
state,
bpm,
isPlaying,
pixelsPerBar = PIXELS_PER_BAR,
onDurationChange,
onSeek,
onLoopRegionChange,
onSectionAdd,
onSectionResize,
onSectionMove,
onSectionDelete,
onDrumKeyframeAdd,
onDrumKeyframeUpdate,
onDrumKeyframeDelete,
onChordKeyframeAdd,
onChordKeyframeUpdate,
onChordKeyframeDelete,
onMuteKeyframeToggle,
onAutomationPointAdd,
onAutomationPointUpdate,
onAutomationPointDelete,
className,
}: TimelineProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [selectedSectionId, setSelectedSectionId] = useState<string | null>(null);
const [selectedDrumKeyframeId, setSelectedDrumKeyframeId] = useState<string | null>(null);
const [selectedChordKeyframeId, setSelectedChordKeyframeId] = useState<string | null>(null);
const [expandedLayers, setExpandedLayers] = useState<Record<LayerName, boolean>>({
drums: false,
chords: false,
ambient: false,
});
const toggleLayerExpand = (layer: LayerName) => {
setExpandedLayers((prev) => ({ ...prev, [layer]: !prev[layer] }));
};
const handleRulerClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left - 80;
const totalBeats = x / (pixelsPerBar / 4);
const bar = Math.floor(totalBeats / 4);
const beat = Math.floor(totalBeats % 4);
if (bar >= 0 && bar < state.durationBars) {
onSeek(bar, beat);
}
},
[pixelsPerBar, state.durationBars, onSeek]
);
const totalHeight =
24 + 20 + 32 + 32 + 32 +
(expandedLayers.drums ? 48 : 0) +
(expandedLayers.chords ? 48 : 0) +
(expandedLayers.ambient ? 48 : 0) +
24 + 24 + 24;
return (
<div className={cn('flex flex-col bg-card/30 rounded-lg border border-border/50', className)}>
<div className="flex items-center justify-between p-2 border-b border-border/50">
<TimeDisplay
bar={state.playheadBar}
beat={state.playheadBeat}
durationBars={state.durationBars}
bpm={bpm}
/>
<DurationSelector value={state.durationBars} onChange={onDurationChange} />
</div>
<div ref={containerRef} className="relative overflow-x-auto overflow-y-visible">
<div className="relative" style={{ minWidth: state.durationBars * pixelsPerBar + 80 }}>
<div className="ml-20 cursor-pointer" onClick={handleRulerClick}>
<TimelineRuler durationBars={state.durationBars} pixelsPerBar={pixelsPerBar} />
</div>
<div className="ml-20">
<LoopBracket
region={state.loopRegion}
durationBars={state.durationBars}
pixelsPerBar={pixelsPerBar}
onChange={(r) => onLoopRegionChange(r.start, r.end, r.enabled)}
/>
</div>
<SectionTrack
sections={state.sections}
durationBars={state.durationBars}
pixelsPerBar={pixelsPerBar}
selectedId={selectedSectionId}
onSelect={setSelectedSectionId}
onAdd={onSectionAdd}
onResize={onSectionResize}
onMove={onSectionMove}
onDelete={onSectionDelete}
/>
<KeyframeTrack<PatternKeyframe>
label="Drums"
icon={<Drum className="h-3 w-3" />}
keyframes={state.drumKeyframes}
durationBars={state.durationBars}
pixelsPerBar={pixelsPerBar}
selectedId={selectedDrumKeyframeId}
onSelect={setSelectedDrumKeyframeId}
onAdd={(bar) => onDrumKeyframeAdd(bar, 0)}
onDelete={onDrumKeyframeDelete}
markerColor="bg-lofi-orange"
renderPicker={({ keyframe, onClose }) => (
<PatternPicker
selectedIndex={keyframe.patternIndex}
onSelect={(idx) => onDrumKeyframeUpdate(keyframe.id, idx)}
onRandom={() => onDrumKeyframeUpdate(keyframe.id, Math.floor(Math.random() * 5))}
onClose={onClose}
/>
)}
/>
<div className="flex items-center h-5 px-2 ml-20 bg-muted/5">
<Toggle
pressed={expandedLayers.drums}
onPressedChange={() => toggleLayerExpand('drums')}
size="sm"
className="h-4 px-1 text-[9px]"
>
{expandedLayers.drums ? 'Hide' : 'Auto'}
</Toggle>
</div>
{expandedLayers.drums && (
<AutomationLane
label="Vol"
icon={<Volume2 className="h-3 w-3" />}
points={state.volumeAutomation.drums}
durationBars={state.durationBars}
pixelsPerBar={pixelsPerBar}
onAddPoint={(bar, beat, value) => onAutomationPointAdd('drums', bar, beat, value)}
onUpdatePoint={(id, bar, beat, value) =>
onAutomationPointUpdate('drums', id, bar, beat, value)
}
onDeletePoint={(id) => onAutomationPointDelete('drums', id)}
/>
)}
<KeyframeTrack<ChordKeyframe>
label="Chords"
icon={<Music className="h-3 w-3" />}
keyframes={state.chordKeyframes}
durationBars={state.durationBars}
pixelsPerBar={pixelsPerBar}
selectedId={selectedChordKeyframeId}
onSelect={setSelectedChordKeyframeId}
onAdd={(bar) => onChordKeyframeAdd(bar, 0)}
onDelete={onChordKeyframeDelete}
markerColor="bg-lofi-pink"
renderPicker={({ keyframe, onClose }) => (
<ProgressionPicker
selectedIndex={keyframe.progressionIndex}
onSelect={(idx) => onChordKeyframeUpdate(keyframe.id, idx)}
onRandom={() => onChordKeyframeUpdate(keyframe.id, Math.floor(Math.random() * 5))}
onClose={onClose}
/>
)}
/>
<div className="flex items-center h-5 px-2 ml-20 bg-muted/5">
<Toggle
pressed={expandedLayers.chords}
onPressedChange={() => toggleLayerExpand('chords')}
size="sm"
className="h-4 px-1 text-[9px]"
>
{expandedLayers.chords ? 'Hide' : 'Auto'}
</Toggle>
</div>
{expandedLayers.chords && (
<AutomationLane
label="Vol"
icon={<Volume2 className="h-3 w-3" />}
points={state.volumeAutomation.chords}
durationBars={state.durationBars}
pixelsPerBar={pixelsPerBar}
onAddPoint={(bar, beat, value) => onAutomationPointAdd('chords', bar, beat, value)}
onUpdatePoint={(id, bar, beat, value) =>
onAutomationPointUpdate('chords', id, bar, beat, value)
}
onDeletePoint={(id) => onAutomationPointDelete('chords', id)}
/>
)}
<MuteTrack
label="Ambient"
icon={<Cloud className="h-3 w-3" />}
keyframes={state.muteKeyframes.ambient}
durationBars={state.durationBars}
pixelsPerBar={pixelsPerBar}
onToggle={(bar) => onMuteKeyframeToggle('ambient', bar)}
/>
<div className="flex items-center h-5 px-2 ml-20 bg-muted/5">
<Toggle
pressed={expandedLayers.ambient}
onPressedChange={() => toggleLayerExpand('ambient')}
size="sm"
className="h-4 px-1 text-[9px]"
>
{expandedLayers.ambient ? 'Hide' : 'Auto'}
</Toggle>
</div>
{expandedLayers.ambient && (
<AutomationLane
label="Vol"
icon={<Volume2 className="h-3 w-3" />}
points={state.volumeAutomation.ambient}
durationBars={state.durationBars}
pixelsPerBar={pixelsPerBar}
onAddPoint={(bar, beat, value) => onAutomationPointAdd('ambient', bar, beat, value)}
onUpdatePoint={(id, bar, beat, value) =>
onAutomationPointUpdate('ambient', id, bar, beat, value)
}
onDeletePoint={(id) => onAutomationPointDelete('ambient', id)}
/>
)}
<Playhead
bar={state.playheadBar}
beat={state.playheadBeat}
pixelsPerBar={pixelsPerBar}
height={totalHeight}
durationBars={state.durationBars}
onSeek={onSeek}
className="ml-20"
/>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,51 @@
'use client';
import { useMemo } from 'react';
import { cn } from '@/lib/utils';
interface TimelineRulerProps {
durationBars: number;
pixelsPerBar: number;
className?: string;
}
export function TimelineRuler({
durationBars,
pixelsPerBar,
className,
}: TimelineRulerProps) {
const markers = useMemo(() => {
const items: { bar: number; isMajor: boolean }[] = [];
for (let bar = 0; bar <= durationBars; bar++) {
items.push({ bar, isMajor: bar % 4 === 0 });
}
return items;
}, [durationBars]);
return (
<div
className={cn('relative h-6 bg-muted/30 border-b border-border/50', className)}
style={{ width: durationBars * pixelsPerBar }}
>
{markers.map(({ bar, isMajor }) => (
<div
key={bar}
className="absolute top-0 flex flex-col items-center"
style={{ left: bar * pixelsPerBar }}
>
<div
className={cn(
'w-px',
isMajor ? 'h-4 bg-muted-foreground/60' : 'h-2 bg-muted-foreground/30'
)}
/>
{isMajor && (
<span className="text-[9px] font-mono text-muted-foreground/80 mt-0.5">
{bar + 1}
</span>
)}
</div>
))}
</div>
);
}

View File

@ -0,0 +1,16 @@
export { Timeline } from './Timeline';
export { TimelineRuler } from './TimelineRuler';
export { Playhead } from './Playhead';
export { TimeDisplay } from './TimeDisplay';
export { DurationSelector } from './DurationSelector';
export { LoopBracket } from './LoopBracket';
export { SectionTrack } from './SectionTrack';
export { SectionBlock } from './SectionBlock';
export { SectionPicker } from './SectionPicker';
export { KeyframeTrack } from './KeyframeTrack';
export { KeyframeMarker } from './KeyframeMarker';
export { PatternPicker } from './PatternPicker';
export { ProgressionPicker } from './ProgressionPicker';
export { MuteTrack } from './MuteTrack';
export { AutomationLane } from './AutomationLane';
export { ExportModal } from './ExportModal';

View File

@ -1,190 +0,0 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@ -1,49 +1,58 @@
'use client'; 'use client';
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { EngineState, LayerName, Genre } from '@/types/audio'; import { EngineState, LayerName, MeterLevels, LoopRegion } from '@/types/audio';
const defaultState: EngineState = { const defaultState: EngineState = {
isPlaying: false, isPlaying: false,
isInitialized: false, isInitialized: false,
bpm: 90, bpm: 78,
swing: 0.15, swing: 0.12,
currentStep: 0, currentStep: 0,
genre: 'hiphop',
duration: 3,
instruments: {
drums: 'acoustic',
bass: 'synth',
brass: 'trumpet',
piano: 'grand',
chords: 'pad',
ambient: 'rain',
},
volumes: { volumes: {
master: 0.8, master: 0.8,
drums: 0.8, drums: 0.8,
bass: 0.6,
brass: 0.4,
piano: 0.5,
chords: 0.6, chords: 0.6,
ambient: 0.4, ambient: 0.4,
}, },
muted: { muted: {
drums: false, drums: false,
bass: false,
brass: true,
piano: true,
chords: false, chords: false,
ambient: false, ambient: false,
}, },
}; };
const defaultMeterLevels: MeterLevels = {
drums: 0,
chords: 0,
ambient: 0,
master: 0,
};
const defaultPans: Record<LayerName, number> = {
drums: 0,
chords: 0,
ambient: 0,
};
const defaultSoloed: Record<LayerName, boolean> = {
drums: false,
chords: false,
ambient: false,
};
export function useAudioEngine() { export function useAudioEngine() {
const [state, setState] = useState<EngineState>(defaultState); const [state, setState] = useState<EngineState>(defaultState);
const [currentStep, setCurrentStep] = useState(0); const [currentStep, setCurrentStep] = useState(0);
const [meterLevels, setMeterLevels] = useState<MeterLevels>(defaultMeterLevels);
const [pans, setPans] = useState<Record<LayerName, number>>(defaultPans);
const [soloed, setSoloed] = useState<Record<LayerName, boolean>>(defaultSoloed);
const [playheadPosition, setPlayheadPosition] = useState({ bar: 0, beat: 0 });
const engineRef = useRef<typeof import('@/lib/audio/audioEngine').default | null>(null); const engineRef = useRef<typeof import('@/lib/audio/audioEngine').default | null>(null);
const isInitializingRef = useRef(false); const isInitializingRef = useRef(false);
// Dynamically import the audio engine (client-side only)
const getEngine = useCallback(async () => { const getEngine = useCallback(async () => {
if (typeof window === 'undefined') return null; if (typeof window === 'undefined') return null;
@ -54,6 +63,7 @@ export function useAudioEngine() {
return engineRef.current; return engineRef.current;
}, []); }, []);
// Initialize engine and set up callbacks
const initialize = useCallback(async () => { const initialize = useCallback(async () => {
if (isInitializingRef.current) return; if (isInitializingRef.current) return;
isInitializingRef.current = true; isInitializingRef.current = true;
@ -69,6 +79,12 @@ export function useAudioEngine() {
onStateChange: (newState) => { onStateChange: (newState) => {
setState(newState); setState(newState);
}, },
onBarChange: (bar, beat) => {
setPlayheadPosition({ bar, beat });
},
onMeterUpdate: (levels) => {
setMeterLevels(levels);
},
}); });
await engine.initialize(); await engine.initialize();
@ -78,6 +94,7 @@ export function useAudioEngine() {
} }
}, [getEngine]); }, [getEngine]);
// Play/pause toggle
const togglePlayback = useCallback(async () => { const togglePlayback = useCallback(async () => {
const engine = await getEngine(); const engine = await getEngine();
if (!engine) return; if (!engine) return;
@ -94,6 +111,7 @@ export function useAudioEngine() {
} }
}, [getEngine, state.isInitialized, initialize]); }, [getEngine, state.isInitialized, initialize]);
// Stop playback
const stop = useCallback(async () => { const stop = useCallback(async () => {
const engine = await getEngine(); const engine = await getEngine();
if (!engine) return; if (!engine) return;
@ -101,6 +119,7 @@ export function useAudioEngine() {
setCurrentStep(0); setCurrentStep(0);
}, [getEngine]); }, [getEngine]);
// Generate new beat
const generateNewBeat = useCallback(async () => { const generateNewBeat = useCallback(async () => {
const engine = await getEngine(); const engine = await getEngine();
if (!engine) return; if (!engine) return;
@ -112,84 +131,136 @@ export function useAudioEngine() {
engine.generateNewBeat(); engine.generateNewBeat();
}, [getEngine, state.isInitialized, initialize]); }, [getEngine, state.isInitialized, initialize]);
const setGenre = useCallback(async (genre: Genre) => { // Set BPM
const engine = await getEngine();
if (!engine) return;
if (!state.isInitialized) {
await initialize();
}
engine.setGenre(genre);
}, [getEngine, state.isInitialized, initialize]);
const setDuration = useCallback(async (minutes: number) => {
const engine = await getEngine();
if (!engine) return;
engine.setDuration(minutes);
}, [getEngine]);
const setInstrument = useCallback(async (layer: LayerName, instrument: string) => {
const engine = await getEngine();
if (!engine) return;
if (!state.isInitialized) {
await initialize();
}
engine.setInstrument(layer, instrument);
}, [getEngine, state.isInitialized, initialize]);
const setBpm = useCallback(async (bpm: number) => { const setBpm = useCallback(async (bpm: number) => {
const engine = await getEngine(); const engine = await getEngine();
if (!engine) return; if (!engine) return;
engine.setBpm(bpm); engine.setBpm(bpm);
}, [getEngine]); }, [getEngine]);
// Set swing
const setSwing = useCallback(async (swing: number) => { const setSwing = useCallback(async (swing: number) => {
const engine = await getEngine(); const engine = await getEngine();
if (!engine) return; if (!engine) return;
engine.setSwing(swing); engine.setSwing(swing);
}, [getEngine]); }, [getEngine]);
// Set master volume
const setMasterVolume = useCallback(async (volume: number) => { const setMasterVolume = useCallback(async (volume: number) => {
const engine = await getEngine(); const engine = await getEngine();
if (!engine) return; if (!engine) return;
engine.setMasterVolume(volume); engine.setMasterVolume(volume);
}, [getEngine]); }, [getEngine]);
// Set layer volume
const setLayerVolume = useCallback(async (layer: LayerName, volume: number) => { const setLayerVolume = useCallback(async (layer: LayerName, volume: number) => {
const engine = await getEngine(); const engine = await getEngine();
if (!engine) return; if (!engine) return;
engine.setLayerVolume(layer, volume); engine.setLayerVolume(layer, volume);
}, [getEngine]); }, [getEngine]);
// Toggle layer mute
const toggleMute = useCallback(async (layer: LayerName) => { const toggleMute = useCallback(async (layer: LayerName) => {
const engine = await getEngine(); const engine = await getEngine();
if (!engine) return; if (!engine) return;
engine.toggleMute(layer); engine.toggleMute(layer);
}, [getEngine]); }, [getEngine]);
// Set muted state directly
const setMuted = useCallback(async (layer: LayerName, muted: boolean) => {
const engine = await getEngine();
if (!engine) return;
engine.setMuted(layer, muted);
}, [getEngine]);
// Set pan
const setPan = useCallback(async (layer: LayerName, value: number) => {
const engine = await getEngine();
if (!engine) return;
engine.setPan(layer, value);
setPans((prev) => ({ ...prev, [layer]: value }));
}, [getEngine]);
// Set solo
const setSolo = useCallback(async (layer: LayerName, enabled: boolean) => {
const engine = await getEngine();
if (!engine) return;
engine.setSolo(layer, enabled);
setSoloed(engine.getSoloState());
}, [getEngine]);
// Toggle solo
const toggleSolo = useCallback(async (layer: LayerName) => {
const engine = await getEngine();
if (!engine) return;
const current = engine.getSoloState();
engine.setSolo(layer, !current[layer]);
setSoloed(engine.getSoloState());
}, [getEngine]);
// Seek to position
const seek = useCallback(async (bar: number, beat: number = 0) => {
const engine = await getEngine();
if (!engine) return;
engine.seek(bar, beat);
setPlayheadPosition({ bar, beat });
}, [getEngine]);
// Set duration
const setDuration = useCallback(async (bars: number) => {
const engine = await getEngine();
if (!engine) return;
engine.setDuration(bars);
}, [getEngine]);
// Set loop region
const setLoopRegion = useCallback(
async (start: number, end: number, enabled: boolean) => {
const engine = await getEngine();
if (!engine) return;
engine.setLoopRegion(start, end, enabled);
},
[getEngine]
);
// Get meter levels on demand
const getMeterLevels = useCallback(async (): Promise<MeterLevels> => {
const engine = await getEngine();
if (!engine) return defaultMeterLevels;
return engine.getMeterLevels();
}, [getEngine]);
// Cleanup on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
// Don't dispose on unmount // Don't dispose on unmount to allow seamless navigation
// The engine is a singleton that persists
}; };
}, []); }, []);
return { return {
state, state,
currentStep, currentStep,
meterLevels,
pans,
soloed,
playheadPosition,
initialize, initialize,
togglePlayback, togglePlayback,
stop, stop,
generateNewBeat, generateNewBeat,
setGenre,
setDuration,
setInstrument,
setBpm, setBpm,
setSwing, setSwing,
setMasterVolume, setMasterVolume,
setLayerVolume, setLayerVolume,
toggleMute, toggleMute,
setMuted,
setPan,
setSolo,
toggleSolo,
seek,
setDuration,
setLoopRegion,
getMeterLevels,
}; };
} }

277
hooks/useTimeline.ts Normal file
View File

@ -0,0 +1,277 @@
'use client';
import { useState, useCallback, useRef, useEffect } from 'react';
import {
TimelineState,
Section,
SectionType,
PatternKeyframe,
ChordKeyframe,
MuteKeyframe,
AutomationPoint,
LoopRegion,
LayerName,
SECTION_COLORS,
} from '@/types/audio';
function generateId(): string {
return Math.random().toString(36).substring(2, 9);
}
const defaultState: TimelineState = {
durationBars: 16,
playheadBar: 0,
playheadBeat: 0,
sections: [],
drumKeyframes: [{ id: generateId(), bar: 0, patternIndex: 0 }],
chordKeyframes: [{ id: generateId(), bar: 0, progressionIndex: 0 }],
volumeAutomation: {
drums: [],
chords: [],
ambient: [],
},
muteKeyframes: {
drums: [],
chords: [],
ambient: [],
},
loopRegion: { start: 0, end: 16, enabled: false },
};
export function useTimeline() {
const [state, setState] = useState<TimelineState>(defaultState);
const setDuration = useCallback((bars: number) => {
setState((prev) => ({
...prev,
durationBars: bars,
loopRegion: {
...prev.loopRegion,
end: Math.min(prev.loopRegion.end, bars),
},
}));
}, []);
const setPlayheadPosition = useCallback((bar: number, beat: number) => {
setState((prev) => ({
...prev,
playheadBar: bar,
playheadBeat: beat,
}));
}, []);
const setLoopRegion = useCallback((start: number, end: number, enabled: boolean) => {
setState((prev) => ({
...prev,
loopRegion: { start, end, enabled },
}));
}, []);
const addSection = useCallback(
(type: SectionType, startBar: number, endBar: number) => {
const newSection: Section = {
id: generateId(),
type,
name: type.charAt(0).toUpperCase() + type.slice(1),
startBar,
endBar,
color: SECTION_COLORS[type],
};
setState((prev) => ({
...prev,
sections: [...prev.sections, newSection],
}));
},
[]
);
const resizeSection = useCallback((id: string, startBar: number, endBar: number) => {
setState((prev) => ({
...prev,
sections: prev.sections.map((s) =>
s.id === id ? { ...s, startBar, endBar } : s
),
}));
}, []);
const moveSection = useCallback((id: string, startBar: number) => {
setState((prev) => ({
...prev,
sections: prev.sections.map((s) => {
if (s.id !== id) return s;
const duration = s.endBar - s.startBar;
return { ...s, startBar, endBar: startBar + duration };
}),
}));
}, []);
const deleteSection = useCallback((id: string) => {
setState((prev) => ({
...prev,
sections: prev.sections.filter((s) => s.id !== id),
}));
}, []);
const addDrumKeyframe = useCallback((bar: number, patternIndex: number) => {
const newKeyframe: PatternKeyframe = {
id: generateId(),
bar,
patternIndex,
};
setState((prev) => ({
...prev,
drumKeyframes: [...prev.drumKeyframes, newKeyframe],
}));
}, []);
const updateDrumKeyframe = useCallback((id: string, patternIndex: number) => {
setState((prev) => ({
...prev,
drumKeyframes: prev.drumKeyframes.map((kf) =>
kf.id === id ? { ...kf, patternIndex } : kf
),
}));
}, []);
const deleteDrumKeyframe = useCallback((id: string) => {
setState((prev) => ({
...prev,
drumKeyframes: prev.drumKeyframes.filter((kf) => kf.id !== id),
}));
}, []);
const addChordKeyframe = useCallback((bar: number, progressionIndex: number) => {
const newKeyframe: ChordKeyframe = {
id: generateId(),
bar,
progressionIndex,
};
setState((prev) => ({
...prev,
chordKeyframes: [...prev.chordKeyframes, newKeyframe],
}));
}, []);
const updateChordKeyframe = useCallback((id: string, progressionIndex: number) => {
setState((prev) => ({
...prev,
chordKeyframes: prev.chordKeyframes.map((kf) =>
kf.id === id ? { ...kf, progressionIndex } : kf
),
}));
}, []);
const deleteChordKeyframe = useCallback((id: string) => {
setState((prev) => ({
...prev,
chordKeyframes: prev.chordKeyframes.filter((kf) => kf.id !== id),
}));
}, []);
const toggleMuteKeyframe = useCallback((layer: LayerName, bar: number) => {
setState((prev) => {
const keyframes = prev.muteKeyframes[layer];
const existing = keyframes.find((kf) => kf.bar === bar);
if (existing) {
return {
...prev,
muteKeyframes: {
...prev.muteKeyframes,
[layer]: keyframes.map((kf) =>
kf.id === existing.id ? { ...kf, muted: !kf.muted } : kf
),
},
};
}
const sorted = [...keyframes].sort((a, b) => a.bar - b.bar);
const prevKeyframe = sorted.filter((kf) => kf.bar < bar).pop();
const wasMuted = prevKeyframe?.muted ?? false;
const newKeyframe: MuteKeyframe = {
id: generateId(),
bar,
muted: !wasMuted,
};
return {
...prev,
muteKeyframes: {
...prev.muteKeyframes,
[layer]: [...keyframes, newKeyframe],
},
};
});
}, []);
const addAutomationPoint = useCallback(
(layer: LayerName, bar: number, beat: number, value: number) => {
const newPoint: AutomationPoint = {
id: generateId(),
bar,
beat,
value,
};
setState((prev) => ({
...prev,
volumeAutomation: {
...prev.volumeAutomation,
[layer]: [...prev.volumeAutomation[layer], newPoint],
},
}));
},
[]
);
const updateAutomationPoint = useCallback(
(layer: LayerName, id: string, bar: number, beat: number, value: number) => {
setState((prev) => ({
...prev,
volumeAutomation: {
...prev.volumeAutomation,
[layer]: prev.volumeAutomation[layer].map((p) =>
p.id === id ? { ...p, bar, beat, value } : p
),
},
}));
},
[]
);
const deleteAutomationPoint = useCallback((layer: LayerName, id: string) => {
setState((prev) => ({
...prev,
volumeAutomation: {
...prev.volumeAutomation,
[layer]: prev.volumeAutomation[layer].filter((p) => p.id !== id),
},
}));
}, []);
const resetTimeline = useCallback(() => {
setState(defaultState);
}, []);
return {
state,
setDuration,
setPlayheadPosition,
setLoopRegion,
addSection,
resizeSection,
moveSection,
deleteSection,
addDrumKeyframe,
updateDrumKeyframe,
deleteDrumKeyframe,
addChordKeyframe,
updateChordKeyframe,
deleteChordKeyframe,
toggleMuteKeyframe,
addAutomationPoint,
updateAutomationPoint,
deleteAutomationPoint,
resetTimeline,
};
}

View File

@ -1,119 +1,68 @@
import * as Tone from 'tone'; import * as Tone from 'tone';
import { AmbientType } from '@/types/audio';
export class AmbientLayer { export class AmbientLayer {
private noise1: Tone.Noise | null = null; private rainNoise: Tone.Noise;
private noise2: Tone.Noise | null = null; private vinylNoise: Tone.Noise;
private filter1: Tone.Filter | null = null; private rainFilter: Tone.Filter;
private filter2: Tone.Filter | null = null; private vinylFilter: Tone.Filter;
private gain1: Tone.Gain | null = null; private rainGain: Tone.Gain;
private gain2: Tone.Gain | null = null; private vinylGain: Tone.Gain;
private output: Tone.Gain; private output: Tone.Gain;
private lfo: Tone.LFO | null = null; private lfo: Tone.LFO;
private currentType: AmbientType = 'rain';
private isPlaying = false;
constructor(destination: Tone.InputNode) { constructor(destination: Tone.InputNode) {
this.output = new Tone.Gain(0.4); this.output = new Tone.Gain(0.4);
this.output.connect(destination);
this.createAmbient('rain');
}
private createAmbient(type: AmbientType): void { // Rain sound - filtered pink noise
// Dispose existing this.rainNoise = new Tone.Noise('pink');
this.noise1?.dispose(); this.rainFilter = new Tone.Filter({
this.noise2?.dispose(); frequency: 3000,
this.filter1?.dispose(); type: 'lowpass',
this.filter2?.dispose(); rolloff: -24,
this.gain1?.dispose();
this.gain2?.dispose();
this.lfo?.dispose();
const configs: Record<AmbientType, {
noise1: { type: 'white' | 'pink' | 'brown'; filter: number; filterType: BiquadFilterType; gain: number };
noise2: { type: 'white' | 'pink' | 'brown'; filter: number; filterType: BiquadFilterType; gain: number };
lfoFreq: number;
}> = {
rain: {
noise1: { type: 'pink', filter: 3000, filterType: 'lowpass', gain: 0.15 },
noise2: { type: 'brown', filter: 1500, filterType: 'bandpass', gain: 0.1 },
lfoFreq: 0.1,
},
vinyl: {
noise1: { type: 'brown', filter: 2000, filterType: 'bandpass', gain: 0.12 },
noise2: { type: 'white', filter: 800, filterType: 'lowpass', gain: 0.05 },
lfoFreq: 0.05,
},
nature: {
noise1: { type: 'pink', filter: 4000, filterType: 'lowpass', gain: 0.1 },
noise2: { type: 'brown', filter: 500, filterType: 'lowpass', gain: 0.08 },
lfoFreq: 0.08,
},
space: {
noise1: { type: 'pink', filter: 1000, filterType: 'lowpass', gain: 0.12 },
noise2: { type: 'brown', filter: 300, filterType: 'lowpass', gain: 0.1 },
lfoFreq: 0.02,
},
};
const config = configs[type];
// Primary noise layer
this.noise1 = new Tone.Noise(config.noise1.type);
this.filter1 = new Tone.Filter({
frequency: config.noise1.filter,
type: config.noise1.filterType,
}); });
this.gain1 = new Tone.Gain(config.noise1.gain); this.rainGain = new Tone.Gain(0.15);
this.noise1.connect(this.filter1);
this.filter1.connect(this.gain1);
this.gain1.connect(this.output);
// Secondary noise layer // Vinyl crackle - filtered brown noise with modulation
this.noise2 = new Tone.Noise(config.noise2.type); this.vinylNoise = new Tone.Noise('brown');
this.filter2 = new Tone.Filter({ this.vinylFilter = new Tone.Filter({
frequency: config.noise2.filter, frequency: 1500,
type: config.noise2.filterType, type: 'bandpass',
Q: 2,
}); });
this.gain2 = new Tone.Gain(config.noise2.gain); this.vinylGain = new Tone.Gain(0.1);
this.noise2.connect(this.filter2);
this.filter2.connect(this.gain2);
this.gain2.connect(this.output);
// LFO for subtle variation // LFO for subtle rain intensity variation
this.lfo = new Tone.LFO({ this.lfo = new Tone.LFO({
frequency: config.lfoFreq, frequency: 0.1,
min: config.noise1.gain * 0.7, min: 0.1,
max: config.noise1.gain * 1.2, max: 0.2,
}); });
this.lfo.connect(this.gain1.gain); this.lfo.connect(this.rainGain.gain);
this.currentType = type; // Chain rain: noise -> filter -> gain -> output
this.rainNoise.connect(this.rainFilter);
this.rainFilter.connect(this.rainGain);
this.rainGain.connect(this.output);
// Restart if was playing // Chain vinyl: noise -> filter -> gain -> output
if (this.isPlaying) { this.vinylNoise.connect(this.vinylFilter);
this.noise1.start(); this.vinylFilter.connect(this.vinylGain);
this.noise2.start(); this.vinylGain.connect(this.output);
this.lfo.start();
}
}
setInstrument(type: AmbientType): void { // Output to destination
this.createAmbient(type); this.output.connect(destination);
} }
start(): void { start(): void {
this.noise1?.start(); this.rainNoise.start();
this.noise2?.start(); this.vinylNoise.start();
this.lfo?.start(); this.lfo.start();
this.isPlaying = true;
} }
stop(): void { stop(): void {
this.noise1?.stop(); this.rainNoise.stop();
this.noise2?.stop(); this.vinylNoise.stop();
this.lfo?.stop(); this.lfo.stop();
this.isPlaying = false;
} }
setVolume(volume: number): void { setVolume(volume: number): void {
@ -124,15 +73,25 @@ export class AmbientLayer {
this.output.gain.rampTo(muted ? 0 : 0.4, 0.1); 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 { dispose(): void {
this.stop(); this.rainNoise.stop();
this.noise1?.dispose(); this.vinylNoise.stop();
this.noise2?.dispose(); this.lfo.stop();
this.filter1?.dispose(); this.rainNoise.dispose();
this.filter2?.dispose(); this.vinylNoise.dispose();
this.gain1?.dispose(); this.rainFilter.dispose();
this.gain2?.dispose(); this.vinylFilter.dispose();
this.lfo?.dispose(); this.rainGain.dispose();
this.vinylGain.dispose();
this.lfo.dispose();
this.output.dispose(); this.output.dispose();
} }
} }

View File

@ -2,21 +2,12 @@ import * as Tone from 'tone';
import { DrumMachine } from './drumMachine'; import { DrumMachine } from './drumMachine';
import { ChordEngine } from './chordEngine'; import { ChordEngine } from './chordEngine';
import { AmbientLayer } from './ambientLayer'; import { AmbientLayer } from './ambientLayer';
import { BassEngine } from './bassEngine';
import { BrassEngine } from './brassEngine';
import { PianoEngine } from './pianoEngine';
import { import {
EngineState, EngineState,
AudioEngineCallbacks, AudioEngineCallbacks,
LayerName, LayerName,
Genre, MeterLevels,
GENRE_CONFIG, LoopRegion,
DrumKit,
BassType,
BrassType,
PianoType,
ChordType,
AmbientType,
} from '@/types/audio'; } from '@/types/audio';
class AudioEngine { class AudioEngine {
@ -25,47 +16,55 @@ class AudioEngine {
private drumMachine: DrumMachine | null = null; private drumMachine: DrumMachine | null = null;
private chordEngine: ChordEngine | null = null; private chordEngine: ChordEngine | null = null;
private ambientLayer: AmbientLayer | null = null; private ambientLayer: AmbientLayer | null = null;
private bassEngine: BassEngine | null = null;
private brassEngine: BrassEngine | null = null;
private pianoEngine: PianoEngine | null = null;
private masterGain: Tone.Gain | null = null; private masterGain: Tone.Gain | null = null;
private masterCompressor: Tone.Compressor | null = null; private masterCompressor: Tone.Compressor | null = null;
private masterLimiter: Tone.Limiter | null = null; private masterLimiter: Tone.Limiter | null = null;
private masterReverb: Tone.Reverb | null = null; private masterReverb: Tone.Reverb | null = null;
// Meters for level detection
private drumMeter: Tone.Meter | null = null;
private chordMeter: Tone.Meter | null = null;
private ambientMeter: Tone.Meter | null = null;
private masterMeter: Tone.Meter | null = null;
// Panners for stereo positioning
private drumPanner: Tone.Panner | null = null;
private chordPanner: Tone.Panner | null = null;
private ambientPanner: Tone.Panner | null = null;
// Solo state tracking
private soloState: Record<LayerName, boolean> = {
drums: false,
chords: false,
ambient: false,
};
// Pre-solo volume states for restoration
private preSoloMuted: Record<LayerName, boolean> | null = null;
// Timeline state
private durationBars: number = 16;
private loopRegion: LoopRegion = { start: 0, end: 16, enabled: false };
private meterAnimationId: number | null = null;
private barTrackerId: number | null = null;
private callbacks: AudioEngineCallbacks = {}; private callbacks: AudioEngineCallbacks = {};
private state: EngineState = { private state: EngineState = {
isPlaying: false, isPlaying: false,
isInitialized: false, isInitialized: false,
bpm: 90, bpm: 78,
swing: 0.15, swing: 0.12,
currentStep: 0, currentStep: 0,
genre: 'hiphop',
duration: 3,
instruments: {
drums: 'acoustic',
bass: 'synth',
brass: 'trumpet',
piano: 'grand',
chords: 'pad',
ambient: 'rain',
},
volumes: { volumes: {
master: 0.8, master: 0.8,
drums: 0.8, drums: 0.8,
bass: 0.6,
brass: 0.4,
piano: 0.5,
chords: 0.6, chords: 0.6,
ambient: 0.4, ambient: 0.4,
}, },
muted: { muted: {
drums: false, drums: false,
bass: false,
brass: true,
piano: true,
chords: false, chords: false,
ambient: false, ambient: false,
}, },
@ -83,13 +82,25 @@ class AudioEngine {
async initialize(): Promise<void> { async initialize(): Promise<void> {
if (this.state.isInitialized) return; if (this.state.isInitialized) return;
// Start Tone.js audio context (requires user gesture)
await Tone.start(); await Tone.start();
const genreConfig = GENRE_CONFIG[this.state.genre]; // Set up transport
Tone.getTransport().bpm.value = genreConfig.bpm; Tone.getTransport().bpm.value = this.state.bpm;
Tone.getTransport().swing = genreConfig.swing; Tone.getTransport().swing = this.state.swing;
Tone.getTransport().swingSubdivision = '16n'; Tone.getTransport().swingSubdivision = '16n';
// Create meters
this.drumMeter = new Tone.Meter({ smoothing: 0.8 });
this.chordMeter = new Tone.Meter({ smoothing: 0.8 });
this.ambientMeter = new Tone.Meter({ smoothing: 0.8 });
this.masterMeter = new Tone.Meter({ smoothing: 0.8 });
// Create panners
this.drumPanner = new Tone.Panner(0);
this.chordPanner = new Tone.Panner(0);
this.ambientPanner = new Tone.Panner(0);
// Master chain // Master chain
this.masterGain = new Tone.Gain(this.state.volumes.master); this.masterGain = new Tone.Gain(this.state.volumes.master);
this.masterCompressor = new Tone.Compressor({ this.masterCompressor = new Tone.Compressor({
@ -104,29 +115,27 @@ class AudioEngine {
wet: 0.15, wet: 0.15,
}); });
// Chain: gain -> reverb -> compressor -> limiter -> meter -> destination
this.masterGain.connect(this.masterReverb); this.masterGain.connect(this.masterReverb);
this.masterReverb.connect(this.masterCompressor); this.masterReverb.connect(this.masterCompressor);
this.masterCompressor.connect(this.masterLimiter); this.masterCompressor.connect(this.masterLimiter);
this.masterLimiter.toDestination(); this.masterLimiter.connect(this.masterMeter);
this.masterMeter.toDestination();
// Initialize all layers // Initialize layers with panners and meters in chain
this.drumMachine = new DrumMachine(this.masterGain); this.drumMachine = new DrumMachine(this.drumPanner);
this.chordEngine = new ChordEngine(this.masterGain); this.chordEngine = new ChordEngine(this.chordPanner);
this.ambientLayer = new AmbientLayer(this.masterGain); this.ambientLayer = new AmbientLayer(this.ambientPanner);
this.bassEngine = new BassEngine(this.masterGain);
this.brassEngine = new BrassEngine(this.masterGain);
this.pianoEngine = new PianoEngine(this.masterGain);
// Set initial genre for all engines // Connect panners -> meters -> master
this.drumMachine.setGenre(this.state.genre); this.drumPanner.connect(this.drumMeter);
this.chordEngine.setGenre(this.state.genre); this.drumMeter.connect(this.masterGain);
this.bassEngine.setGenre(this.state.genre);
this.brassEngine.setGenre(this.state.genre);
this.pianoEngine.setGenre(this.state.genre);
// Apply initial mute states this.chordPanner.connect(this.chordMeter);
this.brassEngine.mute(this.state.muted.brass); this.chordMeter.connect(this.masterGain);
this.pianoEngine.mute(this.state.muted.piano);
this.ambientPanner.connect(this.ambientMeter);
this.ambientMeter.connect(this.masterGain);
// Create sequences // Create sequences
this.drumMachine.createSequence((step) => { this.drumMachine.createSequence((step) => {
@ -134,12 +143,13 @@ class AudioEngine {
this.callbacks.onStepChange?.(step); this.callbacks.onStepChange?.(step);
}); });
this.chordEngine.createSequence(); this.chordEngine.createSequence();
this.bassEngine.createSequence();
this.brassEngine.createSequence();
this.pianoEngine.createSequence();
this.state.bpm = genreConfig.bpm; // Start meter animation loop
this.state.swing = genreConfig.swing; this.startMeterLoop();
// Start bar tracking
this.startBarTracking();
this.state.isInitialized = true; this.state.isInitialized = true;
this.notifyStateChange(); this.notifyStateChange();
} }
@ -181,69 +191,11 @@ class AudioEngine {
generateNewBeat(): void { generateNewBeat(): void {
this.drumMachine?.randomize(); this.drumMachine?.randomize();
this.chordEngine?.randomize(); this.chordEngine?.randomize();
this.bassEngine?.randomize();
this.brassEngine?.randomize();
this.pianoEngine?.randomize();
this.notifyStateChange();
}
setGenre(genre: Genre): void {
this.state.genre = genre;
const genreConfig = GENRE_CONFIG[genre];
// Update BPM and swing for genre
this.state.bpm = genreConfig.bpm;
this.state.swing = genreConfig.swing;
Tone.getTransport().bpm.value = genreConfig.bpm;
Tone.getTransport().swing = genreConfig.swing;
// Update all engines with new genre
this.drumMachine?.setGenre(genre);
this.chordEngine?.setGenre(genre);
this.bassEngine?.setGenre(genre);
this.brassEngine?.setGenre(genre);
this.pianoEngine?.setGenre(genre);
this.notifyStateChange();
}
setDuration(minutes: number): void {
this.state.duration = Math.max(1, Math.min(10, minutes));
this.notifyStateChange();
}
setInstrument(layer: LayerName, instrument: string): void {
switch (layer) {
case 'drums':
this.state.instruments.drums = instrument as DrumKit;
this.drumMachine?.setInstrument(instrument as DrumKit);
break;
case 'bass':
this.state.instruments.bass = instrument as BassType;
this.bassEngine?.setInstrument(instrument as BassType);
break;
case 'brass':
this.state.instruments.brass = instrument as BrassType;
this.brassEngine?.setInstrument(instrument as BrassType);
break;
case 'piano':
this.state.instruments.piano = instrument as PianoType;
this.pianoEngine?.setInstrument(instrument as PianoType);
break;
case 'chords':
this.state.instruments.chords = instrument as ChordType;
this.chordEngine?.setInstrument(instrument as ChordType);
break;
case 'ambient':
this.state.instruments.ambient = instrument as AmbientType;
this.ambientLayer?.setInstrument(instrument as AmbientType);
break;
}
this.notifyStateChange(); this.notifyStateChange();
} }
setBpm(bpm: number): void { setBpm(bpm: number): void {
this.state.bpm = Math.max(60, Math.min(180, bpm)); this.state.bpm = Math.max(60, Math.min(100, bpm));
Tone.getTransport().bpm.value = this.state.bpm; Tone.getTransport().bpm.value = this.state.bpm;
this.notifyStateChange(); this.notifyStateChange();
} }
@ -268,15 +220,6 @@ class AudioEngine {
case 'drums': case 'drums':
this.drumMachine?.setVolume(normalizedVolume); this.drumMachine?.setVolume(normalizedVolume);
break; break;
case 'bass':
this.bassEngine?.setVolume(normalizedVolume);
break;
case 'brass':
this.brassEngine?.setVolume(normalizedVolume);
break;
case 'piano':
this.pianoEngine?.setVolume(normalizedVolume);
break;
case 'chords': case 'chords':
this.chordEngine?.setVolume(normalizedVolume); this.chordEngine?.setVolume(normalizedVolume);
break; break;
@ -295,15 +238,6 @@ class AudioEngine {
case 'drums': case 'drums':
this.drumMachine?.mute(this.state.muted[layer]); this.drumMachine?.mute(this.state.muted[layer]);
break; break;
case 'bass':
this.bassEngine?.mute(this.state.muted[layer]);
break;
case 'brass':
this.brassEngine?.mute(this.state.muted[layer]);
break;
case 'piano':
this.pianoEngine?.mute(this.state.muted[layer]);
break;
case 'chords': case 'chords':
this.chordEngine?.mute(this.state.muted[layer]); this.chordEngine?.mute(this.state.muted[layer]);
break; break;
@ -322,15 +256,6 @@ class AudioEngine {
case 'drums': case 'drums':
this.drumMachine?.mute(muted); this.drumMachine?.mute(muted);
break; break;
case 'bass':
this.bassEngine?.mute(muted);
break;
case 'brass':
this.brassEngine?.mute(muted);
break;
case 'piano':
this.pianoEngine?.mute(muted);
break;
case 'chords': case 'chords':
this.chordEngine?.mute(muted); this.chordEngine?.mute(muted);
break; break;
@ -346,29 +271,195 @@ class AudioEngine {
return { ...this.state }; return { ...this.state };
} }
// Meter methods
private startMeterLoop(): void {
const updateMeters = () => {
if (this.state.isPlaying && this.callbacks.onMeterUpdate) {
this.callbacks.onMeterUpdate(this.getMeterLevels());
}
this.meterAnimationId = requestAnimationFrame(updateMeters);
};
this.meterAnimationId = requestAnimationFrame(updateMeters);
}
private stopMeterLoop(): void {
if (this.meterAnimationId !== null) {
cancelAnimationFrame(this.meterAnimationId);
this.meterAnimationId = null;
}
}
getMeterLevels(): MeterLevels {
const normalize = (val: number | number[]): number => {
const v = typeof val === 'number' ? val : val[0] ?? -Infinity;
const db = Math.max(-60, Math.min(0, v));
return (db + 60) / 60;
};
return {
drums: normalize(this.drumMeter?.getValue() ?? -Infinity),
chords: normalize(this.chordMeter?.getValue() ?? -Infinity),
ambient: normalize(this.ambientMeter?.getValue() ?? -Infinity),
master: normalize(this.masterMeter?.getValue() ?? -Infinity),
};
}
// Panner methods
setPan(layer: LayerName, value: number): void {
const normalizedPan = Math.max(-1, Math.min(1, value));
switch (layer) {
case 'drums':
this.drumPanner?.pan.rampTo(normalizedPan, 0.1);
break;
case 'chords':
this.chordPanner?.pan.rampTo(normalizedPan, 0.1);
break;
case 'ambient':
this.ambientPanner?.pan.rampTo(normalizedPan, 0.1);
break;
}
}
getPan(layer: LayerName): number {
switch (layer) {
case 'drums':
return this.drumPanner?.pan.value ?? 0;
case 'chords':
return this.chordPanner?.pan.value ?? 0;
case 'ambient':
return this.ambientPanner?.pan.value ?? 0;
}
}
// Solo methods
setSolo(layer: LayerName, enabled: boolean): void {
const hadAnySolo = Object.values(this.soloState).some(Boolean);
this.soloState[layer] = enabled;
const hasAnySolo = Object.values(this.soloState).some(Boolean);
if (!hadAnySolo && hasAnySolo) {
this.preSoloMuted = { ...this.state.muted };
}
if (hasAnySolo) {
const layers: LayerName[] = ['drums', 'chords', 'ambient'];
layers.forEach((l) => {
const shouldMute = !this.soloState[l];
this.setMuted(l, shouldMute);
});
} else if (hadAnySolo && !hasAnySolo && this.preSoloMuted) {
const layers: LayerName[] = ['drums', 'chords', 'ambient'];
layers.forEach((l) => {
this.setMuted(l, this.preSoloMuted![l]);
});
this.preSoloMuted = null;
}
}
getSoloState(): Record<LayerName, boolean> {
return { ...this.soloState };
}
// Timeline methods
private startBarTracking(): void {
if (this.barTrackerId !== null) {
Tone.getTransport().clear(this.barTrackerId);
}
this.barTrackerId = Tone.getTransport().scheduleRepeat((time) => {
const position = Tone.getTransport().position;
const [bars, beats] = String(position).split(':').map(Number);
Tone.getDraw().schedule(() => {
this.callbacks.onBarChange?.(bars, beats);
}, time);
}, '4n');
}
seek(bar: number, beat: number = 0): void {
const position = `${bar}:${beat}:0`;
Tone.getTransport().position = position;
this.callbacks.onBarChange?.(bar, beat);
}
setDuration(bars: number): void {
this.durationBars = Math.max(4, Math.min(128, bars));
}
getDuration(): number {
return this.durationBars;
}
setLoopRegion(start: number, end: number, enabled: boolean): void {
this.loopRegion = { start, end, enabled };
if (enabled) {
Tone.getTransport().setLoopPoints(`${start}:0:0`, `${end}:0:0`);
Tone.getTransport().loop = true;
} else {
Tone.getTransport().loop = false;
}
}
getLoopRegion(): LoopRegion {
return { ...this.loopRegion };
}
getPlaybackPosition(): { bar: number; beat: number; sixteenth: number } {
const position = String(Tone.getTransport().position);
const [bars, beats, sixteenths] = position.split(':').map(Number);
return { bar: bars || 0, beat: beats || 0, sixteenth: sixteenths || 0 };
}
// Pattern access methods
getDrumMachine(): DrumMachine | null {
return this.drumMachine;
}
getChordEngine(): ChordEngine | null {
return this.chordEngine;
}
dispose(): void { dispose(): void {
this.stop(); this.stop();
this.stopMeterLoop();
if (this.barTrackerId !== null) {
Tone.getTransport().clear(this.barTrackerId);
this.barTrackerId = null;
}
this.drumMachine?.dispose(); this.drumMachine?.dispose();
this.chordEngine?.dispose(); this.chordEngine?.dispose();
this.ambientLayer?.dispose(); this.ambientLayer?.dispose();
this.bassEngine?.dispose();
this.brassEngine?.dispose();
this.pianoEngine?.dispose();
this.masterGain?.dispose(); this.masterGain?.dispose();
this.masterCompressor?.dispose(); this.masterCompressor?.dispose();
this.masterLimiter?.dispose(); this.masterLimiter?.dispose();
this.masterReverb?.dispose(); this.masterReverb?.dispose();
// Dispose new components
this.drumMeter?.dispose();
this.chordMeter?.dispose();
this.ambientMeter?.dispose();
this.masterMeter?.dispose();
this.drumPanner?.dispose();
this.chordPanner?.dispose();
this.ambientPanner?.dispose();
this.drumMachine = null; this.drumMachine = null;
this.chordEngine = null; this.chordEngine = null;
this.ambientLayer = null; this.ambientLayer = null;
this.bassEngine = null;
this.brassEngine = null;
this.pianoEngine = null;
this.masterGain = null; this.masterGain = null;
this.masterCompressor = null; this.masterCompressor = null;
this.masterLimiter = null; this.masterLimiter = null;
this.masterReverb = null; this.masterReverb = null;
this.drumMeter = null;
this.chordMeter = null;
this.ambientMeter = null;
this.masterMeter = null;
this.drumPanner = null;
this.chordPanner = null;
this.ambientPanner = null;
this.state.isInitialized = false; this.state.isInitialized = false;
this.state.isPlaying = false; this.state.isPlaying = false;

View File

@ -1,117 +0,0 @@
import * as Tone from 'tone';
import { BassType, Genre } from '@/types/audio';
import { getRandomBassPattern } from './patterns';
export class BassEngine {
private synth: Tone.MonoSynth | null = null;
private sequence: Tone.Sequence | null = null;
private pattern: (string | null)[];
private output: Tone.Gain;
private filter: Tone.Filter;
private currentType: BassType = 'synth';
private genre: Genre = 'hiphop';
constructor(destination: Tone.InputNode) {
this.output = new Tone.Gain(0.6);
this.filter = new Tone.Filter({
frequency: 800,
type: 'lowpass',
rolloff: -24,
});
this.filter.connect(this.output);
this.output.connect(destination);
this.pattern = getRandomBassPattern(this.genre);
this.createSynth('synth');
}
private createSynth(type: BassType): void {
if (this.synth) {
this.synth.dispose();
}
const configs: Record<BassType, Partial<Tone.MonoSynthOptions>> = {
synth: {
oscillator: { type: 'sawtooth' },
envelope: { attack: 0.01, decay: 0.3, sustain: 0.4, release: 0.2 },
filterEnvelope: { attack: 0.01, decay: 0.2, sustain: 0.5, release: 0.2, baseFrequency: 200, octaves: 2 },
},
sub: {
oscillator: { type: 'sine' },
envelope: { attack: 0.02, decay: 0.5, sustain: 0.8, release: 0.4 },
filterEnvelope: { attack: 0.01, decay: 0.1, sustain: 1, release: 0.1, baseFrequency: 100, octaves: 1 },
},
electric: {
oscillator: { type: 'triangle' },
envelope: { attack: 0.005, decay: 0.2, sustain: 0.3, release: 0.15 },
filterEnvelope: { attack: 0.01, decay: 0.15, sustain: 0.4, release: 0.2, baseFrequency: 300, octaves: 2.5 },
},
upright: {
oscillator: { type: 'triangle' },
envelope: { attack: 0.02, decay: 0.4, sustain: 0.2, release: 0.3 },
filterEnvelope: { attack: 0.02, decay: 0.3, sustain: 0.3, release: 0.3, baseFrequency: 250, octaves: 1.5 },
},
};
this.synth = new Tone.MonoSynth(configs[type]);
this.synth.volume.value = -6;
this.synth.connect(this.filter);
this.currentType = type;
}
setInstrument(type: BassType): void {
this.createSynth(type);
}
setGenre(genre: Genre): void {
this.genre = genre;
this.pattern = getRandomBassPattern(genre);
if (this.sequence) {
this.createSequence();
}
}
createSequence(): void {
if (this.sequence) {
this.sequence.dispose();
}
const steps = Array.from({ length: 16 }, (_, i) => i);
this.sequence = new Tone.Sequence(
(time, step) => {
const note = this.pattern[step];
if (note && this.synth) {
this.synth.triggerAttackRelease(note, '8n', time, 0.7);
}
},
steps,
'16n'
);
this.sequence.start(0);
}
randomize(): void {
this.pattern = getRandomBassPattern(this.genre);
if (this.sequence) {
this.createSequence();
}
}
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);
}
dispose(): void {
this.sequence?.dispose();
this.synth?.dispose();
this.filter.dispose();
this.output.dispose();
}
}

View File

@ -1,120 +0,0 @@
import * as Tone from 'tone';
import { BrassType, Genre } from '@/types/audio';
import { getRandomBrassPattern } from './patterns';
export class BrassEngine {
private synth: Tone.Synth | null = null;
private sequence: Tone.Sequence | null = null;
private pattern: (string | null)[];
private output: Tone.Gain;
private filter: Tone.Filter;
private reverb: Tone.Reverb;
private currentType: BrassType = 'trumpet';
private genre: Genre = 'hiphop';
constructor(destination: Tone.InputNode) {
this.output = new Tone.Gain(0.4);
this.filter = new Tone.Filter({
frequency: 3000,
type: 'lowpass',
rolloff: -12,
});
this.reverb = new Tone.Reverb({
decay: 2,
wet: 0.3,
});
this.filter.connect(this.reverb);
this.reverb.connect(this.output);
this.output.connect(destination);
this.pattern = getRandomBrassPattern(this.genre);
this.createSynth('trumpet');
}
private createSynth(type: BrassType): void {
if (this.synth) {
this.synth.dispose();
}
const configs: Record<BrassType, Partial<Tone.SynthOptions>> = {
trumpet: {
oscillator: { type: 'sawtooth' },
envelope: { attack: 0.05, decay: 0.2, sustain: 0.6, release: 0.3 },
},
horn: {
oscillator: { type: 'sine' },
envelope: { attack: 0.1, decay: 0.3, sustain: 0.7, release: 0.5 },
},
'synth-brass': {
oscillator: { type: 'fatsawtooth', spread: 20, count: 3 } as Tone.OmniOscillatorOptions,
envelope: { attack: 0.02, decay: 0.3, sustain: 0.5, release: 0.2 },
},
orchestra: {
oscillator: { type: 'sawtooth' },
envelope: { attack: 0.15, decay: 0.4, sustain: 0.8, release: 0.6 },
},
};
this.synth = new Tone.Synth(configs[type]);
this.synth.volume.value = -8;
this.synth.connect(this.filter);
this.currentType = type;
}
setInstrument(type: BrassType): void {
this.createSynth(type);
}
setGenre(genre: Genre): void {
this.genre = genre;
this.pattern = getRandomBrassPattern(genre);
if (this.sequence) {
this.createSequence();
}
}
createSequence(): void {
if (this.sequence) {
this.sequence.dispose();
}
const steps = Array.from({ length: 16 }, (_, i) => i);
this.sequence = new Tone.Sequence(
(time, step) => {
const note = this.pattern[step];
if (note && this.synth) {
this.synth.triggerAttackRelease(note, '8n', time, 0.6);
}
},
steps,
'16n'
);
this.sequence.start(0);
}
randomize(): void {
this.pattern = getRandomBrassPattern(this.genre);
if (this.sequence) {
this.createSequence();
}
}
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);
}
dispose(): void {
this.sequence?.dispose();
this.synth?.dispose();
this.filter.dispose();
this.reverb.dispose();
this.output.dispose();
}
}

View File

@ -1,32 +1,33 @@
import * as Tone from 'tone'; import * as Tone from 'tone';
import { ChordProgression, ChordType, Genre } from '@/types/audio'; import { ChordProgression } from '@/types/audio';
import { getRandomProgression } from './patterns'; import { getRandomProgression } from './patterns';
export class ChordEngine { export class ChordEngine {
private synth: Tone.PolySynth | null = null; private synth: Tone.PolySynth;
private sequence: Tone.Sequence | null = null; private sequence: Tone.Sequence | null = null;
private progression: ChordProgression; private progression: ChordProgression;
private output: Tone.Gain; private output: Tone.Gain;
private filter: Tone.Filter; private filter: Tone.Filter;
private reverb: Tone.Reverb; private reverb: Tone.Reverb;
private chorus: Tone.Chorus; private chorus: Tone.Chorus;
private currentType: ChordType = 'pad';
private genre: Genre = 'hiphop';
constructor(destination: Tone.InputNode) { constructor(destination: Tone.InputNode) {
this.output = new Tone.Gain(0.6); this.output = new Tone.Gain(0.6);
// Warm lofi filter
this.filter = new Tone.Filter({ this.filter = new Tone.Filter({
frequency: 2000, frequency: 2000,
type: 'lowpass', type: 'lowpass',
rolloff: -24, rolloff: -24,
}); });
// Dreamy reverb
this.reverb = new Tone.Reverb({ this.reverb = new Tone.Reverb({
decay: 3, decay: 3,
wet: 0.4, wet: 0.4,
}); });
// Subtle chorus for width
this.chorus = new Tone.Chorus({ this.chorus = new Tone.Chorus({
frequency: 0.5, frequency: 0.5,
delayTime: 3.5, delayTime: 3.5,
@ -34,73 +35,42 @@ export class ChordEngine {
wet: 0.3, wet: 0.3,
}).start(); }).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.filter.connect(this.chorus);
this.chorus.connect(this.reverb); this.chorus.connect(this.reverb);
this.reverb.connect(this.output); this.reverb.connect(this.output);
this.output.connect(destination); this.output.connect(destination);
this.progression = getRandomProgression(this.genre); // Initialize with a random progression
this.createSynth('pad'); this.progression = getRandomProgression();
}
private createSynth(type: ChordType): void {
if (this.synth) {
this.synth.dispose();
}
const configs: Record<ChordType, { synth: typeof Tone.Synth | typeof Tone.FMSynth; options: object }> = {
pad: {
synth: Tone.FMSynth,
options: {
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 },
},
},
strings: {
synth: Tone.Synth,
options: {
oscillator: { type: 'sawtooth' },
envelope: { attack: 0.4, decay: 0.3, sustain: 0.9, release: 2 },
},
},
organ: {
synth: Tone.Synth,
options: {
oscillator: { type: 'sine' },
envelope: { attack: 0.01, decay: 0.1, sustain: 0.9, release: 0.3 },
},
},
synth: {
synth: Tone.Synth,
options: {
oscillator: { type: 'fatsawtooth', spread: 30, count: 3 },
envelope: { attack: 0.1, decay: 0.2, sustain: 0.6, release: 0.8 },
},
},
};
const config = configs[type];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.synth = new Tone.PolySynth(config.synth as any, config.options);
this.synth.volume.value = -12;
this.synth.connect(this.filter);
this.currentType = type;
}
setInstrument(type: ChordType): void {
this.createSynth(type);
}
setGenre(genre: Genre): void {
this.genre = genre;
this.progression = getRandomProgression(genre);
if (this.sequence) {
this.createSequence();
}
} }
createSequence(): void { createSequence(): void {
@ -115,10 +85,9 @@ export class ChordEngine {
const chord = this.progression.chords[step]; const chord = this.progression.chords[step];
const duration = this.progression.durations[step]; const duration = this.progression.durations[step];
if (this.synth) { // Release previous notes and play new chord
this.synth.releaseAll(time); this.synth.releaseAll(time);
this.synth.triggerAttackRelease(chord, duration, time, 0.5); this.synth.triggerAttackRelease(chord, duration, time, 0.5);
}
}, },
steps, steps,
'2n' '2n'
@ -129,13 +98,14 @@ export class ChordEngine {
setProgression(progression: ChordProgression): void { setProgression(progression: ChordProgression): void {
this.progression = progression; this.progression = progression;
// Recreate sequence with new progression
if (this.sequence) { if (this.sequence) {
this.createSequence(); this.createSequence();
} }
} }
randomize(): ChordProgression { randomize(): ChordProgression {
this.progression = getRandomProgression(this.genre); this.progression = getRandomProgression();
if (this.sequence) { if (this.sequence) {
this.createSequence(); this.createSequence();
} }
@ -160,7 +130,7 @@ export class ChordEngine {
dispose(): void { dispose(): void {
this.sequence?.dispose(); this.sequence?.dispose();
this.synth?.dispose(); this.synth.dispose();
this.filter.dispose(); this.filter.dispose();
this.reverb.dispose(); this.reverb.dispose();
this.chorus.dispose(); this.chorus.dispose();

View File

@ -1,21 +1,16 @@
import * as Tone from 'tone'; import * as Tone from 'tone';
import { DrumPattern, DrumKit, Genre } from '@/types/audio'; import { DrumPattern } from '@/types/audio';
import { getRandomPattern } from './patterns'; import { getRandomPattern, generateRandomPattern } from './patterns';
export class DrumMachine { export class DrumMachine {
private kick: Tone.MembraneSynth | null = null; private kick: Tone.MembraneSynth;
private snare: Tone.NoiseSynth | null = null; private snare: Tone.NoiseSynth;
private hihat: Tone.NoiseSynth | null = null; private hihat: Tone.NoiseSynth;
private openhat: Tone.NoiseSynth | null = null; private openhat: Tone.NoiseSynth;
private snareFilter: Tone.Filter | null = null;
private hihatFilter: Tone.Filter | null = null;
private openhatFilter: Tone.Filter | null = null;
private sequence: Tone.Sequence | null = null; private sequence: Tone.Sequence | null = null;
private pattern: DrumPattern; private pattern: DrumPattern;
private output: Tone.Gain; private output: Tone.Gain;
private lowpass: Tone.Filter; private lowpass: Tone.Filter;
private currentKit: DrumKit = 'acoustic';
private genre: Genre = 'hiphop';
constructor(destination: Tone.InputNode) { constructor(destination: Tone.InputNode) {
this.output = new Tone.Gain(0.8); this.output = new Tone.Gain(0.8);
@ -25,116 +20,80 @@ export class DrumMachine {
rolloff: -12, rolloff: -12,
}); });
this.lowpass.connect(this.output); // Kick drum - deep and punchy
this.output.connect(destination);
this.pattern = getRandomPattern(this.genre);
this.createKit('acoustic');
}
private createKit(kit: DrumKit): void {
// Dispose existing instruments
this.kick?.dispose();
this.snare?.dispose();
this.hihat?.dispose();
this.openhat?.dispose();
this.snareFilter?.dispose();
this.hihatFilter?.dispose();
this.openhatFilter?.dispose();
const kitConfigs: Record<DrumKit, {
kick: Partial<Tone.MembraneSynthOptions>;
snareFilter: number;
hihatFilter: number;
snareDecay: number;
hihatDecay: number;
}> = {
acoustic: {
kick: { pitchDecay: 0.05, octaves: 6, envelope: { attack: 0.001, decay: 0.4, sustain: 0.01, release: 0.4 } },
snareFilter: 5000,
hihatFilter: 10000,
snareDecay: 0.2,
hihatDecay: 0.05,
},
electronic: {
kick: { pitchDecay: 0.08, octaves: 8, envelope: { attack: 0.001, decay: 0.3, sustain: 0, release: 0.3 } },
snareFilter: 4000,
hihatFilter: 12000,
snareDecay: 0.15,
hihatDecay: 0.03,
},
'808': {
kick: { pitchDecay: 0.15, octaves: 10, envelope: { attack: 0.001, decay: 0.8, sustain: 0.1, release: 0.6 } },
snareFilter: 3000,
hihatFilter: 8000,
snareDecay: 0.25,
hihatDecay: 0.04,
},
orchestral: {
kick: { pitchDecay: 0.02, octaves: 4, envelope: { attack: 0.01, decay: 0.5, sustain: 0.05, release: 0.5 } },
snareFilter: 6000,
hihatFilter: 6000,
snareDecay: 0.3,
hihatDecay: 0.1,
},
};
const config = kitConfigs[kit];
// Kick drum
this.kick = new Tone.MembraneSynth({ this.kick = new Tone.MembraneSynth({
...config.kick, pitchDecay: 0.05,
octaves: 6,
oscillator: { type: 'sine' }, oscillator: { type: 'sine' },
envelope: {
attack: 0.001,
decay: 0.4,
sustain: 0.01,
release: 0.4,
},
}); });
this.kick.connect(this.lowpass);
// Snare // Snare - filtered noise
this.snareFilter = new Tone.Filter({ this.snare = new Tone.NoiseSynth({
frequency: config.snareFilter, noise: { type: 'white' },
envelope: {
attack: 0.001,
decay: 0.2,
sustain: 0,
release: 0.1,
},
});
const snareFilter = new Tone.Filter({
frequency: 5000,
type: 'bandpass', type: 'bandpass',
Q: 1, Q: 1,
}); });
this.snare = new Tone.NoiseSynth({ this.snare.connect(snareFilter);
noise: { type: 'white' }, snareFilter.connect(this.lowpass);
envelope: { attack: 0.001, decay: config.snareDecay, sustain: 0, release: 0.1 },
});
this.snare.connect(this.snareFilter);
this.snareFilter.connect(this.lowpass);
// Closed hi-hat // Closed hi-hat - high filtered noise
this.hihatFilter = new Tone.Filter({
frequency: config.hihatFilter,
type: 'highpass',
});
this.hihat = new Tone.NoiseSynth({ this.hihat = new Tone.NoiseSynth({
noise: { type: 'white' }, noise: { type: 'white' },
envelope: { attack: 0.001, decay: config.hihatDecay, sustain: 0, release: 0.02 }, envelope: {
attack: 0.001,
decay: 0.05,
sustain: 0,
release: 0.02,
},
}); });
this.hihat.connect(this.hihatFilter); const hihatFilter = new Tone.Filter({
this.hihatFilter.connect(this.lowpass); frequency: 10000,
// Open hi-hat
this.openhatFilter = new Tone.Filter({
frequency: config.hihatFilter - 2000,
type: 'highpass', type: 'highpass',
}); });
this.hihat.connect(hihatFilter);
hihatFilter.connect(this.lowpass);
// Open hi-hat
this.openhat = new Tone.NoiseSynth({ this.openhat = new Tone.NoiseSynth({
noise: { type: 'white' }, noise: { type: 'white' },
envelope: { attack: 0.001, decay: 0.3, sustain: 0, release: 0.15 }, envelope: {
attack: 0.001,
decay: 0.3,
sustain: 0,
release: 0.15,
},
}); });
this.openhat.connect(this.openhatFilter); const openhatFilter = new Tone.Filter({
this.openhatFilter.connect(this.lowpass); frequency: 8000,
type: 'highpass',
});
this.openhat.connect(openhatFilter);
openhatFilter.connect(this.lowpass);
this.currentKit = kit; // Connect kick directly to lowpass
} this.kick.connect(this.lowpass);
setInstrument(kit: DrumKit): void { // Chain: lowpass -> output -> destination
this.createKit(kit); this.lowpass.connect(this.output);
} this.output.connect(destination);
setGenre(genre: Genre): void { // Initialize with a random pattern
this.genre = genre; this.pattern = getRandomPattern();
this.pattern = getRandomPattern(genre);
} }
createSequence(onStep?: (step: number) => void): void { createSequence(onStep?: (step: number) => void): void {
@ -146,19 +105,20 @@ export class DrumMachine {
this.sequence = new Tone.Sequence( this.sequence = new Tone.Sequence(
(time, step) => { (time, step) => {
if (this.pattern.kick[step] && this.kick) { if (this.pattern.kick[step]) {
this.kick.triggerAttackRelease('C1', '8n', time, 0.8); this.kick.triggerAttackRelease('C1', '8n', time, 0.8);
} }
if (this.pattern.snare[step] && this.snare) { if (this.pattern.snare[step]) {
this.snare.triggerAttackRelease('8n', time, 0.5); this.snare.triggerAttackRelease('8n', time, 0.5);
} }
if (this.pattern.hihat[step] && this.hihat) { if (this.pattern.hihat[step]) {
this.hihat.triggerAttackRelease('32n', time, 0.3); this.hihat.triggerAttackRelease('32n', time, 0.3);
} }
if (this.pattern.openhat[step] && this.openhat) { if (this.pattern.openhat[step]) {
this.openhat.triggerAttackRelease('16n', time, 0.25); this.openhat.triggerAttackRelease('16n', time, 0.25);
} }
// Call step callback on main thread
if (onStep) { if (onStep) {
Tone.getDraw().schedule(() => { Tone.getDraw().schedule(() => {
onStep(step); onStep(step);
@ -177,7 +137,7 @@ export class DrumMachine {
} }
randomize(): DrumPattern { randomize(): DrumPattern {
this.pattern = getRandomPattern(this.genre); this.pattern = Math.random() > 0.5 ? getRandomPattern() : generateRandomPattern();
return this.pattern; return this.pattern;
} }
@ -195,13 +155,10 @@ export class DrumMachine {
dispose(): void { dispose(): void {
this.sequence?.dispose(); this.sequence?.dispose();
this.kick?.dispose(); this.kick.dispose();
this.snare?.dispose(); this.snare.dispose();
this.hihat?.dispose(); this.hihat.dispose();
this.openhat?.dispose(); this.openhat.dispose();
this.snareFilter?.dispose();
this.hihatFilter?.dispose();
this.openhatFilter?.dispose();
this.lowpass.dispose(); this.lowpass.dispose();
this.output.dispose(); this.output.dispose();
} }

View File

@ -1,238 +1,145 @@
import { DrumPattern, ChordProgression, Genre } from '@/types/audio'; import { DrumPattern, ChordProgression } from '@/types/audio';
// Genre-specific drum patterns // Classic boom bap patterns
export const drumPatterns: Record<Genre, DrumPattern[]> = { export const drumPatterns: DrumPattern[] = [
hiphop: [ {
{ // Classic boom bap
kick: [true, false, false, false, false, false, false, false, true, false, true, false, false, false, false, false], 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], 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], 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], openhat: [false, false, false, false, false, false, false, true, false, false, false, false, false, false, false, true],
}, },
{ {
kick: [true, false, false, false, false, false, true, false, true, false, false, false, false, false, false, false], // Laid back groove
snare: [false, false, false, false, true, false, false, false, false, false, false, false, true, false, false, true], kick: [true, false, false, false, false, false, true, false, true, false, false, false, false, false, false, false],
hihat: [true, true, true, false, true, true, true, false, true, true, true, false, true, true, true, false], snare: [false, false, false, false, true, false, false, false, false, false, false, false, true, false, false, true],
openhat: [false, false, false, true, false, false, false, true, false, false, false, true, false, false, false, false], 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],
], },
classical: [ {
{ // Minimal chill
kick: [true, false, false, false, false, false, false, false, true, false, false, false, false, false, false, false], kick: [true, false, false, false, false, false, false, false, true, false, false, false, false, false, true, false],
snare: [false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false], snare: [false, false, false, false, true, false, false, false, false, false, false, false, true, false, false, false],
hihat: [false, false, false, false, false, false, false, false, false, false, false, false, false, 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, false], openhat: [false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, true],
}, },
{ {
kick: [true, false, false, false, true, false, false, false, true, false, false, false, true, false, false, false], // Jazzy swing
snare: [false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false], kick: [true, false, false, true, false, false, true, false, false, false, true, false, false, false, false, false],
hihat: [true, false, true, false, true, false, true, false, true, false, true, false, true, false, true, false], snare: [false, false, false, false, true, false, false, false, false, true, false, false, true, false, false, true],
openhat: [false, false, false, false, false, false, false, false, false, false, false, false, false, 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, true, false, false, false, false, false, false, false, true, false, false],
], },
trap: [ {
{ // Deep pocket
kick: [true, false, false, false, false, false, true, false, false, false, true, false, false, false, false, false], 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], 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], 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, true, false], openhat: [false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, true],
}, },
{ ];
kick: [true, false, false, true, false, false, true, false, false, false, true, false, false, true, false, false],
snare: [false, false, false, false, true, false, false, false, false, false, false, false, true, false, false, true],
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, true, false, false, false, false, false, false, false, true],
},
],
pop: [
{
kick: [true, false, false, false, true, false, false, false, true, false, false, false, true, 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, false, false, false, false, false, false, false, false, true],
},
{
kick: [true, false, false, false, false, false, true, 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, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true],
openhat: [false, false, false, false, false, false, false, true, false, false, false, false, false, false, false, false],
},
],
};
// Genre-specific chord progressions // Jazz chord progressions in lofi style
export const chordProgressions: Record<Genre, ChordProgression[]> = { export const chordProgressions: ChordProgression[] = [
hiphop: [ {
{ name: 'Classic ii-V-I',
name: 'Classic ii-V-I', chords: [
chords: [ ['D3', 'F3', 'A3', 'C4'], // Dm7
['D3', 'F3', 'A3', 'C4'], ['G2', 'B2', 'D3', 'F3'], // G7
['G2', 'B2', 'D3', 'F3'], ['C3', 'E3', 'G3', 'B3'], // Cmaj7
['C3', 'E3', 'G3', 'B3'], ['C3', 'E3', 'G3', 'B3'], // Cmaj7
['C3', 'E3', 'G3', 'B3'], ],
], durations: ['2n', '2n', '2n', '2n'],
durations: ['2n', '2n', '2n', '2n'], },
}, {
{ name: 'Minor Key Chill',
name: 'Minor Key Chill', chords: [
chords: [ ['A2', 'C3', 'E3', 'G3'], // Am7
['A2', 'C3', 'E3', 'G3'], ['D3', 'F3', 'A3', 'C4'], // Dm7
['D3', 'F3', 'A3', 'C4'], ['E2', 'G#2', 'B2', 'D3'], // E7
['E2', 'G#2', 'B2', 'D3'], ['A2', 'C3', 'E3', 'G3'], // Am7
['A2', 'C3', 'E3', 'G3'], ],
], durations: ['2n', '2n', '2n', '2n'],
durations: ['2n', '2n', '2n', '2n'], },
}, {
], name: 'Neo Soul',
classical: [ chords: [
{ ['F3', 'A3', 'C4', 'E4'], // Fmaj7
name: 'Romantic', ['E3', 'G3', 'B3', 'D4'], // Em7
chords: [ ['D3', 'F3', 'A3', 'C4'], // Dm7
['C3', 'E3', 'G3'], ['G2', 'B2', 'D3', 'F3'], // G7
['F3', 'A3', 'C4'], ],
['G3', 'B3', 'D4'], durations: ['2n', '2n', '2n', '2n'],
['C3', 'E3', 'G3'], },
], {
durations: ['1n', '1n', '1n', '1n'], name: 'Dreamy',
}, chords: [
{ ['C3', 'E3', 'G3', 'B3'], // Cmaj7
name: 'Baroque', ['A2', 'C3', 'E3', 'G3'], // Am7
chords: [ ['F3', 'A3', 'C4', 'E4'], // Fmaj7
['D3', 'F3', 'A3'], ['G2', 'B2', 'D3', 'F3'], // G7
['G2', 'B2', 'D3'], ],
['C3', 'E3', 'G3'], durations: ['2n', '2n', '2n', '2n'],
['A2', 'C3', 'E3'], },
], {
durations: ['1n', '1n', '1n', '1n'], name: 'Melancholy',
}, chords: [
], ['D3', 'F3', 'A3', 'C4'], // Dm7
trap: [ ['G2', 'Bb2', 'D3', 'F3'], // Gm7
{ ['C3', 'Eb3', 'G3', 'Bb3'],// Cm7
name: 'Dark Minor', ['F2', 'A2', 'C3', 'Eb3'], // F7
chords: [ ],
['A2', 'C3', 'E3'], durations: ['2n', '2n', '2n', '2n'],
['F2', 'A2', 'C3'], },
['G2', 'B2', 'D3'], ];
['E2', 'G#2', 'B2'],
],
durations: ['2n', '2n', '2n', '2n'],
},
{
name: 'Eerie',
chords: [
['D3', 'F3', 'A3'],
['Bb2', 'D3', 'F3'],
['A2', 'C3', 'E3'],
['G2', 'Bb2', 'D3'],
],
durations: ['2n', '2n', '2n', '2n'],
},
],
pop: [
{
name: 'Four Chord Pop',
chords: [
['C3', 'E3', 'G3'],
['G2', 'B2', 'D3'],
['A2', 'C3', 'E3'],
['F2', 'A2', 'C3'],
],
durations: ['1n', '1n', '1n', '1n'],
},
{
name: 'Uplifting',
chords: [
['D3', 'F#3', 'A3'],
['A2', 'C#3', 'E3'],
['B2', 'D3', 'F#3'],
['G2', 'B2', 'D3'],
],
durations: ['1n', '1n', '1n', '1n'],
},
],
};
// Bass patterns by genre export function getRandomPattern(): DrumPattern {
export const bassPatterns: Record<Genre, string[][]> = { return drumPatterns[Math.floor(Math.random() * drumPatterns.length)];
hiphop: [
['C2', null, null, null, 'C2', null, 'G1', null, 'C2', null, null, null, 'E2', null, null, null],
['A1', null, null, 'A1', null, null, 'G1', null, 'A1', null, null, null, 'E1', null, 'G1', null],
],
classical: [
['C2', null, null, null, 'G2', null, null, null, 'C2', null, null, null, 'G2', null, null, null],
['D2', null, null, null, 'A2', null, null, null, 'D2', null, null, null, 'A2', null, null, null],
],
trap: [
['C1', null, null, 'C1', null, null, 'C1', null, null, null, 'G1', null, null, 'C1', null, null],
['A0', null, null, 'A0', null, 'A0', null, null, 'A0', null, null, null, 'E1', null, 'A0', null],
],
pop: [
['C2', null, 'C2', null, 'G2', null, 'G2', null, 'A2', null, 'A2', null, 'F2', null, 'F2', null],
['D2', null, null, 'D2', 'A2', null, null, null, 'B2', null, null, 'B2', 'G2', null, null, null],
],
};
// Brass melody patterns by genre
export const brassPatterns: Record<Genre, string[][]> = {
hiphop: [
['C4', null, null, null, 'E4', null, null, null, 'G4', null, null, null, 'E4', null, null, null],
[null, null, 'D4', null, null, null, 'F4', null, null, null, 'A4', null, null, null, 'G4', null],
],
classical: [
['G4', null, null, null, null, null, null, null, 'E4', null, null, null, null, null, null, null],
['C4', null, null, null, 'D4', null, null, null, 'E4', null, null, null, 'G4', null, null, null],
],
trap: [
['A3', null, null, null, null, null, null, null, 'C4', null, null, null, null, null, null, null],
[null, null, null, null, 'E4', null, null, null, null, null, null, null, 'D4', null, null, null],
],
pop: [
['E4', null, null, null, 'G4', null, null, null, 'A4', null, null, null, 'G4', null, null, null],
['C4', null, 'D4', null, 'E4', null, 'G4', null, 'A4', null, 'G4', null, 'E4', null, 'D4', null],
],
};
// Piano patterns by genre
export const pianoPatterns: Record<Genre, string[][][]> = {
hiphop: [
[['C4', 'E4', 'G4'], null, null, null, ['D4', 'F4', 'A4'], null, null, null, ['E4', 'G4', 'B4'], null, null, null, ['D4', 'F4', 'A4'], null, null, null],
[['A3', 'C4', 'E4'], null, ['A3', 'C4', 'E4'], null, null, null, ['G3', 'B3', 'D4'], null, ['G3', 'B3', 'D4'], null, null, null, ['F3', 'A3', 'C4'], null, null, null],
],
classical: [
[['C4', 'E4', 'G4'], ['E4'], ['G4'], ['C5'], ['G4'], ['E4'], ['C4', 'E4', 'G4'], null, ['D4', 'F4', 'A4'], ['F4'], ['A4'], ['D5'], ['A4'], ['F4'], ['D4', 'F4', 'A4'], null],
[['A3', 'C4', 'E4'], null, ['C4', 'E4'], null, ['A3', 'C4', 'E4'], null, ['C4', 'E4'], null, ['G3', 'B3', 'D4'], null, ['B3', 'D4'], null, ['G3', 'B3', 'D4'], null, null, null],
],
trap: [
[['A3', 'C4', 'E4'], null, null, null, null, null, null, null, ['G3', 'Bb3', 'D4'], null, null, null, null, null, null, null],
[['D4', 'F4', 'A4'], null, null, null, null, null, ['C4', 'E4', 'G4'], null, null, null, null, null, null, null, ['Bb3', 'D4', 'F4'], null],
],
pop: [
[['C4', 'E4', 'G4'], null, ['C4', 'E4', 'G4'], null, ['G3', 'B3', 'D4'], null, ['G3', 'B3', 'D4'], null, ['A3', 'C4', 'E4'], null, ['A3', 'C4', 'E4'], null, ['F3', 'A3', 'C4'], null, ['F3', 'A3', 'C4'], null],
[['D4', 'F#4', 'A4'], null, null, ['D4', 'F#4', 'A4'], ['A3', 'C#4', 'E4'], null, null, ['A3', 'C#4', 'E4'], ['B3', 'D4', 'F#4'], null, null, ['B3', 'D4', 'F#4'], ['G3', 'B3', 'D4'], null, null, null],
],
};
export function getRandomPattern(genre: Genre): DrumPattern {
const patterns = drumPatterns[genre];
return patterns[Math.floor(Math.random() * patterns.length)];
} }
export function getRandomProgression(genre: Genre): ChordProgression { export function getRandomProgression(): ChordProgression {
const progressions = chordProgressions[genre]; return chordProgressions[Math.floor(Math.random() * chordProgressions.length)];
return progressions[Math.floor(Math.random() * progressions.length)];
} }
export function getRandomBassPattern(genre: Genre): (string | null)[] { export function generateRandomPattern(): DrumPattern {
const patterns = bassPatterns[genre]; const pattern: DrumPattern = {
return patterns[Math.floor(Math.random() * patterns.length)]; kick: new Array(16).fill(false),
} snare: new Array(16).fill(false),
hihat: new Array(16).fill(false),
openhat: new Array(16).fill(false),
};
export function getRandomBrassPattern(genre: Genre): (string | null)[] { // Kick on 1 and somewhere in second half
const patterns = brassPatterns[genre]; pattern.kick[0] = true;
return patterns[Math.floor(Math.random() * patterns.length)]; pattern.kick[8 + Math.floor(Math.random() * 4)] = true;
} if (Math.random() > 0.5) {
pattern.kick[6 + Math.floor(Math.random() * 2)] = true;
}
export function getRandomPianoPattern(genre: Genre): (string[] | null)[] { // Snare on 2 and 4
const patterns = pianoPatterns[genre]; pattern.snare[4] = true;
return patterns[Math.floor(Math.random() * patterns.length)]; 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;
} }

View File

@ -1,138 +0,0 @@
import * as Tone from 'tone';
import { PianoType, Genre } from '@/types/audio';
import { getRandomPianoPattern } from './patterns';
export class PianoEngine {
private synth: Tone.PolySynth | null = null;
private sequence: Tone.Sequence | null = null;
private pattern: (string[] | null)[];
private output: Tone.Gain;
private filter: Tone.Filter;
private reverb: Tone.Reverb;
private currentType: PianoType = 'grand';
private genre: Genre = 'hiphop';
constructor(destination: Tone.InputNode) {
this.output = new Tone.Gain(0.5);
this.filter = new Tone.Filter({
frequency: 5000,
type: 'lowpass',
rolloff: -12,
});
this.reverb = new Tone.Reverb({
decay: 2.5,
wet: 0.25,
});
this.filter.connect(this.reverb);
this.reverb.connect(this.output);
this.output.connect(destination);
this.pattern = getRandomPianoPattern(this.genre);
this.createSynth('grand');
}
private createSynth(type: PianoType): void {
if (this.synth) {
this.synth.dispose();
}
const configs: Record<PianoType, { synth: typeof Tone.Synth; options: object }> = {
grand: {
synth: Tone.Synth,
options: {
oscillator: { type: 'triangle' },
envelope: { attack: 0.005, decay: 0.8, sustain: 0.2, release: 1.2 },
},
},
electric: {
synth: Tone.Synth,
options: {
oscillator: { type: 'sine' },
envelope: { attack: 0.01, decay: 0.5, sustain: 0.3, release: 0.8 },
},
},
rhodes: {
synth: Tone.FMSynth,
options: {
harmonicity: 3,
modulationIndex: 1,
oscillator: { type: 'sine' },
envelope: { attack: 0.01, decay: 0.6, sustain: 0.3, release: 1 },
modulation: { type: 'sine' },
modulationEnvelope: { attack: 0.01, decay: 0.3, sustain: 0.5, release: 0.5 },
},
},
synth: {
synth: Tone.Synth,
options: {
oscillator: { type: 'sawtooth' },
envelope: { attack: 0.02, decay: 0.3, sustain: 0.4, release: 0.5 },
},
},
};
const config = configs[type];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.synth = new Tone.PolySynth(config.synth as any, config.options);
this.synth.volume.value = -10;
this.synth.connect(this.filter);
this.currentType = type;
}
setInstrument(type: PianoType): void {
this.createSynth(type);
}
setGenre(genre: Genre): void {
this.genre = genre;
this.pattern = getRandomPianoPattern(genre);
if (this.sequence) {
this.createSequence();
}
}
createSequence(): void {
if (this.sequence) {
this.sequence.dispose();
}
const steps = Array.from({ length: 16 }, (_, i) => i);
this.sequence = new Tone.Sequence(
(time, step) => {
const notes = this.pattern[step];
if (notes && this.synth) {
this.synth.triggerAttackRelease(notes, '8n', time, 0.5);
}
},
steps,
'16n'
);
this.sequence.start(0);
}
randomize(): void {
this.pattern = getRandomPianoPattern(this.genre);
if (this.sequence) {
this.createSequence();
}
}
setVolume(volume: number): void {
this.output.gain.rampTo(volume, 0.1);
}
mute(muted: boolean): void {
this.output.gain.rampTo(muted ? 0 : 0.5, 0.1);
}
dispose(): void {
this.sequence?.dispose();
this.synth?.dispose();
this.filter.dispose();
this.reverb.dispose();
this.output.dispose();
}
}

View File

@ -0,0 +1,176 @@
import * as Tone from 'tone';
import {
PatternKeyframe,
ChordKeyframe,
MuteKeyframe,
AutomationPoint,
LayerName,
} from '@/types/audio';
import { DrumMachine } from './drumMachine';
import { ChordEngine } from './chordEngine';
import { drumPatterns, chordProgressions } from './patterns';
type ScheduledEventId = number;
interface ScheduledEvents {
drumPatterns: ScheduledEventId[];
chordProgressions: ScheduledEventId[];
muteEvents: Record<LayerName, ScheduledEventId[]>;
automationEvents: Record<LayerName, ScheduledEventId[]>;
}
export class TimelineScheduler {
private transport = Tone.getTransport();
private events: ScheduledEvents = {
drumPatterns: [],
chordProgressions: [],
muteEvents: { drums: [], chords: [], ambient: [] },
automationEvents: { drums: [], chords: [], ambient: [] },
};
private drumMachine: DrumMachine | null = null;
private chordEngine: ChordEngine | null = null;
private volumeCallback: ((layer: LayerName, value: number) => void) | null = null;
private muteCallback: ((layer: LayerName, muted: boolean) => void) | null = null;
setDrumMachine(dm: DrumMachine | null): void {
this.drumMachine = dm;
}
setChordEngine(ce: ChordEngine | null): void {
this.chordEngine = ce;
}
setVolumeCallback(cb: (layer: LayerName, value: number) => void): void {
this.volumeCallback = cb;
}
setMuteCallback(cb: (layer: LayerName, muted: boolean) => void): void {
this.muteCallback = cb;
}
scheduleDrumKeyframes(keyframes: PatternKeyframe[]): void {
this.clearDrumEvents();
const sorted = [...keyframes].sort((a, b) => a.bar - b.bar);
sorted.forEach((kf) => {
const eventId = this.transport.schedule((time) => {
if (this.drumMachine && drumPatterns[kf.patternIndex]) {
this.drumMachine.setPattern(drumPatterns[kf.patternIndex]);
}
}, `${kf.bar}:0:0`);
this.events.drumPatterns.push(eventId);
});
}
scheduleChordKeyframes(keyframes: ChordKeyframe[]): void {
this.clearChordEvents();
const sorted = [...keyframes].sort((a, b) => a.bar - b.bar);
sorted.forEach((kf) => {
const eventId = this.transport.schedule((time) => {
if (this.chordEngine && chordProgressions[kf.progressionIndex]) {
this.chordEngine.setProgression(chordProgressions[kf.progressionIndex]);
}
}, `${kf.bar}:0:0`);
this.events.chordProgressions.push(eventId);
});
}
scheduleMuteKeyframes(layer: LayerName, keyframes: MuteKeyframe[]): void {
this.clearMuteEvents(layer);
const sorted = [...keyframes].sort((a, b) => a.bar - b.bar);
sorted.forEach((kf) => {
const eventId = this.transport.schedule((time) => {
this.muteCallback?.(layer, kf.muted);
}, `${kf.bar}:0:0`);
this.events.muteEvents[layer].push(eventId);
});
}
scheduleVolumeAutomation(layer: LayerName, points: AutomationPoint[]): void {
this.clearAutomationEvents(layer);
if (points.length === 0) return;
const sorted = [...points].sort((a, b) => {
if (a.bar !== b.bar) return a.bar - b.bar;
return a.beat - b.beat;
});
for (let i = 0; i < sorted.length; i++) {
const current = sorted[i];
const next = sorted[i + 1];
if (!next) {
const eventId = this.transport.schedule((time) => {
this.volumeCallback?.(layer, current.value);
}, `${current.bar}:${current.beat}:0`);
this.events.automationEvents[layer].push(eventId);
continue;
}
const startTime = this.transport.toSeconds(`${current.bar}:${current.beat}:0`);
const endTime = this.transport.toSeconds(`${next.bar}:${next.beat}:0`);
const duration = endTime - startTime;
const steps = Math.max(1, Math.floor(duration * 16));
const stepDuration = duration / steps;
for (let step = 0; step <= steps; step++) {
const t = step / steps;
const value = current.value + (next.value - current.value) * t;
const scheduleTime = startTime + step * stepDuration;
const eventId = this.transport.schedule((time) => {
this.volumeCallback?.(layer, value);
}, scheduleTime);
this.events.automationEvents[layer].push(eventId);
}
}
}
private clearDrumEvents(): void {
this.events.drumPatterns.forEach((id) => this.transport.clear(id));
this.events.drumPatterns = [];
}
private clearChordEvents(): void {
this.events.chordProgressions.forEach((id) => this.transport.clear(id));
this.events.chordProgressions = [];
}
private clearMuteEvents(layer: LayerName): void {
this.events.muteEvents[layer].forEach((id) => this.transport.clear(id));
this.events.muteEvents[layer] = [];
}
private clearAutomationEvents(layer: LayerName): void {
this.events.automationEvents[layer].forEach((id) => this.transport.clear(id));
this.events.automationEvents[layer] = [];
}
clearAll(): void {
this.clearDrumEvents();
this.clearChordEvents();
const layers: LayerName[] = ['drums', 'chords', 'ambient'];
layers.forEach((l) => {
this.clearMuteEvents(l);
this.clearAutomationEvents(l);
});
}
dispose(): void {
this.clearAll();
this.drumMachine = null;
this.chordEngine = null;
this.volumeCallback = null;
this.muteCallback = null;
}
}

View File

@ -10,7 +10,6 @@
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle": "^1.1.10",

View File

@ -1,12 +1,3 @@
export type Genre = 'hiphop' | 'classical' | 'trap' | 'pop';
export type DrumKit = 'acoustic' | 'electronic' | '808' | 'orchestral';
export type BassType = 'synth' | 'sub' | 'electric' | 'upright';
export type BrassType = 'trumpet' | 'horn' | 'synth-brass' | 'orchestra';
export type PianoType = 'grand' | 'electric' | 'rhodes' | 'synth';
export type ChordType = 'pad' | 'strings' | 'organ' | 'synth';
export type AmbientType = 'rain' | 'vinyl' | 'nature' | 'space';
export interface DrumPattern { export interface DrumPattern {
kick: boolean[]; kick: boolean[];
snare: boolean[]; snare: boolean[];
@ -20,97 +11,118 @@ export interface ChordProgression {
durations: string[]; durations: string[];
} }
export interface BassPattern {
notes: (string | null)[];
durations: string[];
}
export interface InstrumentConfig {
drums: DrumKit;
bass: BassType;
brass: BrassType;
piano: PianoType;
chords: ChordType;
ambient: AmbientType;
}
export interface EngineState { export interface EngineState {
isPlaying: boolean; isPlaying: boolean;
isInitialized: boolean; isInitialized: boolean;
bpm: number; bpm: number;
swing: number; swing: number;
currentStep: number; currentStep: number;
genre: Genre;
duration: number; // in minutes
instruments: InstrumentConfig;
volumes: { volumes: {
master: number; master: number;
drums: number; drums: number;
bass: number;
brass: number;
piano: number;
chords: number; chords: number;
ambient: number; ambient: number;
}; };
muted: { muted: {
drums: boolean; drums: boolean;
bass: boolean;
brass: boolean;
piano: boolean;
chords: boolean; chords: boolean;
ambient: boolean; ambient: boolean;
}; };
} }
export type LayerName = 'drums' | 'bass' | 'brass' | 'piano' | 'chords' | 'ambient'; export type LayerName = 'drums' | 'chords' | 'ambient';
export interface AudioEngineCallbacks { export interface AudioEngineCallbacks {
onStepChange?: (step: number) => void; onStepChange?: (step: number) => void;
onStateChange?: (state: EngineState) => void; onStateChange?: (state: EngineState) => void;
onBarChange?: (bar: number, beat: number) => void;
onMeterUpdate?: (levels: MeterLevels) => void;
} }
export const GENRE_CONFIG: Record<Genre, { bpm: number; swing: number; name: string }> = { // Mixer types
hiphop: { bpm: 90, swing: 0.15, name: 'Hip Hop' }, export interface MeterLevels {
classical: { bpm: 72, swing: 0, name: 'Classical' }, drums: number;
trap: { bpm: 140, swing: 0.05, name: 'Trap' }, chords: number;
pop: { bpm: 120, swing: 0.08, name: 'Pop' }, ambient: number;
}; master: number;
}
export const INSTRUMENT_OPTIONS = { export interface MixerState {
drums: [ volumes: Record<LayerName | 'master', number>;
{ value: 'acoustic', label: 'Acoustic Kit' }, pans: Record<LayerName, number>;
{ value: 'electronic', label: 'Electronic' }, muted: Record<LayerName, boolean>;
{ value: '808', label: '808 Machine' }, soloed: Record<LayerName, boolean>;
{ value: 'orchestral', label: 'Orchestral' }, levels: MeterLevels;
], }
bass: [
{ value: 'synth', label: 'Synth Bass' }, // Timeline types
{ value: 'sub', label: 'Sub Bass' }, export type SectionType = 'intro' | 'verse' | 'chorus' | 'drop' | 'bridge' | 'outro';
{ value: 'electric', label: 'Electric Bass' },
{ value: 'upright', label: 'Upright Bass' }, export interface Section {
], id: string;
brass: [ type: SectionType;
{ value: 'trumpet', label: 'Trumpet' }, name: string;
{ value: 'horn', label: 'French Horn' }, startBar: number;
{ value: 'synth-brass', label: 'Synth Brass' }, endBar: number;
{ value: 'orchestra', label: 'Brass Section' }, color: string;
], }
piano: [
{ value: 'grand', label: 'Grand Piano' }, export interface PatternKeyframe {
{ value: 'electric', label: 'Electric Piano' }, id: string;
{ value: 'rhodes', label: 'Rhodes' }, bar: number;
{ value: 'synth', label: 'Synth Keys' }, patternIndex: number;
], }
chords: [
{ value: 'pad', label: 'Synth Pad' }, export interface ChordKeyframe {
{ value: 'strings', label: 'Strings' }, id: string;
{ value: 'organ', label: 'Organ' }, bar: number;
{ value: 'synth', label: 'Synth Lead' }, progressionIndex: number;
], }
ambient: [
{ value: 'rain', label: 'Rain' }, export interface AutomationPoint {
{ value: 'vinyl', label: 'Vinyl Crackle' }, id: string;
{ value: 'nature', label: 'Nature' }, bar: number;
{ value: 'space', label: 'Space Atmosphere' }, beat: number;
], value: number;
} as const; }
export interface MuteKeyframe {
id: string;
bar: number;
muted: boolean;
}
export interface LoopRegion {
start: number;
end: number;
enabled: boolean;
}
export interface TimelineState {
durationBars: number;
playheadBar: number;
playheadBeat: number;
sections: Section[];
drumKeyframes: PatternKeyframe[];
chordKeyframes: ChordKeyframe[];
volumeAutomation: Record<LayerName, AutomationPoint[]>;
muteKeyframes: Record<LayerName, MuteKeyframe[]>;
loopRegion: LoopRegion;
}
export interface PlaybackPosition {
bar: number;
beat: number;
sixteenth: number;
totalSeconds: number;
totalBars: number;
}
export const SECTION_COLORS: Record<SectionType, string> = {
intro: 'oklch(0.6 0.15 230)',
verse: 'oklch(0.6 0.12 180)',
chorus: 'oklch(0.65 0.15 50)',
drop: 'oklch(0.6 0.18 25)',
bridge: 'oklch(0.55 0.15 280)',
outro: 'oklch(0.5 0.1 230)',
};