forked from averyfelts/Lofi_Generator
Compare commits
2 Commits
feat/timel
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9479ad9d19 | |||
|
|
0f17775f3f |
@ -123,19 +123,3 @@
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
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 = {
|
||||
title: "Lofi Generator",
|
||||
description: "Web-based lofi hip hop beat generator - beats to relax/study to",
|
||||
title: "Beat Generator",
|
||||
description: "Create custom beats across Hip Hop, Classical, Trap, and Pop genres",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
79
bun.lock
79
bun.lock
@ -6,6 +6,7 @@
|
||||
"name": "lofi-generator",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
@ -96,6 +97,14 @@
|
||||
|
||||
"@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/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
|
||||
@ -198,6 +207,8 @@
|
||||
|
||||
"@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-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=="],
|
||||
@ -206,26 +217,50 @@
|
||||
|
||||
"@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-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-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-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
|
||||
|
||||
"@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ=="],
|
||||
|
||||
"@radix-ui/react-use-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-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-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-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=="],
|
||||
|
||||
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
|
||||
@ -342,6 +377,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
@ -430,6 +467,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
@ -528,6 +567,8 @@
|
||||
|
||||
"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-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=="],
|
||||
@ -766,6 +807,12 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
@ -884,6 +931,10 @@
|
||||
|
||||
"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-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=="],
|
||||
@ -910,14 +961,30 @@
|
||||
|
||||
"@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-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-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/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||
@ -954,10 +1021,22 @@
|
||||
|
||||
"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-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=="],
|
||||
}
|
||||
}
|
||||
|
||||
143
components/lofi-generator/LayerBox.tsx
Normal file
143
components/lofi-generator/LayerBox.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@ -1,71 +0,0 @@
|
||||
'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,125 +1,159 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TransportControls } from './TransportControls';
|
||||
import { Visualizer } from './Visualizer';
|
||||
import { Mixer } from '@/components/mixer';
|
||||
import { Timeline, ExportModal } from '@/components/timeline';
|
||||
import { useAudioEngine } from '@/hooks/useAudioEngine';
|
||||
import { useTimeline } from '@/hooks/useTimeline';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Download, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { LayerName } from '@/types/audio';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { LayerBox } from './LayerBox';
|
||||
import { Visualizer } from './Visualizer';
|
||||
import { useAudioEngine } from '@/hooks/useAudioEngine';
|
||||
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() {
|
||||
const {
|
||||
state,
|
||||
currentStep,
|
||||
meterLevels,
|
||||
pans,
|
||||
soloed,
|
||||
playheadPosition,
|
||||
togglePlayback,
|
||||
generateNewBeat,
|
||||
setMasterVolume,
|
||||
setLayerVolume,
|
||||
toggleMute,
|
||||
setPan,
|
||||
toggleSolo,
|
||||
setGenre,
|
||||
setDuration,
|
||||
setInstrument,
|
||||
setBpm,
|
||||
setSwing,
|
||||
seek,
|
||||
setLoopRegion,
|
||||
} = useAudioEngine();
|
||||
|
||||
const timeline = useTimeline();
|
||||
|
||||
const [showTimeline, setShowTimeline] = useState(false);
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
|
||||
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,
|
||||
};
|
||||
const genres: { value: Genre; label: string }[] = [
|
||||
{ value: 'hiphop', label: 'Hip Hop' },
|
||||
{ value: 'classical', label: 'Classical' },
|
||||
{ value: 'trap', label: 'Trap' },
|
||||
{ value: 'pop', label: 'Pop' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-4xl bg-card/80 backdrop-blur-sm border-border/50">
|
||||
<CardHeader className="text-center pb-2">
|
||||
<CardTitle className="text-2xl font-light tracking-wide">
|
||||
lofi generator
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">beats to relax/study to</p>
|
||||
</CardHeader>
|
||||
<div className="min-h-screen flex flex-col items-center p-4 pt-8">
|
||||
{/* Modern Header */}
|
||||
<header className="w-full max-w-2xl mb-8">
|
||||
<div className="flex items-center justify-center gap-3 mb-2">
|
||||
<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">
|
||||
<Waves className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<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">
|
||||
Beat Generator
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Create custom beats across multiple genres
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<Visualizer currentStep={currentStep} isPlaying={state.isPlaying} />
|
||||
<Card className="w-full max-w-2xl bg-card/80 backdrop-blur-sm border-border/50">
|
||||
<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>
|
||||
|
||||
<div className="flex justify-center items-center gap-2">
|
||||
<TransportControls
|
||||
isPlaying={state.isPlaying}
|
||||
isInitialized={state.isInitialized}
|
||||
onTogglePlayback={togglePlayback}
|
||||
onGenerateNewBeat={generateNewBeat}
|
||||
/>
|
||||
{/* Transport Controls */}
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<Button
|
||||
variant={showTimeline ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setShowTimeline(!showTimeline)}
|
||||
className="gap-1 h-9"
|
||||
size="lg"
|
||||
onClick={togglePlayback}
|
||||
className="h-14 w-14 rounded-full bg-gradient-to-br from-lofi-orange to-lofi-pink hover:opacity-90"
|
||||
>
|
||||
Timeline
|
||||
{showTimeline ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
{state.isPlaying ? (
|
||||
<Pause className="h-6 w-6" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
<Play className="h-6 w-6 ml-1" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowExportModal(true)}
|
||||
className="h-9 w-9 p-0"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
onClick={generateNewBeat}
|
||||
className="gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
<Shuffle className="h-4 w-4" />
|
||||
Generate
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
{/* Visualizer */}
|
||||
<Visualizer currentStep={currentStep} isPlaying={state.isPlaying} />
|
||||
|
||||
{/* Master Controls */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-muted-foreground">Master</Label>
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{Math.round(state.volumes.master * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[state.volumes.master]}
|
||||
onValueChange={([v]) => setMasterVolume(v)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-muted-foreground">BPM</Label>
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
@ -130,11 +164,11 @@ export function LofiGenerator() {
|
||||
value={[state.bpm]}
|
||||
onValueChange={([v]) => setBpm(v)}
|
||||
min={60}
|
||||
max={100}
|
||||
max={180}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-muted-foreground">Swing</Label>
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
@ -151,58 +185,99 @@ export function LofiGenerator() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Mixer
|
||||
volumes={volumes}
|
||||
pans={pans}
|
||||
muted={state.muted}
|
||||
soloed={soloed}
|
||||
levels={meterLevels}
|
||||
onVolumeChange={handleVolumeChange}
|
||||
onPanChange={setPan}
|
||||
onMuteToggle={toggleMute}
|
||||
onSoloToggle={toggleSolo}
|
||||
/>
|
||||
{/* Instrument Layers */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Instruments
|
||||
</h3>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden transition-all duration-300 ease-in-out',
|
||||
showTimeline ? 'max-h-[500px] opacity-100' : 'max-h-0 opacity-0'
|
||||
)}
|
||||
>
|
||||
<Timeline
|
||||
state={timeline.state}
|
||||
bpm={state.bpm}
|
||||
isPlaying={state.isPlaying}
|
||||
onDurationChange={timeline.setDuration}
|
||||
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}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<LayerBox
|
||||
title="Drums"
|
||||
icon={<Drum className="h-4 w-4" />}
|
||||
volume={state.volumes.drums}
|
||||
muted={state.muted.drums}
|
||||
instrument={state.instruments.drums}
|
||||
instrumentOptions={INSTRUMENT_OPTIONS.drums}
|
||||
onVolumeChange={(v) => setLayerVolume('drums', v)}
|
||||
onToggleMute={() => toggleMute('drums')}
|
||||
onInstrumentChange={(v) => setInstrument('drums', v)}
|
||||
accentColor="orange"
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
</Card>
|
||||
|
||||
{showExportModal && (
|
||||
<ExportModal
|
||||
durationBars={timeline.state.durationBars}
|
||||
bpm={state.bpm}
|
||||
onExport={handleExport}
|
||||
onClose={() => setShowExportModal(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,45 +0,0 @@
|
||||
'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,25 +7,31 @@ interface VisualizerProps {
|
||||
|
||||
export function Visualizer({ currentStep, isPlaying }: VisualizerProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-1.5 py-4">
|
||||
<div className="flex items-end justify-center gap-1 h-16 px-4 py-3 bg-secondary/50 rounded-lg border border-border/50">
|
||||
{Array.from({ length: 16 }, (_, i) => {
|
||||
const isActive = isPlaying && currentStep === i;
|
||||
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 (
|
||||
<div
|
||||
key={i}
|
||||
className={`
|
||||
transition-all duration-75
|
||||
${isBeat ? 'w-3 h-8' : 'w-2 h-6'}
|
||||
rounded-full
|
||||
transition-all duration-50 ease-out
|
||||
${isBeat ? 'w-4' : 'w-2.5'}
|
||||
${isActive ? activeHeight : baseHeight}
|
||||
${
|
||||
isActive
|
||||
? 'bg-primary scale-110 shadow-lg shadow-primary/50'
|
||||
? 'bg-gradient-to-t from-lofi-orange via-lofi-pink to-lofi-purple shadow-[0_0_12px_rgba(255,150,100,0.6)]'
|
||||
: isBeat
|
||||
? 'bg-muted-foreground/40'
|
||||
: 'bg-muted-foreground/20'
|
||||
? 'bg-gradient-to-t from-lofi-orange/60 to-lofi-orange/30'
|
||||
: 'bg-muted-foreground/30'
|
||||
}
|
||||
${isBeat ? 'rounded-sm' : 'rounded-[2px]'}
|
||||
`}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -1,37 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@ -1,102 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@ -1,52 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@ -1,78 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@ -1,81 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@ -1,113 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
export { Mixer } from './Mixer';
|
||||
export { ChannelStrip } from './ChannelStrip';
|
||||
export { LevelMeter } from './LevelMeter';
|
||||
export { PanKnob } from './PanKnob';
|
||||
export { VerticalFader } from './VerticalFader';
|
||||
@ -1,197 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@ -1,32 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@ -1,163 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@ -1,56 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@ -1,137 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@ -1,127 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@ -1,117 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@ -1,108 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@ -1,94 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@ -1,91 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@ -1,130 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@ -1,63 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@ -1,112 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@ -1,55 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@ -1,305 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@ -1,51 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
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';
|
||||
190
components/ui/select.tsx
Normal file
190
components/ui/select.tsx
Normal file
@ -0,0 +1,190 @@
|
||||
"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,58 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { EngineState, LayerName, MeterLevels, LoopRegion } from '@/types/audio';
|
||||
import { EngineState, LayerName, Genre } from '@/types/audio';
|
||||
|
||||
const defaultState: EngineState = {
|
||||
isPlaying: false,
|
||||
isInitialized: false,
|
||||
bpm: 78,
|
||||
swing: 0.12,
|
||||
bpm: 90,
|
||||
swing: 0.15,
|
||||
currentStep: 0,
|
||||
genre: 'hiphop',
|
||||
duration: 3,
|
||||
instruments: {
|
||||
drums: 'acoustic',
|
||||
bass: 'synth',
|
||||
brass: 'trumpet',
|
||||
piano: 'grand',
|
||||
chords: 'pad',
|
||||
ambient: 'rain',
|
||||
},
|
||||
volumes: {
|
||||
master: 0.8,
|
||||
drums: 0.8,
|
||||
bass: 0.6,
|
||||
brass: 0.4,
|
||||
piano: 0.5,
|
||||
chords: 0.6,
|
||||
ambient: 0.4,
|
||||
},
|
||||
muted: {
|
||||
drums: false,
|
||||
bass: false,
|
||||
brass: true,
|
||||
piano: true,
|
||||
chords: 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() {
|
||||
const [state, setState] = useState<EngineState>(defaultState);
|
||||
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 isInitializingRef = useRef(false);
|
||||
|
||||
// Dynamically import the audio engine (client-side only)
|
||||
const getEngine = useCallback(async () => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
@ -63,7 +54,6 @@ export function useAudioEngine() {
|
||||
return engineRef.current;
|
||||
}, []);
|
||||
|
||||
// Initialize engine and set up callbacks
|
||||
const initialize = useCallback(async () => {
|
||||
if (isInitializingRef.current) return;
|
||||
isInitializingRef.current = true;
|
||||
@ -79,12 +69,6 @@ export function useAudioEngine() {
|
||||
onStateChange: (newState) => {
|
||||
setState(newState);
|
||||
},
|
||||
onBarChange: (bar, beat) => {
|
||||
setPlayheadPosition({ bar, beat });
|
||||
},
|
||||
onMeterUpdate: (levels) => {
|
||||
setMeterLevels(levels);
|
||||
},
|
||||
});
|
||||
|
||||
await engine.initialize();
|
||||
@ -94,7 +78,6 @@ export function useAudioEngine() {
|
||||
}
|
||||
}, [getEngine]);
|
||||
|
||||
// Play/pause toggle
|
||||
const togglePlayback = useCallback(async () => {
|
||||
const engine = await getEngine();
|
||||
if (!engine) return;
|
||||
@ -111,7 +94,6 @@ export function useAudioEngine() {
|
||||
}
|
||||
}, [getEngine, state.isInitialized, initialize]);
|
||||
|
||||
// Stop playback
|
||||
const stop = useCallback(async () => {
|
||||
const engine = await getEngine();
|
||||
if (!engine) return;
|
||||
@ -119,7 +101,6 @@ export function useAudioEngine() {
|
||||
setCurrentStep(0);
|
||||
}, [getEngine]);
|
||||
|
||||
// Generate new beat
|
||||
const generateNewBeat = useCallback(async () => {
|
||||
const engine = await getEngine();
|
||||
if (!engine) return;
|
||||
@ -131,136 +112,84 @@ export function useAudioEngine() {
|
||||
engine.generateNewBeat();
|
||||
}, [getEngine, state.isInitialized, initialize]);
|
||||
|
||||
// Set BPM
|
||||
const setGenre = useCallback(async (genre: Genre) => {
|
||||
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 engine = await getEngine();
|
||||
if (!engine) return;
|
||||
engine.setBpm(bpm);
|
||||
}, [getEngine]);
|
||||
|
||||
// Set swing
|
||||
const setSwing = useCallback(async (swing: number) => {
|
||||
const engine = await getEngine();
|
||||
if (!engine) return;
|
||||
engine.setSwing(swing);
|
||||
}, [getEngine]);
|
||||
|
||||
// Set master volume
|
||||
const setMasterVolume = useCallback(async (volume: number) => {
|
||||
const engine = await getEngine();
|
||||
if (!engine) return;
|
||||
engine.setMasterVolume(volume);
|
||||
}, [getEngine]);
|
||||
|
||||
// Set layer volume
|
||||
const setLayerVolume = useCallback(async (layer: LayerName, volume: number) => {
|
||||
const engine = await getEngine();
|
||||
if (!engine) return;
|
||||
engine.setLayerVolume(layer, volume);
|
||||
}, [getEngine]);
|
||||
|
||||
// Toggle layer mute
|
||||
const toggleMute = useCallback(async (layer: LayerName) => {
|
||||
const engine = await getEngine();
|
||||
if (!engine) return;
|
||||
engine.toggleMute(layer);
|
||||
}, [getEngine]);
|
||||
|
||||
// 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(() => {
|
||||
return () => {
|
||||
// Don't dispose on unmount to allow seamless navigation
|
||||
// The engine is a singleton that persists
|
||||
// Don't dispose on unmount
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
state,
|
||||
currentStep,
|
||||
meterLevels,
|
||||
pans,
|
||||
soloed,
|
||||
playheadPosition,
|
||||
initialize,
|
||||
togglePlayback,
|
||||
stop,
|
||||
generateNewBeat,
|
||||
setGenre,
|
||||
setDuration,
|
||||
setInstrument,
|
||||
setBpm,
|
||||
setSwing,
|
||||
setMasterVolume,
|
||||
setLayerVolume,
|
||||
toggleMute,
|
||||
setMuted,
|
||||
setPan,
|
||||
setSolo,
|
||||
toggleSolo,
|
||||
seek,
|
||||
setDuration,
|
||||
setLoopRegion,
|
||||
getMeterLevels,
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,277 +0,0 @@
|
||||
'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,68 +1,119 @@
|
||||
import * as Tone from 'tone';
|
||||
import { AmbientType } from '@/types/audio';
|
||||
|
||||
export class AmbientLayer {
|
||||
private rainNoise: Tone.Noise;
|
||||
private vinylNoise: Tone.Noise;
|
||||
private rainFilter: Tone.Filter;
|
||||
private vinylFilter: Tone.Filter;
|
||||
private rainGain: Tone.Gain;
|
||||
private vinylGain: Tone.Gain;
|
||||
private noise1: Tone.Noise | null = null;
|
||||
private noise2: Tone.Noise | null = null;
|
||||
private filter1: Tone.Filter | null = null;
|
||||
private filter2: Tone.Filter | null = null;
|
||||
private gain1: Tone.Gain | null = null;
|
||||
private gain2: Tone.Gain | null = null;
|
||||
private output: Tone.Gain;
|
||||
private lfo: Tone.LFO;
|
||||
private lfo: Tone.LFO | null = null;
|
||||
private currentType: AmbientType = 'rain';
|
||||
private isPlaying = false;
|
||||
|
||||
constructor(destination: Tone.InputNode) {
|
||||
this.output = new Tone.Gain(0.4);
|
||||
|
||||
// Rain sound - filtered pink noise
|
||||
this.rainNoise = new Tone.Noise('pink');
|
||||
this.rainFilter = new Tone.Filter({
|
||||
frequency: 3000,
|
||||
type: 'lowpass',
|
||||
rolloff: -24,
|
||||
});
|
||||
this.rainGain = new Tone.Gain(0.15);
|
||||
|
||||
// Vinyl crackle - filtered brown noise with modulation
|
||||
this.vinylNoise = new Tone.Noise('brown');
|
||||
this.vinylFilter = new Tone.Filter({
|
||||
frequency: 1500,
|
||||
type: 'bandpass',
|
||||
Q: 2,
|
||||
});
|
||||
this.vinylGain = new Tone.Gain(0.1);
|
||||
|
||||
// LFO for subtle rain intensity variation
|
||||
this.lfo = new Tone.LFO({
|
||||
frequency: 0.1,
|
||||
min: 0.1,
|
||||
max: 0.2,
|
||||
});
|
||||
this.lfo.connect(this.rainGain.gain);
|
||||
|
||||
// Chain rain: noise -> filter -> gain -> output
|
||||
this.rainNoise.connect(this.rainFilter);
|
||||
this.rainFilter.connect(this.rainGain);
|
||||
this.rainGain.connect(this.output);
|
||||
|
||||
// Chain vinyl: noise -> filter -> gain -> output
|
||||
this.vinylNoise.connect(this.vinylFilter);
|
||||
this.vinylFilter.connect(this.vinylGain);
|
||||
this.vinylGain.connect(this.output);
|
||||
|
||||
// Output to destination
|
||||
this.output.connect(destination);
|
||||
this.createAmbient('rain');
|
||||
}
|
||||
|
||||
private createAmbient(type: AmbientType): void {
|
||||
// Dispose existing
|
||||
this.noise1?.dispose();
|
||||
this.noise2?.dispose();
|
||||
this.filter1?.dispose();
|
||||
this.filter2?.dispose();
|
||||
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.noise1.connect(this.filter1);
|
||||
this.filter1.connect(this.gain1);
|
||||
this.gain1.connect(this.output);
|
||||
|
||||
// Secondary noise layer
|
||||
this.noise2 = new Tone.Noise(config.noise2.type);
|
||||
this.filter2 = new Tone.Filter({
|
||||
frequency: config.noise2.filter,
|
||||
type: config.noise2.filterType,
|
||||
});
|
||||
this.gain2 = new Tone.Gain(config.noise2.gain);
|
||||
this.noise2.connect(this.filter2);
|
||||
this.filter2.connect(this.gain2);
|
||||
this.gain2.connect(this.output);
|
||||
|
||||
// LFO for subtle variation
|
||||
this.lfo = new Tone.LFO({
|
||||
frequency: config.lfoFreq,
|
||||
min: config.noise1.gain * 0.7,
|
||||
max: config.noise1.gain * 1.2,
|
||||
});
|
||||
this.lfo.connect(this.gain1.gain);
|
||||
|
||||
this.currentType = type;
|
||||
|
||||
// Restart if was playing
|
||||
if (this.isPlaying) {
|
||||
this.noise1.start();
|
||||
this.noise2.start();
|
||||
this.lfo.start();
|
||||
}
|
||||
}
|
||||
|
||||
setInstrument(type: AmbientType): void {
|
||||
this.createAmbient(type);
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.rainNoise.start();
|
||||
this.vinylNoise.start();
|
||||
this.lfo.start();
|
||||
this.noise1?.start();
|
||||
this.noise2?.start();
|
||||
this.lfo?.start();
|
||||
this.isPlaying = true;
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.rainNoise.stop();
|
||||
this.vinylNoise.stop();
|
||||
this.lfo.stop();
|
||||
this.noise1?.stop();
|
||||
this.noise2?.stop();
|
||||
this.lfo?.stop();
|
||||
this.isPlaying = false;
|
||||
}
|
||||
|
||||
setVolume(volume: number): void {
|
||||
@ -73,25 +124,15 @@ export class AmbientLayer {
|
||||
this.output.gain.rampTo(muted ? 0 : 0.4, 0.1);
|
||||
}
|
||||
|
||||
setRainIntensity(intensity: number): void {
|
||||
this.rainGain.gain.rampTo(intensity * 0.2, 0.5);
|
||||
}
|
||||
|
||||
setVinylIntensity(intensity: number): void {
|
||||
this.vinylGain.gain.rampTo(intensity * 0.15, 0.5);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.rainNoise.stop();
|
||||
this.vinylNoise.stop();
|
||||
this.lfo.stop();
|
||||
this.rainNoise.dispose();
|
||||
this.vinylNoise.dispose();
|
||||
this.rainFilter.dispose();
|
||||
this.vinylFilter.dispose();
|
||||
this.rainGain.dispose();
|
||||
this.vinylGain.dispose();
|
||||
this.lfo.dispose();
|
||||
this.stop();
|
||||
this.noise1?.dispose();
|
||||
this.noise2?.dispose();
|
||||
this.filter1?.dispose();
|
||||
this.filter2?.dispose();
|
||||
this.gain1?.dispose();
|
||||
this.gain2?.dispose();
|
||||
this.lfo?.dispose();
|
||||
this.output.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,12 +2,21 @@ import * as Tone from 'tone';
|
||||
import { DrumMachine } from './drumMachine';
|
||||
import { ChordEngine } from './chordEngine';
|
||||
import { AmbientLayer } from './ambientLayer';
|
||||
import { BassEngine } from './bassEngine';
|
||||
import { BrassEngine } from './brassEngine';
|
||||
import { PianoEngine } from './pianoEngine';
|
||||
import {
|
||||
EngineState,
|
||||
AudioEngineCallbacks,
|
||||
LayerName,
|
||||
MeterLevels,
|
||||
LoopRegion,
|
||||
Genre,
|
||||
GENRE_CONFIG,
|
||||
DrumKit,
|
||||
BassType,
|
||||
BrassType,
|
||||
PianoType,
|
||||
ChordType,
|
||||
AmbientType,
|
||||
} from '@/types/audio';
|
||||
|
||||
class AudioEngine {
|
||||
@ -16,55 +25,47 @@ class AudioEngine {
|
||||
private drumMachine: DrumMachine | null = null;
|
||||
private chordEngine: ChordEngine | 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 masterCompressor: Tone.Compressor | null = null;
|
||||
private masterLimiter: Tone.Limiter | 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 state: EngineState = {
|
||||
isPlaying: false,
|
||||
isInitialized: false,
|
||||
bpm: 78,
|
||||
swing: 0.12,
|
||||
bpm: 90,
|
||||
swing: 0.15,
|
||||
currentStep: 0,
|
||||
genre: 'hiphop',
|
||||
duration: 3,
|
||||
instruments: {
|
||||
drums: 'acoustic',
|
||||
bass: 'synth',
|
||||
brass: 'trumpet',
|
||||
piano: 'grand',
|
||||
chords: 'pad',
|
||||
ambient: 'rain',
|
||||
},
|
||||
volumes: {
|
||||
master: 0.8,
|
||||
drums: 0.8,
|
||||
bass: 0.6,
|
||||
brass: 0.4,
|
||||
piano: 0.5,
|
||||
chords: 0.6,
|
||||
ambient: 0.4,
|
||||
},
|
||||
muted: {
|
||||
drums: false,
|
||||
bass: false,
|
||||
brass: true,
|
||||
piano: true,
|
||||
chords: false,
|
||||
ambient: false,
|
||||
},
|
||||
@ -82,25 +83,13 @@ class AudioEngine {
|
||||
async initialize(): Promise<void> {
|
||||
if (this.state.isInitialized) return;
|
||||
|
||||
// Start Tone.js audio context (requires user gesture)
|
||||
await Tone.start();
|
||||
|
||||
// Set up transport
|
||||
Tone.getTransport().bpm.value = this.state.bpm;
|
||||
Tone.getTransport().swing = this.state.swing;
|
||||
const genreConfig = GENRE_CONFIG[this.state.genre];
|
||||
Tone.getTransport().bpm.value = genreConfig.bpm;
|
||||
Tone.getTransport().swing = genreConfig.swing;
|
||||
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
|
||||
this.masterGain = new Tone.Gain(this.state.volumes.master);
|
||||
this.masterCompressor = new Tone.Compressor({
|
||||
@ -115,27 +104,29 @@ class AudioEngine {
|
||||
wet: 0.15,
|
||||
});
|
||||
|
||||
// Chain: gain -> reverb -> compressor -> limiter -> meter -> destination
|
||||
this.masterGain.connect(this.masterReverb);
|
||||
this.masterReverb.connect(this.masterCompressor);
|
||||
this.masterCompressor.connect(this.masterLimiter);
|
||||
this.masterLimiter.connect(this.masterMeter);
|
||||
this.masterMeter.toDestination();
|
||||
this.masterLimiter.toDestination();
|
||||
|
||||
// Initialize layers with panners and meters in chain
|
||||
this.drumMachine = new DrumMachine(this.drumPanner);
|
||||
this.chordEngine = new ChordEngine(this.chordPanner);
|
||||
this.ambientLayer = new AmbientLayer(this.ambientPanner);
|
||||
// Initialize all layers
|
||||
this.drumMachine = new DrumMachine(this.masterGain);
|
||||
this.chordEngine = new ChordEngine(this.masterGain);
|
||||
this.ambientLayer = new AmbientLayer(this.masterGain);
|
||||
this.bassEngine = new BassEngine(this.masterGain);
|
||||
this.brassEngine = new BrassEngine(this.masterGain);
|
||||
this.pianoEngine = new PianoEngine(this.masterGain);
|
||||
|
||||
// Connect panners -> meters -> master
|
||||
this.drumPanner.connect(this.drumMeter);
|
||||
this.drumMeter.connect(this.masterGain);
|
||||
// Set initial genre for all engines
|
||||
this.drumMachine.setGenre(this.state.genre);
|
||||
this.chordEngine.setGenre(this.state.genre);
|
||||
this.bassEngine.setGenre(this.state.genre);
|
||||
this.brassEngine.setGenre(this.state.genre);
|
||||
this.pianoEngine.setGenre(this.state.genre);
|
||||
|
||||
this.chordPanner.connect(this.chordMeter);
|
||||
this.chordMeter.connect(this.masterGain);
|
||||
|
||||
this.ambientPanner.connect(this.ambientMeter);
|
||||
this.ambientMeter.connect(this.masterGain);
|
||||
// Apply initial mute states
|
||||
this.brassEngine.mute(this.state.muted.brass);
|
||||
this.pianoEngine.mute(this.state.muted.piano);
|
||||
|
||||
// Create sequences
|
||||
this.drumMachine.createSequence((step) => {
|
||||
@ -143,13 +134,12 @@ class AudioEngine {
|
||||
this.callbacks.onStepChange?.(step);
|
||||
});
|
||||
this.chordEngine.createSequence();
|
||||
this.bassEngine.createSequence();
|
||||
this.brassEngine.createSequence();
|
||||
this.pianoEngine.createSequence();
|
||||
|
||||
// Start meter animation loop
|
||||
this.startMeterLoop();
|
||||
|
||||
// Start bar tracking
|
||||
this.startBarTracking();
|
||||
|
||||
this.state.bpm = genreConfig.bpm;
|
||||
this.state.swing = genreConfig.swing;
|
||||
this.state.isInitialized = true;
|
||||
this.notifyStateChange();
|
||||
}
|
||||
@ -191,11 +181,69 @@ class AudioEngine {
|
||||
generateNewBeat(): void {
|
||||
this.drumMachine?.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();
|
||||
}
|
||||
|
||||
setBpm(bpm: number): void {
|
||||
this.state.bpm = Math.max(60, Math.min(100, bpm));
|
||||
this.state.bpm = Math.max(60, Math.min(180, bpm));
|
||||
Tone.getTransport().bpm.value = this.state.bpm;
|
||||
this.notifyStateChange();
|
||||
}
|
||||
@ -220,6 +268,15 @@ class AudioEngine {
|
||||
case 'drums':
|
||||
this.drumMachine?.setVolume(normalizedVolume);
|
||||
break;
|
||||
case 'bass':
|
||||
this.bassEngine?.setVolume(normalizedVolume);
|
||||
break;
|
||||
case 'brass':
|
||||
this.brassEngine?.setVolume(normalizedVolume);
|
||||
break;
|
||||
case 'piano':
|
||||
this.pianoEngine?.setVolume(normalizedVolume);
|
||||
break;
|
||||
case 'chords':
|
||||
this.chordEngine?.setVolume(normalizedVolume);
|
||||
break;
|
||||
@ -238,6 +295,15 @@ class AudioEngine {
|
||||
case 'drums':
|
||||
this.drumMachine?.mute(this.state.muted[layer]);
|
||||
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':
|
||||
this.chordEngine?.mute(this.state.muted[layer]);
|
||||
break;
|
||||
@ -256,6 +322,15 @@ class AudioEngine {
|
||||
case 'drums':
|
||||
this.drumMachine?.mute(muted);
|
||||
break;
|
||||
case 'bass':
|
||||
this.bassEngine?.mute(muted);
|
||||
break;
|
||||
case 'brass':
|
||||
this.brassEngine?.mute(muted);
|
||||
break;
|
||||
case 'piano':
|
||||
this.pianoEngine?.mute(muted);
|
||||
break;
|
||||
case 'chords':
|
||||
this.chordEngine?.mute(muted);
|
||||
break;
|
||||
@ -271,195 +346,29 @@ class AudioEngine {
|
||||
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 {
|
||||
this.stop();
|
||||
this.stopMeterLoop();
|
||||
|
||||
if (this.barTrackerId !== null) {
|
||||
Tone.getTransport().clear(this.barTrackerId);
|
||||
this.barTrackerId = null;
|
||||
}
|
||||
|
||||
this.drumMachine?.dispose();
|
||||
this.chordEngine?.dispose();
|
||||
this.ambientLayer?.dispose();
|
||||
this.bassEngine?.dispose();
|
||||
this.brassEngine?.dispose();
|
||||
this.pianoEngine?.dispose();
|
||||
this.masterGain?.dispose();
|
||||
this.masterCompressor?.dispose();
|
||||
this.masterLimiter?.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.chordEngine = null;
|
||||
this.ambientLayer = null;
|
||||
this.bassEngine = null;
|
||||
this.brassEngine = null;
|
||||
this.pianoEngine = null;
|
||||
this.masterGain = null;
|
||||
this.masterCompressor = null;
|
||||
this.masterLimiter = 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.isPlaying = false;
|
||||
|
||||
117
lib/audio/bassEngine.ts
Normal file
117
lib/audio/bassEngine.ts
Normal file
@ -0,0 +1,117 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
120
lib/audio/brassEngine.ts
Normal file
120
lib/audio/brassEngine.ts
Normal file
@ -0,0 +1,120 @@
|
||||
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,33 +1,32 @@
|
||||
import * as Tone from 'tone';
|
||||
import { ChordProgression } from '@/types/audio';
|
||||
import { ChordProgression, ChordType, Genre } from '@/types/audio';
|
||||
import { getRandomProgression } from './patterns';
|
||||
|
||||
export class ChordEngine {
|
||||
private synth: Tone.PolySynth;
|
||||
private synth: Tone.PolySynth | null = null;
|
||||
private sequence: Tone.Sequence | null = null;
|
||||
private progression: ChordProgression;
|
||||
private output: Tone.Gain;
|
||||
private filter: Tone.Filter;
|
||||
private reverb: Tone.Reverb;
|
||||
private chorus: Tone.Chorus;
|
||||
private currentType: ChordType = 'pad';
|
||||
private genre: Genre = 'hiphop';
|
||||
|
||||
constructor(destination: Tone.InputNode) {
|
||||
this.output = new Tone.Gain(0.6);
|
||||
|
||||
// Warm lofi filter
|
||||
this.filter = new Tone.Filter({
|
||||
frequency: 2000,
|
||||
type: 'lowpass',
|
||||
rolloff: -24,
|
||||
});
|
||||
|
||||
// Dreamy reverb
|
||||
this.reverb = new Tone.Reverb({
|
||||
decay: 3,
|
||||
wet: 0.4,
|
||||
});
|
||||
|
||||
// Subtle chorus for width
|
||||
this.chorus = new Tone.Chorus({
|
||||
frequency: 0.5,
|
||||
delayTime: 3.5,
|
||||
@ -35,42 +34,73 @@ export class ChordEngine {
|
||||
wet: 0.3,
|
||||
}).start();
|
||||
|
||||
// FM Synth for warm, evolving pad sound
|
||||
this.synth = new Tone.PolySynth(Tone.FMSynth, {
|
||||
harmonicity: 2,
|
||||
modulationIndex: 1.5,
|
||||
oscillator: {
|
||||
type: 'sine',
|
||||
},
|
||||
envelope: {
|
||||
attack: 0.3,
|
||||
decay: 0.3,
|
||||
sustain: 0.8,
|
||||
release: 1.5,
|
||||
},
|
||||
modulation: {
|
||||
type: 'sine',
|
||||
},
|
||||
modulationEnvelope: {
|
||||
attack: 0.5,
|
||||
decay: 0.2,
|
||||
sustain: 0.5,
|
||||
release: 0.5,
|
||||
},
|
||||
});
|
||||
|
||||
// Lower the overall synth volume to prevent clipping
|
||||
this.synth.volume.value = -12;
|
||||
|
||||
// Chain: synth -> filter -> chorus -> reverb -> output -> destination
|
||||
this.synth.connect(this.filter);
|
||||
this.filter.connect(this.chorus);
|
||||
this.chorus.connect(this.reverb);
|
||||
this.reverb.connect(this.output);
|
||||
this.output.connect(destination);
|
||||
|
||||
// Initialize with a random progression
|
||||
this.progression = getRandomProgression();
|
||||
this.progression = getRandomProgression(this.genre);
|
||||
this.createSynth('pad');
|
||||
}
|
||||
|
||||
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 {
|
||||
@ -85,9 +115,10 @@ export class ChordEngine {
|
||||
const chord = this.progression.chords[step];
|
||||
const duration = this.progression.durations[step];
|
||||
|
||||
// Release previous notes and play new chord
|
||||
this.synth.releaseAll(time);
|
||||
this.synth.triggerAttackRelease(chord, duration, time, 0.5);
|
||||
if (this.synth) {
|
||||
this.synth.releaseAll(time);
|
||||
this.synth.triggerAttackRelease(chord, duration, time, 0.5);
|
||||
}
|
||||
},
|
||||
steps,
|
||||
'2n'
|
||||
@ -98,14 +129,13 @@ export class ChordEngine {
|
||||
|
||||
setProgression(progression: ChordProgression): void {
|
||||
this.progression = progression;
|
||||
// Recreate sequence with new progression
|
||||
if (this.sequence) {
|
||||
this.createSequence();
|
||||
}
|
||||
}
|
||||
|
||||
randomize(): ChordProgression {
|
||||
this.progression = getRandomProgression();
|
||||
this.progression = getRandomProgression(this.genre);
|
||||
if (this.sequence) {
|
||||
this.createSequence();
|
||||
}
|
||||
@ -130,7 +160,7 @@ export class ChordEngine {
|
||||
|
||||
dispose(): void {
|
||||
this.sequence?.dispose();
|
||||
this.synth.dispose();
|
||||
this.synth?.dispose();
|
||||
this.filter.dispose();
|
||||
this.reverb.dispose();
|
||||
this.chorus.dispose();
|
||||
|
||||
@ -1,16 +1,21 @@
|
||||
import * as Tone from 'tone';
|
||||
import { DrumPattern } from '@/types/audio';
|
||||
import { getRandomPattern, generateRandomPattern } from './patterns';
|
||||
import { DrumPattern, DrumKit, Genre } from '@/types/audio';
|
||||
import { getRandomPattern } from './patterns';
|
||||
|
||||
export class DrumMachine {
|
||||
private kick: Tone.MembraneSynth;
|
||||
private snare: Tone.NoiseSynth;
|
||||
private hihat: Tone.NoiseSynth;
|
||||
private openhat: Tone.NoiseSynth;
|
||||
private kick: Tone.MembraneSynth | null = null;
|
||||
private snare: Tone.NoiseSynth | null = null;
|
||||
private hihat: Tone.NoiseSynth | null = null;
|
||||
private openhat: Tone.NoiseSynth | null = null;
|
||||
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 pattern: DrumPattern;
|
||||
private output: Tone.Gain;
|
||||
private lowpass: Tone.Filter;
|
||||
private currentKit: DrumKit = 'acoustic';
|
||||
private genre: Genre = 'hiphop';
|
||||
|
||||
constructor(destination: Tone.InputNode) {
|
||||
this.output = new Tone.Gain(0.8);
|
||||
@ -20,80 +25,116 @@ export class DrumMachine {
|
||||
rolloff: -12,
|
||||
});
|
||||
|
||||
// Kick drum - deep and punchy
|
||||
this.kick = new Tone.MembraneSynth({
|
||||
pitchDecay: 0.05,
|
||||
octaves: 6,
|
||||
oscillator: { type: 'sine' },
|
||||
envelope: {
|
||||
attack: 0.001,
|
||||
decay: 0.4,
|
||||
sustain: 0.01,
|
||||
release: 0.4,
|
||||
},
|
||||
});
|
||||
|
||||
// Snare - filtered noise
|
||||
this.snare = new Tone.NoiseSynth({
|
||||
noise: { type: 'white' },
|
||||
envelope: {
|
||||
attack: 0.001,
|
||||
decay: 0.2,
|
||||
sustain: 0,
|
||||
release: 0.1,
|
||||
},
|
||||
});
|
||||
const snareFilter = new Tone.Filter({
|
||||
frequency: 5000,
|
||||
type: 'bandpass',
|
||||
Q: 1,
|
||||
});
|
||||
this.snare.connect(snareFilter);
|
||||
snareFilter.connect(this.lowpass);
|
||||
|
||||
// Closed hi-hat - high filtered noise
|
||||
this.hihat = new Tone.NoiseSynth({
|
||||
noise: { type: 'white' },
|
||||
envelope: {
|
||||
attack: 0.001,
|
||||
decay: 0.05,
|
||||
sustain: 0,
|
||||
release: 0.02,
|
||||
},
|
||||
});
|
||||
const hihatFilter = new Tone.Filter({
|
||||
frequency: 10000,
|
||||
type: 'highpass',
|
||||
});
|
||||
this.hihat.connect(hihatFilter);
|
||||
hihatFilter.connect(this.lowpass);
|
||||
|
||||
// Open hi-hat
|
||||
this.openhat = new Tone.NoiseSynth({
|
||||
noise: { type: 'white' },
|
||||
envelope: {
|
||||
attack: 0.001,
|
||||
decay: 0.3,
|
||||
sustain: 0,
|
||||
release: 0.15,
|
||||
},
|
||||
});
|
||||
const openhatFilter = new Tone.Filter({
|
||||
frequency: 8000,
|
||||
type: 'highpass',
|
||||
});
|
||||
this.openhat.connect(openhatFilter);
|
||||
openhatFilter.connect(this.lowpass);
|
||||
|
||||
// Connect kick directly to lowpass
|
||||
this.kick.connect(this.lowpass);
|
||||
|
||||
// Chain: lowpass -> output -> destination
|
||||
this.lowpass.connect(this.output);
|
||||
this.output.connect(destination);
|
||||
|
||||
// Initialize with a random pattern
|
||||
this.pattern = getRandomPattern();
|
||||
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({
|
||||
...config.kick,
|
||||
oscillator: { type: 'sine' },
|
||||
});
|
||||
this.kick.connect(this.lowpass);
|
||||
|
||||
// Snare
|
||||
this.snareFilter = new Tone.Filter({
|
||||
frequency: config.snareFilter,
|
||||
type: 'bandpass',
|
||||
Q: 1,
|
||||
});
|
||||
this.snare = new Tone.NoiseSynth({
|
||||
noise: { type: 'white' },
|
||||
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
|
||||
this.hihatFilter = new Tone.Filter({
|
||||
frequency: config.hihatFilter,
|
||||
type: 'highpass',
|
||||
});
|
||||
this.hihat = new Tone.NoiseSynth({
|
||||
noise: { type: 'white' },
|
||||
envelope: { attack: 0.001, decay: config.hihatDecay, sustain: 0, release: 0.02 },
|
||||
});
|
||||
this.hihat.connect(this.hihatFilter);
|
||||
this.hihatFilter.connect(this.lowpass);
|
||||
|
||||
// Open hi-hat
|
||||
this.openhatFilter = new Tone.Filter({
|
||||
frequency: config.hihatFilter - 2000,
|
||||
type: 'highpass',
|
||||
});
|
||||
this.openhat = new Tone.NoiseSynth({
|
||||
noise: { type: 'white' },
|
||||
envelope: { attack: 0.001, decay: 0.3, sustain: 0, release: 0.15 },
|
||||
});
|
||||
this.openhat.connect(this.openhatFilter);
|
||||
this.openhatFilter.connect(this.lowpass);
|
||||
|
||||
this.currentKit = kit;
|
||||
}
|
||||
|
||||
setInstrument(kit: DrumKit): void {
|
||||
this.createKit(kit);
|
||||
}
|
||||
|
||||
setGenre(genre: Genre): void {
|
||||
this.genre = genre;
|
||||
this.pattern = getRandomPattern(genre);
|
||||
}
|
||||
|
||||
createSequence(onStep?: (step: number) => void): void {
|
||||
@ -105,20 +146,19 @@ export class DrumMachine {
|
||||
|
||||
this.sequence = new Tone.Sequence(
|
||||
(time, step) => {
|
||||
if (this.pattern.kick[step]) {
|
||||
if (this.pattern.kick[step] && this.kick) {
|
||||
this.kick.triggerAttackRelease('C1', '8n', time, 0.8);
|
||||
}
|
||||
if (this.pattern.snare[step]) {
|
||||
if (this.pattern.snare[step] && this.snare) {
|
||||
this.snare.triggerAttackRelease('8n', time, 0.5);
|
||||
}
|
||||
if (this.pattern.hihat[step]) {
|
||||
if (this.pattern.hihat[step] && this.hihat) {
|
||||
this.hihat.triggerAttackRelease('32n', time, 0.3);
|
||||
}
|
||||
if (this.pattern.openhat[step]) {
|
||||
if (this.pattern.openhat[step] && this.openhat) {
|
||||
this.openhat.triggerAttackRelease('16n', time, 0.25);
|
||||
}
|
||||
|
||||
// Call step callback on main thread
|
||||
if (onStep) {
|
||||
Tone.getDraw().schedule(() => {
|
||||
onStep(step);
|
||||
@ -137,7 +177,7 @@ export class DrumMachine {
|
||||
}
|
||||
|
||||
randomize(): DrumPattern {
|
||||
this.pattern = Math.random() > 0.5 ? getRandomPattern() : generateRandomPattern();
|
||||
this.pattern = getRandomPattern(this.genre);
|
||||
return this.pattern;
|
||||
}
|
||||
|
||||
@ -155,10 +195,13 @@ export class DrumMachine {
|
||||
|
||||
dispose(): void {
|
||||
this.sequence?.dispose();
|
||||
this.kick.dispose();
|
||||
this.snare.dispose();
|
||||
this.hihat.dispose();
|
||||
this.openhat.dispose();
|
||||
this.kick?.dispose();
|
||||
this.snare?.dispose();
|
||||
this.hihat?.dispose();
|
||||
this.openhat?.dispose();
|
||||
this.snareFilter?.dispose();
|
||||
this.hihatFilter?.dispose();
|
||||
this.openhatFilter?.dispose();
|
||||
this.lowpass.dispose();
|
||||
this.output.dispose();
|
||||
}
|
||||
|
||||
@ -1,145 +1,238 @@
|
||||
import { DrumPattern, ChordProgression } from '@/types/audio';
|
||||
import { DrumPattern, ChordProgression, Genre } from '@/types/audio';
|
||||
|
||||
// Classic boom bap patterns
|
||||
export const drumPatterns: DrumPattern[] = [
|
||||
{
|
||||
// Classic boom bap
|
||||
kick: [true, false, false, false, false, false, false, false, true, false, true, false, false, false, false, false],
|
||||
snare: [false, false, false, false, true, false, false, false, false, false, false, false, true, false, false, false],
|
||||
hihat: [true, false, true, false, true, false, true, false, true, false, true, false, true, false, true, false],
|
||||
openhat: [false, false, false, false, false, false, false, true, false, false, false, false, false, false, false, true],
|
||||
},
|
||||
{
|
||||
// Laid back groove
|
||||
kick: [true, false, false, false, false, false, true, false, true, false, false, false, false, false, false, false],
|
||||
snare: [false, false, false, false, true, false, false, false, false, false, false, false, true, false, false, true],
|
||||
hihat: [true, true, true, false, true, true, true, false, true, true, true, false, true, true, true, false],
|
||||
openhat: [false, false, false, true, false, false, false, true, false, false, false, true, false, false, false, false],
|
||||
},
|
||||
{
|
||||
// Minimal chill
|
||||
kick: [true, false, false, false, false, false, false, false, true, false, false, false, false, false, true, false],
|
||||
snare: [false, false, false, false, true, false, false, false, false, false, false, false, true, false, false, false],
|
||||
hihat: [true, false, true, true, true, false, true, true, true, false, true, true, true, false, true, true],
|
||||
openhat: [false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, true],
|
||||
},
|
||||
{
|
||||
// Jazzy swing
|
||||
kick: [true, false, false, true, false, false, true, false, false, false, true, false, false, false, false, false],
|
||||
snare: [false, false, false, false, true, false, false, false, false, true, false, false, true, false, false, true],
|
||||
hihat: [true, false, true, false, true, false, true, false, true, false, true, false, true, false, true, false],
|
||||
openhat: [false, false, false, false, false, true, false, false, false, false, false, false, false, true, false, false],
|
||||
},
|
||||
{
|
||||
// Deep pocket
|
||||
kick: [true, false, false, false, false, false, false, true, false, false, true, false, false, false, false, false],
|
||||
snare: [false, false, false, false, true, false, false, false, false, false, false, false, true, false, false, false],
|
||||
hihat: [true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true],
|
||||
openhat: [false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, true],
|
||||
},
|
||||
];
|
||||
// Genre-specific drum patterns
|
||||
export const drumPatterns: Record<Genre, DrumPattern[]> = {
|
||||
hiphop: [
|
||||
{
|
||||
kick: [true, false, false, false, false, false, false, false, true, false, true, false, false, false, false, false],
|
||||
snare: [false, false, false, false, true, false, false, false, false, false, false, false, true, false, false, false],
|
||||
hihat: [true, false, true, false, true, false, true, false, true, false, true, false, true, false, true, false],
|
||||
openhat: [false, false, false, false, false, false, false, true, false, false, false, false, false, false, false, true],
|
||||
},
|
||||
{
|
||||
kick: [true, false, false, false, false, false, true, false, true, false, false, false, false, false, false, false],
|
||||
snare: [false, false, false, false, true, false, false, false, false, false, false, false, true, false, false, true],
|
||||
hihat: [true, true, true, false, true, true, true, false, true, true, true, false, true, true, true, false],
|
||||
openhat: [false, false, false, true, false, false, false, true, false, false, false, true, false, false, false, false],
|
||||
},
|
||||
],
|
||||
classical: [
|
||||
{
|
||||
kick: [true, false, false, false, false, false, false, false, true, false, false, false, false, false, false, false],
|
||||
snare: [false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false],
|
||||
hihat: [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, false],
|
||||
},
|
||||
{
|
||||
kick: [true, false, false, false, true, false, false, false, true, false, false, false, true, false, false, false],
|
||||
snare: [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, false, false, false, false, false, false, false, false, false, false, false],
|
||||
},
|
||||
],
|
||||
trap: [
|
||||
{
|
||||
kick: [true, false, false, false, false, false, true, false, false, false, true, false, false, false, false, false],
|
||||
snare: [false, false, false, false, true, false, false, false, false, false, false, false, true, false, false, false],
|
||||
hihat: [true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true],
|
||||
openhat: [false, false, false, false, false, false, false, false, false, false, false, false, false, false, true, false],
|
||||
},
|
||||
{
|
||||
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],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Jazz chord progressions in lofi style
|
||||
export const chordProgressions: ChordProgression[] = [
|
||||
{
|
||||
name: 'Classic ii-V-I',
|
||||
chords: [
|
||||
['D3', 'F3', 'A3', 'C4'], // Dm7
|
||||
['G2', 'B2', 'D3', 'F3'], // G7
|
||||
['C3', 'E3', 'G3', 'B3'], // Cmaj7
|
||||
['C3', 'E3', 'G3', 'B3'], // Cmaj7
|
||||
],
|
||||
durations: ['2n', '2n', '2n', '2n'],
|
||||
},
|
||||
{
|
||||
name: 'Minor Key Chill',
|
||||
chords: [
|
||||
['A2', 'C3', 'E3', 'G3'], // Am7
|
||||
['D3', 'F3', 'A3', 'C4'], // Dm7
|
||||
['E2', 'G#2', 'B2', 'D3'], // E7
|
||||
['A2', 'C3', 'E3', 'G3'], // Am7
|
||||
],
|
||||
durations: ['2n', '2n', '2n', '2n'],
|
||||
},
|
||||
{
|
||||
name: 'Neo Soul',
|
||||
chords: [
|
||||
['F3', 'A3', 'C4', 'E4'], // Fmaj7
|
||||
['E3', 'G3', 'B3', 'D4'], // Em7
|
||||
['D3', 'F3', 'A3', 'C4'], // Dm7
|
||||
['G2', 'B2', 'D3', 'F3'], // G7
|
||||
],
|
||||
durations: ['2n', '2n', '2n', '2n'],
|
||||
},
|
||||
{
|
||||
name: 'Dreamy',
|
||||
chords: [
|
||||
['C3', 'E3', 'G3', 'B3'], // Cmaj7
|
||||
['A2', 'C3', 'E3', 'G3'], // Am7
|
||||
['F3', 'A3', 'C4', 'E4'], // Fmaj7
|
||||
['G2', 'B2', 'D3', 'F3'], // G7
|
||||
],
|
||||
durations: ['2n', '2n', '2n', '2n'],
|
||||
},
|
||||
{
|
||||
name: 'Melancholy',
|
||||
chords: [
|
||||
['D3', 'F3', 'A3', 'C4'], // Dm7
|
||||
['G2', 'Bb2', 'D3', 'F3'], // Gm7
|
||||
['C3', 'Eb3', 'G3', 'Bb3'],// Cm7
|
||||
['F2', 'A2', 'C3', 'Eb3'], // F7
|
||||
],
|
||||
durations: ['2n', '2n', '2n', '2n'],
|
||||
},
|
||||
];
|
||||
// Genre-specific chord progressions
|
||||
export const chordProgressions: Record<Genre, ChordProgression[]> = {
|
||||
hiphop: [
|
||||
{
|
||||
name: 'Classic ii-V-I',
|
||||
chords: [
|
||||
['D3', 'F3', 'A3', 'C4'],
|
||||
['G2', 'B2', 'D3', 'F3'],
|
||||
['C3', 'E3', 'G3', 'B3'],
|
||||
['C3', 'E3', 'G3', 'B3'],
|
||||
],
|
||||
durations: ['2n', '2n', '2n', '2n'],
|
||||
},
|
||||
{
|
||||
name: 'Minor Key Chill',
|
||||
chords: [
|
||||
['A2', 'C3', 'E3', 'G3'],
|
||||
['D3', 'F3', 'A3', 'C4'],
|
||||
['E2', 'G#2', 'B2', 'D3'],
|
||||
['A2', 'C3', 'E3', 'G3'],
|
||||
],
|
||||
durations: ['2n', '2n', '2n', '2n'],
|
||||
},
|
||||
],
|
||||
classical: [
|
||||
{
|
||||
name: 'Romantic',
|
||||
chords: [
|
||||
['C3', 'E3', 'G3'],
|
||||
['F3', 'A3', 'C4'],
|
||||
['G3', 'B3', 'D4'],
|
||||
['C3', 'E3', 'G3'],
|
||||
],
|
||||
durations: ['1n', '1n', '1n', '1n'],
|
||||
},
|
||||
{
|
||||
name: 'Baroque',
|
||||
chords: [
|
||||
['D3', 'F3', 'A3'],
|
||||
['G2', 'B2', 'D3'],
|
||||
['C3', 'E3', 'G3'],
|
||||
['A2', 'C3', 'E3'],
|
||||
],
|
||||
durations: ['1n', '1n', '1n', '1n'],
|
||||
},
|
||||
],
|
||||
trap: [
|
||||
{
|
||||
name: 'Dark Minor',
|
||||
chords: [
|
||||
['A2', 'C3', 'E3'],
|
||||
['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'],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export function getRandomPattern(): DrumPattern {
|
||||
return drumPatterns[Math.floor(Math.random() * drumPatterns.length)];
|
||||
// Bass patterns by genre
|
||||
export const bassPatterns: Record<Genre, string[][]> = {
|
||||
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(): ChordProgression {
|
||||
return chordProgressions[Math.floor(Math.random() * chordProgressions.length)];
|
||||
export function getRandomProgression(genre: Genre): ChordProgression {
|
||||
const progressions = chordProgressions[genre];
|
||||
return progressions[Math.floor(Math.random() * progressions.length)];
|
||||
}
|
||||
|
||||
export function generateRandomPattern(): DrumPattern {
|
||||
const pattern: DrumPattern = {
|
||||
kick: new Array(16).fill(false),
|
||||
snare: new Array(16).fill(false),
|
||||
hihat: new Array(16).fill(false),
|
||||
openhat: new Array(16).fill(false),
|
||||
};
|
||||
|
||||
// Kick on 1 and somewhere in second half
|
||||
pattern.kick[0] = true;
|
||||
pattern.kick[8 + Math.floor(Math.random() * 4)] = true;
|
||||
if (Math.random() > 0.5) {
|
||||
pattern.kick[6 + Math.floor(Math.random() * 2)] = true;
|
||||
}
|
||||
|
||||
// Snare on 2 and 4
|
||||
pattern.snare[4] = true;
|
||||
pattern.snare[12] = true;
|
||||
// Ghost notes
|
||||
if (Math.random() > 0.6) {
|
||||
pattern.snare[Math.floor(Math.random() * 16)] = true;
|
||||
}
|
||||
|
||||
// Hi-hats with variation
|
||||
for (let i = 0; i < 16; i++) {
|
||||
if (i % 2 === 0) {
|
||||
pattern.hihat[i] = Math.random() > 0.1;
|
||||
} else {
|
||||
pattern.hihat[i] = Math.random() > 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
// Open hi-hat occasionally
|
||||
if (Math.random() > 0.3) {
|
||||
pattern.openhat[7] = true;
|
||||
}
|
||||
if (Math.random() > 0.5) {
|
||||
pattern.openhat[15] = true;
|
||||
}
|
||||
|
||||
return pattern;
|
||||
export function getRandomBassPattern(genre: Genre): (string | null)[] {
|
||||
const patterns = bassPatterns[genre];
|
||||
return patterns[Math.floor(Math.random() * patterns.length)];
|
||||
}
|
||||
|
||||
export function getRandomBrassPattern(genre: Genre): (string | null)[] {
|
||||
const patterns = brassPatterns[genre];
|
||||
return patterns[Math.floor(Math.random() * patterns.length)];
|
||||
}
|
||||
|
||||
export function getRandomPianoPattern(genre: Genre): (string[] | null)[] {
|
||||
const patterns = pianoPatterns[genre];
|
||||
return patterns[Math.floor(Math.random() * patterns.length)];
|
||||
}
|
||||
|
||||
138
lib/audio/pianoEngine.ts
Normal file
138
lib/audio/pianoEngine.ts
Normal file
@ -0,0 +1,138 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -1,176 +0,0 @@
|
||||
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,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
|
||||
166
types/audio.ts
166
types/audio.ts
@ -1,3 +1,12 @@
|
||||
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 {
|
||||
kick: boolean[];
|
||||
snare: boolean[];
|
||||
@ -11,118 +20,97 @@ export interface ChordProgression {
|
||||
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 {
|
||||
isPlaying: boolean;
|
||||
isInitialized: boolean;
|
||||
bpm: number;
|
||||
swing: number;
|
||||
currentStep: number;
|
||||
genre: Genre;
|
||||
duration: number; // in minutes
|
||||
instruments: InstrumentConfig;
|
||||
volumes: {
|
||||
master: number;
|
||||
drums: number;
|
||||
bass: number;
|
||||
brass: number;
|
||||
piano: number;
|
||||
chords: number;
|
||||
ambient: number;
|
||||
};
|
||||
muted: {
|
||||
drums: boolean;
|
||||
bass: boolean;
|
||||
brass: boolean;
|
||||
piano: boolean;
|
||||
chords: boolean;
|
||||
ambient: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export type LayerName = 'drums' | 'chords' | 'ambient';
|
||||
export type LayerName = 'drums' | 'bass' | 'brass' | 'piano' | 'chords' | 'ambient';
|
||||
|
||||
export interface AudioEngineCallbacks {
|
||||
onStepChange?: (step: number) => void;
|
||||
onStateChange?: (state: EngineState) => void;
|
||||
onBarChange?: (bar: number, beat: number) => void;
|
||||
onMeterUpdate?: (levels: MeterLevels) => void;
|
||||
}
|
||||
|
||||
// Mixer types
|
||||
export interface MeterLevels {
|
||||
drums: number;
|
||||
chords: number;
|
||||
ambient: number;
|
||||
master: number;
|
||||
}
|
||||
|
||||
export interface MixerState {
|
||||
volumes: Record<LayerName | 'master', number>;
|
||||
pans: Record<LayerName, number>;
|
||||
muted: Record<LayerName, boolean>;
|
||||
soloed: Record<LayerName, boolean>;
|
||||
levels: MeterLevels;
|
||||
}
|
||||
|
||||
// Timeline types
|
||||
export type SectionType = 'intro' | 'verse' | 'chorus' | 'drop' | 'bridge' | 'outro';
|
||||
|
||||
export interface Section {
|
||||
id: string;
|
||||
type: SectionType;
|
||||
name: string;
|
||||
startBar: number;
|
||||
endBar: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface PatternKeyframe {
|
||||
id: string;
|
||||
bar: number;
|
||||
patternIndex: number;
|
||||
}
|
||||
|
||||
export interface ChordKeyframe {
|
||||
id: string;
|
||||
bar: number;
|
||||
progressionIndex: number;
|
||||
}
|
||||
|
||||
export interface AutomationPoint {
|
||||
id: string;
|
||||
bar: number;
|
||||
beat: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
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)',
|
||||
export const GENRE_CONFIG: Record<Genre, { bpm: number; swing: number; name: string }> = {
|
||||
hiphop: { bpm: 90, swing: 0.15, name: 'Hip Hop' },
|
||||
classical: { bpm: 72, swing: 0, name: 'Classical' },
|
||||
trap: { bpm: 140, swing: 0.05, name: 'Trap' },
|
||||
pop: { bpm: 120, swing: 0.08, name: 'Pop' },
|
||||
};
|
||||
|
||||
export const INSTRUMENT_OPTIONS = {
|
||||
drums: [
|
||||
{ value: 'acoustic', label: 'Acoustic Kit' },
|
||||
{ value: 'electronic', label: 'Electronic' },
|
||||
{ value: '808', label: '808 Machine' },
|
||||
{ value: 'orchestral', label: 'Orchestral' },
|
||||
],
|
||||
bass: [
|
||||
{ value: 'synth', label: 'Synth Bass' },
|
||||
{ value: 'sub', label: 'Sub Bass' },
|
||||
{ value: 'electric', label: 'Electric Bass' },
|
||||
{ value: 'upright', label: 'Upright Bass' },
|
||||
],
|
||||
brass: [
|
||||
{ value: 'trumpet', label: 'Trumpet' },
|
||||
{ value: 'horn', label: 'French Horn' },
|
||||
{ value: 'synth-brass', label: 'Synth Brass' },
|
||||
{ value: 'orchestra', label: 'Brass Section' },
|
||||
],
|
||||
piano: [
|
||||
{ value: 'grand', label: 'Grand Piano' },
|
||||
{ value: 'electric', label: 'Electric Piano' },
|
||||
{ value: 'rhodes', label: 'Rhodes' },
|
||||
{ value: 'synth', label: 'Synth Keys' },
|
||||
],
|
||||
chords: [
|
||||
{ value: 'pad', label: 'Synth Pad' },
|
||||
{ value: 'strings', label: 'Strings' },
|
||||
{ value: 'organ', label: 'Organ' },
|
||||
{ value: 'synth', label: 'Synth Lead' },
|
||||
],
|
||||
ambient: [
|
||||
{ value: 'rain', label: 'Rain' },
|
||||
{ value: 'vinyl', label: 'Vinyl Crackle' },
|
||||
{ value: 'nature', label: 'Nature' },
|
||||
{ value: 'space', label: 'Space Atmosphere' },
|
||||
],
|
||||
} as const;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user