forked from averyfelts/Lofi_Generator
Compare commits
1 Commits
main
...
feat/timel
| Author | SHA1 | Date | |
|---|---|---|---|
| d3158e4c6a |
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
79
bun.lock
79
bun.lock
@ -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=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
71
components/lofi-generator/LayerMixer.tsx
Normal file
71
components/lofi-generator/LayerMixer.tsx
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Toggle } from '@/components/ui/toggle';
|
||||||
|
import { VolumeControl } from './VolumeControl';
|
||||||
|
import { Volume2, VolumeX, Drum, Music, Cloud } from 'lucide-react';
|
||||||
|
import { LayerName } from '@/types/audio';
|
||||||
|
|
||||||
|
interface LayerMixerProps {
|
||||||
|
volumes: {
|
||||||
|
drums: number;
|
||||||
|
chords: number;
|
||||||
|
ambient: number;
|
||||||
|
};
|
||||||
|
muted: {
|
||||||
|
drums: boolean;
|
||||||
|
chords: boolean;
|
||||||
|
ambient: boolean;
|
||||||
|
};
|
||||||
|
onVolumeChange: (layer: LayerName, volume: number) => void;
|
||||||
|
onToggleMute: (layer: LayerName) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const layers: { name: LayerName; label: string; icon: React.ReactNode }[] = [
|
||||||
|
{ name: 'drums', label: 'Drums', icon: <Drum className="h-4 w-4" /> },
|
||||||
|
{ name: 'chords', label: 'Chords', icon: <Music className="h-4 w-4" /> },
|
||||||
|
{ name: 'ambient', label: 'Ambient', icon: <Cloud className="h-4 w-4" /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function LayerMixer({
|
||||||
|
volumes,
|
||||||
|
muted,
|
||||||
|
onVolumeChange,
|
||||||
|
onToggleMute,
|
||||||
|
}: LayerMixerProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
Layers
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{layers.map(({ name, label, icon }) => (
|
||||||
|
<div key={name} className="flex items-center gap-3">
|
||||||
|
<Toggle
|
||||||
|
pressed={!muted[name]}
|
||||||
|
onPressedChange={() => onToggleMute(name)}
|
||||||
|
size="sm"
|
||||||
|
className="shrink-0"
|
||||||
|
aria-label={`Toggle ${label}`}
|
||||||
|
>
|
||||||
|
{muted[name] ? (
|
||||||
|
<VolumeX className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Volume2 className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Toggle>
|
||||||
|
<div className="flex items-center gap-2 shrink-0 w-20">
|
||||||
|
{icon}
|
||||||
|
<span className="text-sm">{label}</span>
|
||||||
|
</div>
|
||||||
|
<VolumeControl
|
||||||
|
label=""
|
||||||
|
value={volumes[name]}
|
||||||
|
onChange={(v) => onVolumeChange(name, v)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
45
components/lofi-generator/TransportControls.tsx
Normal file
45
components/lofi-generator/TransportControls.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Play, Pause, Shuffle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface TransportControlsProps {
|
||||||
|
isPlaying: boolean;
|
||||||
|
isInitialized: boolean;
|
||||||
|
onTogglePlayback: () => void;
|
||||||
|
onGenerateNewBeat: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TransportControls({
|
||||||
|
isPlaying,
|
||||||
|
isInitialized,
|
||||||
|
onTogglePlayback,
|
||||||
|
onGenerateNewBeat,
|
||||||
|
}: TransportControlsProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
onClick={onTogglePlayback}
|
||||||
|
className="h-14 w-14 rounded-full"
|
||||||
|
>
|
||||||
|
{isPlaying ? (
|
||||||
|
<Pause className="h-6 w-6" />
|
||||||
|
) : (
|
||||||
|
<Play className="h-6 w-6 ml-1" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="lg"
|
||||||
|
onClick={onGenerateNewBeat}
|
||||||
|
disabled={!isInitialized && !isPlaying}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Shuffle className="h-4 w-4" />
|
||||||
|
New Beat
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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]'}
|
|
||||||
`}
|
`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
37
components/lofi-generator/VolumeControl.tsx
Normal file
37
components/lofi-generator/VolumeControl.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Slider } from '@/components/ui/slider';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
||||||
|
interface VolumeControlProps {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VolumeControl({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
className = '',
|
||||||
|
}: VolumeControlProps) {
|
||||||
|
return (
|
||||||
|
<div className={`space-y-2 ${className}`}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-sm text-muted-foreground">{label}</Label>
|
||||||
|
<span className="text-xs text-muted-foreground font-mono">
|
||||||
|
{Math.round(value * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[value]}
|
||||||
|
onValueChange={([v]) => onChange(v)}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
components/mixer/ChannelStrip.tsx
Normal file
102
components/mixer/ChannelStrip.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
components/mixer/LevelMeter.tsx
Normal file
52
components/mixer/LevelMeter.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
components/mixer/Mixer.tsx
Normal file
78
components/mixer/Mixer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
components/mixer/PanKnob.tsx
Normal file
81
components/mixer/PanKnob.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
components/mixer/VerticalFader.tsx
Normal file
113
components/mixer/VerticalFader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
components/mixer/index.ts
Normal file
5
components/mixer/index.ts
Normal 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';
|
||||||
197
components/timeline/AutomationLane.tsx
Normal file
197
components/timeline/AutomationLane.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
components/timeline/DurationSelector.tsx
Normal file
32
components/timeline/DurationSelector.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
163
components/timeline/ExportModal.tsx
Normal file
163
components/timeline/ExportModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
components/timeline/KeyframeMarker.tsx
Normal file
56
components/timeline/KeyframeMarker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
components/timeline/KeyframeTrack.tsx
Normal file
137
components/timeline/KeyframeTrack.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
components/timeline/LoopBracket.tsx
Normal file
127
components/timeline/LoopBracket.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
components/timeline/MuteTrack.tsx
Normal file
117
components/timeline/MuteTrack.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
components/timeline/PatternPicker.tsx
Normal file
108
components/timeline/PatternPicker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
components/timeline/Playhead.tsx
Normal file
94
components/timeline/Playhead.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
components/timeline/ProgressionPicker.tsx
Normal file
91
components/timeline/ProgressionPicker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
components/timeline/SectionBlock.tsx
Normal file
130
components/timeline/SectionBlock.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
components/timeline/SectionPicker.tsx
Normal file
63
components/timeline/SectionPicker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
components/timeline/SectionTrack.tsx
Normal file
112
components/timeline/SectionTrack.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
components/timeline/TimeDisplay.tsx
Normal file
55
components/timeline/TimeDisplay.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
305
components/timeline/Timeline.tsx
Normal file
305
components/timeline/Timeline.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
components/timeline/TimelineRuler.tsx
Normal file
51
components/timeline/TimelineRuler.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
components/timeline/index.ts
Normal file
16
components/timeline/index.ts
Normal 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';
|
||||||
@ -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,
|
|
||||||
}
|
|
||||||
@ -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
277
hooks/useTimeline.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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();
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
176
lib/audio/timelineScheduler.ts
Normal file
176
lib/audio/timelineScheduler.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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",
|
||||||
|
|||||||
166
types/audio.ts
166
types/audio.ts
@ -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)',
|
||||||
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user