feat: mvp
5c472968
21 file(s) · +1114 −419
| 2 | 2 | <html lang="en"> |
|
| 3 | 3 | <head> |
|
| 4 | 4 | <meta charset="UTF-8" /> |
|
| 5 | - | <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> |
|
| 6 | 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|
| 7 | - | <title>murmur</title> |
|
| 6 | + | <meta name="theme-color" content="#121113" /> |
|
| 7 | + | <title>Murmur</title> |
|
| 8 | + | <meta name="description" content="Boids murmuration that triggers MIDI on a scale grid" /> |
|
| 9 | + | ||
| 10 | + | <!-- Open Graph --> |
|
| 11 | + | <meta property="og:type" content="website" /> |
|
| 12 | + | <meta property="og:url" content="https://murmur.darkmatter.build" /> |
|
| 13 | + | <meta property="og:title" content="Murmur" /> |
|
| 14 | + | <meta property="og:description" content="Boids murmuration that triggers MIDI on a scale grid" /> |
|
| 15 | + | <meta property="og:image" content="https://murmur.darkmatter.build/og.png" /> |
|
| 16 | + | <meta property="og:site_name" content="Murmur" /> |
|
| 17 | + | ||
| 18 | + | <!-- Twitter --> |
|
| 19 | + | <meta name="twitter:card" content="summary_large_image" /> |
|
| 20 | + | <meta name="twitter:url" content="https://murmur.darkmatter.build" /> |
|
| 21 | + | <meta name="twitter:title" content="Murmur" /> |
|
| 22 | + | <meta name="twitter:description" content="Boids murmuration that triggers MIDI on a scale grid" /> |
|
| 23 | + | <meta name="twitter:image" content="https://murmur.darkmatter.build/og.png" /> |
|
| 24 | + | ||
| 25 | + | <!-- Favicons --> |
|
| 26 | + | <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png"> |
|
| 27 | + | <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png"> |
|
| 28 | + | <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png"> |
|
| 29 | + | <link rel="manifest" href="/site.webmanifest"> |
|
| 8 | 30 | </head> |
|
| 9 | 31 | <body> |
|
| 10 | 32 | <div id="root"></div> |
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
| 1 | - | <svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-49.64" y="2.03" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-45.045" y="20.029" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-43.513" y="21.178" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="g" width="74.749" height="58.852" x="15.756" y="-17.901" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="h" width="61.377" height="25.362" x="23.548" y="2.284" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="i" width="61.377" height="25.362" x="23.548" y="2.284" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="j" width="56.045" height="63.649" x="-27.636" y="-22.853" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="k" width="54.814" height="64.646" x="20.116" y="-38.415" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="l" width="33.541" height="35.313" x="24.641" y="-11.323" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="m" width="54.814" height="64.646" x="-29.286" y="6.009" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="n" width="54.814" height="64.646" x="-29.286" y="6.009" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="o" width="54.814" height="64.646" x="8.244" y="-2.416" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="p" width="39.409" height="43.623" x="18.713" y="10.588" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter></defs></svg> |
Binary file — no preview.
| 1 | - | <svg xmlns="http://www.w3.org/2000/svg"> |
|
| 2 | - | <symbol id="bluesky-icon" viewBox="0 0 16 17"> |
|
| 3 | - | <g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g> |
|
| 4 | - | <defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs> |
|
| 5 | - | </symbol> |
|
| 6 | - | <symbol id="discord-icon" viewBox="0 0 20 19"> |
|
| 7 | - | <path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/> |
|
| 8 | - | </symbol> |
|
| 9 | - | <symbol id="documentation-icon" viewBox="0 0 21 20"> |
|
| 10 | - | <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/> |
|
| 11 | - | <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/> |
|
| 12 | - | <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/> |
|
| 13 | - | </symbol> |
|
| 14 | - | <symbol id="github-icon" viewBox="0 0 19 19"> |
|
| 15 | - | <path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/> |
|
| 16 | - | </symbol> |
|
| 17 | - | <symbol id="social-icon" viewBox="0 0 20 20"> |
|
| 18 | - | <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/> |
|
| 19 | - | <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/> |
|
| 20 | - | </symbol> |
|
| 21 | - | <symbol id="x-icon" viewBox="0 0 19 19"> |
|
| 22 | - | <path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/> |
|
| 23 | - | </symbol> |
|
| 24 | - | </svg> |
Binary file — no preview.
| 1 | + | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} |
| 1 | - | .counter { |
|
| 2 | - | font-size: 16px; |
|
| 3 | - | padding: 5px 10px; |
|
| 4 | - | border-radius: 5px; |
|
| 5 | - | color: var(--accent); |
|
| 6 | - | background: var(--accent-bg); |
|
| 7 | - | border: 2px solid transparent; |
|
| 8 | - | transition: border-color 0.3s; |
|
| 9 | - | margin-bottom: 24px; |
|
| 10 | - | ||
| 11 | - | &:hover { |
|
| 12 | - | border-color: var(--accent-border); |
|
| 13 | - | } |
|
| 14 | - | &:focus-visible { |
|
| 15 | - | outline: 2px solid var(--accent); |
|
| 16 | - | outline-offset: 2px; |
|
| 17 | - | } |
|
| 1 | + | .murmurations-canvas { |
|
| 2 | + | position: fixed; |
|
| 3 | + | inset: 0; |
|
| 4 | + | width: 100%; |
|
| 5 | + | height: 100%; |
|
| 6 | + | display: block; |
|
| 7 | + | touch-action: none; |
|
| 18 | 8 | } |
|
| 19 | 9 | ||
| 20 | - | .hero { |
|
| 21 | - | position: relative; |
|
| 22 | - | ||
| 23 | - | .base, |
|
| 24 | - | .framework, |
|
| 25 | - | .vite { |
|
| 26 | - | inset-inline: 0; |
|
| 27 | - | margin: 0 auto; |
|
| 28 | - | } |
|
| 29 | - | ||
| 30 | - | .base { |
|
| 31 | - | width: 170px; |
|
| 32 | - | position: relative; |
|
| 33 | - | z-index: 0; |
|
| 34 | - | } |
|
| 35 | - | ||
| 36 | - | .framework, |
|
| 37 | - | .vite { |
|
| 38 | - | position: absolute; |
|
| 39 | - | } |
|
| 40 | - | ||
| 41 | - | .framework { |
|
| 42 | - | z-index: 1; |
|
| 43 | - | top: 34px; |
|
| 44 | - | height: 28px; |
|
| 45 | - | transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) |
|
| 46 | - | scale(1.4); |
|
| 47 | - | } |
|
| 48 | - | ||
| 49 | - | .vite { |
|
| 50 | - | z-index: 0; |
|
| 51 | - | top: 107px; |
|
| 52 | - | height: 26px; |
|
| 53 | - | width: auto; |
|
| 54 | - | transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) |
|
| 55 | - | scale(0.8); |
|
| 56 | - | } |
|
| 10 | + | .controls { |
|
| 11 | + | position: fixed; |
|
| 12 | + | top: 1rem; |
|
| 13 | + | right: 1rem; |
|
| 14 | + | z-index: 10; |
|
| 15 | + | background: rgba(18, 17, 19, 0.85); |
|
| 16 | + | border: 1px solid rgba(245, 243, 238, 0.2); |
|
| 17 | + | padding: 0.75rem; |
|
| 18 | + | font-size: 12px; |
|
| 19 | + | color: #f5f3ee; |
|
| 20 | + | display: flex; |
|
| 21 | + | flex-direction: column; |
|
| 22 | + | gap: 0.5rem; |
|
| 23 | + | min-width: 220px; |
|
| 24 | + | backdrop-filter: blur(4px); |
|
| 57 | 25 | } |
|
| 58 | 26 | ||
| 59 | - | #center { |
|
| 60 | - | display: flex; |
|
| 61 | - | flex-direction: column; |
|
| 62 | - | gap: 25px; |
|
| 63 | - | place-content: center; |
|
| 64 | - | place-items: center; |
|
| 65 | - | flex-grow: 1; |
|
| 66 | - | ||
| 67 | - | @media (max-width: 1024px) { |
|
| 68 | - | padding: 32px 20px 24px; |
|
| 69 | - | gap: 18px; |
|
| 70 | - | } |
|
| 27 | + | .controls label { |
|
| 28 | + | display: flex; |
|
| 29 | + | justify-content: space-between; |
|
| 30 | + | align-items: center; |
|
| 31 | + | gap: 0.5rem; |
|
| 71 | 32 | } |
|
| 72 | 33 | ||
| 73 | - | #next-steps { |
|
| 74 | - | display: flex; |
|
| 75 | - | border-top: 1px solid var(--border); |
|
| 76 | - | text-align: left; |
|
| 77 | - | ||
| 78 | - | & > div { |
|
| 79 | - | flex: 1 1 0; |
|
| 80 | - | padding: 32px; |
|
| 81 | - | @media (max-width: 1024px) { |
|
| 82 | - | padding: 24px 20px; |
|
| 83 | - | } |
|
| 84 | - | } |
|
| 85 | - | ||
| 86 | - | .icon { |
|
| 87 | - | margin-bottom: 16px; |
|
| 88 | - | width: 22px; |
|
| 89 | - | height: 22px; |
|
| 90 | - | } |
|
| 91 | - | ||
| 92 | - | @media (max-width: 1024px) { |
|
| 93 | - | flex-direction: column; |
|
| 94 | - | text-align: center; |
|
| 95 | - | } |
|
| 34 | + | .controls select, |
|
| 35 | + | .controls input[type='range'], |
|
| 36 | + | .controls button { |
|
| 37 | + | background: rgba(245, 243, 238, 0.06); |
|
| 38 | + | border: 1px solid rgba(245, 243, 238, 0.2); |
|
| 39 | + | color: #f5f3ee; |
|
| 40 | + | font: inherit; |
|
| 41 | + | padding: 0.25rem 0.5rem; |
|
| 96 | 42 | } |
|
| 97 | 43 | ||
| 98 | - | #docs { |
|
| 99 | - | border-right: 1px solid var(--border); |
|
| 100 | - | ||
| 101 | - | @media (max-width: 1024px) { |
|
| 102 | - | border-right: none; |
|
| 103 | - | border-bottom: 1px solid var(--border); |
|
| 104 | - | } |
|
| 44 | + | .controls button { |
|
| 45 | + | cursor: pointer; |
|
| 105 | 46 | } |
|
| 106 | 47 | ||
| 107 | - | #next-steps ul { |
|
| 108 | - | list-style: none; |
|
| 109 | - | padding: 0; |
|
| 110 | - | display: flex; |
|
| 111 | - | gap: 8px; |
|
| 112 | - | margin: 32px 0 0; |
|
| 113 | - | ||
| 114 | - | .logo { |
|
| 115 | - | height: 18px; |
|
| 116 | - | } |
|
| 117 | - | ||
| 118 | - | a { |
|
| 119 | - | color: var(--text-h); |
|
| 120 | - | font-size: 16px; |
|
| 121 | - | border-radius: 6px; |
|
| 122 | - | background: var(--social-bg); |
|
| 123 | - | display: flex; |
|
| 124 | - | padding: 6px 12px; |
|
| 125 | - | align-items: center; |
|
| 126 | - | gap: 8px; |
|
| 127 | - | text-decoration: none; |
|
| 128 | - | transition: box-shadow 0.3s; |
|
| 48 | + | .controls button:hover { |
|
| 49 | + | background: rgba(245, 243, 238, 0.15); |
|
| 50 | + | } |
|
| 129 | 51 | ||
| 130 | - | &:hover { |
|
| 131 | - | box-shadow: var(--shadow); |
|
| 132 | - | } |
|
| 133 | - | .button-icon { |
|
| 134 | - | height: 18px; |
|
| 135 | - | width: 18px; |
|
| 136 | - | } |
|
| 137 | - | } |
|
| 138 | - | ||
| 139 | - | @media (max-width: 1024px) { |
|
| 140 | - | margin-top: 20px; |
|
| 141 | - | flex-wrap: wrap; |
|
| 142 | - | justify-content: center; |
|
| 143 | - | ||
| 144 | - | li { |
|
| 145 | - | flex: 1 1 calc(50% - 8px); |
|
| 146 | - | } |
|
| 147 | - | ||
| 148 | - | a { |
|
| 149 | - | width: 100%; |
|
| 150 | - | justify-content: center; |
|
| 151 | - | box-sizing: border-box; |
|
| 152 | - | } |
|
| 153 | - | } |
|
| 52 | + | .controls .status { |
|
| 53 | + | font-size: 11px; |
|
| 54 | + | opacity: 0.6; |
|
| 154 | 55 | } |
|
| 155 | 56 | ||
| 156 | - | #spacer { |
|
| 157 | - | height: 88px; |
|
| 158 | - | border-top: 1px solid var(--border); |
|
| 159 | - | @media (max-width: 1024px) { |
|
| 160 | - | height: 48px; |
|
| 161 | - | } |
|
| 57 | + | .controls .section { |
|
| 58 | + | font-size: 11px; |
|
| 59 | + | opacity: 0.7; |
|
| 60 | + | text-transform: uppercase; |
|
| 61 | + | letter-spacing: 0.5px; |
|
| 62 | + | margin-top: 0.25rem; |
|
| 63 | + | padding-top: 0.5rem; |
|
| 64 | + | border-top: 1px solid rgba(245, 243, 238, 0.15); |
|
| 162 | 65 | } |
|
| 163 | 66 | ||
| 164 | - | .ticks { |
|
| 165 | - | position: relative; |
|
| 166 | - | width: 100%; |
|
| 167 | - | ||
| 168 | - | &::before, |
|
| 169 | - | &::after { |
|
| 170 | - | content: ''; |
|
| 171 | - | position: absolute; |
|
| 172 | - | top: -4.5px; |
|
| 173 | - | border: 5px solid transparent; |
|
| 174 | - | } |
|
| 175 | - | ||
| 176 | - | &::before { |
|
| 177 | - | left: 0; |
|
| 178 | - | border-left-color: var(--border); |
|
| 179 | - | } |
|
| 180 | - | &::after { |
|
| 181 | - | right: 0; |
|
| 182 | - | border-right-color: var(--border); |
|
| 183 | - | } |
|
| 67 | + | .controls input[type='range'] { |
|
| 68 | + | flex: 1; |
|
| 69 | + | max-width: 110px; |
|
| 184 | 70 | } |
| 1 | - | import { useState } from 'react' |
|
| 2 | - | import reactLogo from './assets/react.svg' |
|
| 3 | - | import viteLogo from './assets/vite.svg' |
|
| 4 | - | import heroImg from './assets/hero.png' |
|
| 5 | - | import './App.css' |
|
| 1 | + | import Murmurations from './components/Murmurations'; |
|
| 2 | + | import './App.css'; |
|
| 6 | 3 | ||
| 7 | 4 | function App() { |
|
| 8 | - | const [count, setCount] = useState(0) |
|
| 9 | - | ||
| 10 | - | return ( |
|
| 11 | - | <> |
|
| 12 | - | <section id="center"> |
|
| 13 | - | <div className="hero"> |
|
| 14 | - | <img src={heroImg} className="base" width="170" height="179" alt="" /> |
|
| 15 | - | <img src={reactLogo} className="framework" alt="React logo" /> |
|
| 16 | - | <img src={viteLogo} className="vite" alt="Vite logo" /> |
|
| 17 | - | </div> |
|
| 18 | - | <div> |
|
| 19 | - | <h1>Get started</h1> |
|
| 20 | - | <p> |
|
| 21 | - | Edit <code>src/App.tsx</code> and save to test <code>HMR</code> |
|
| 22 | - | </p> |
|
| 23 | - | </div> |
|
| 24 | - | <button |
|
| 25 | - | type="button" |
|
| 26 | - | className="counter" |
|
| 27 | - | onClick={() => setCount((count) => count + 1)} |
|
| 28 | - | > |
|
| 29 | - | Count is {count} |
|
| 30 | - | </button> |
|
| 31 | - | </section> |
|
| 32 | - | ||
| 33 | - | <div className="ticks"></div> |
|
| 34 | - | ||
| 35 | - | <section id="next-steps"> |
|
| 36 | - | <div id="docs"> |
|
| 37 | - | <svg className="icon" role="presentation" aria-hidden="true"> |
|
| 38 | - | <use href="/icons.svg#documentation-icon"></use> |
|
| 39 | - | </svg> |
|
| 40 | - | <h2>Documentation</h2> |
|
| 41 | - | <p>Your questions, answered</p> |
|
| 42 | - | <ul> |
|
| 43 | - | <li> |
|
| 44 | - | <a href="https://vite.dev/" target="_blank"> |
|
| 45 | - | <img className="logo" src={viteLogo} alt="" /> |
|
| 46 | - | Explore Vite |
|
| 47 | - | </a> |
|
| 48 | - | </li> |
|
| 49 | - | <li> |
|
| 50 | - | <a href="https://react.dev/" target="_blank"> |
|
| 51 | - | <img className="button-icon" src={reactLogo} alt="" /> |
|
| 52 | - | Learn more |
|
| 53 | - | </a> |
|
| 54 | - | </li> |
|
| 55 | - | </ul> |
|
| 56 | - | </div> |
|
| 57 | - | <div id="social"> |
|
| 58 | - | <svg className="icon" role="presentation" aria-hidden="true"> |
|
| 59 | - | <use href="/icons.svg#social-icon"></use> |
|
| 60 | - | </svg> |
|
| 61 | - | <h2>Connect with us</h2> |
|
| 62 | - | <p>Join the Vite community</p> |
|
| 63 | - | <ul> |
|
| 64 | - | <li> |
|
| 65 | - | <a href="https://github.com/vitejs/vite" target="_blank"> |
|
| 66 | - | <svg |
|
| 67 | - | className="button-icon" |
|
| 68 | - | role="presentation" |
|
| 69 | - | aria-hidden="true" |
|
| 70 | - | > |
|
| 71 | - | <use href="/icons.svg#github-icon"></use> |
|
| 72 | - | </svg> |
|
| 73 | - | GitHub |
|
| 74 | - | </a> |
|
| 75 | - | </li> |
|
| 76 | - | <li> |
|
| 77 | - | <a href="https://chat.vite.dev/" target="_blank"> |
|
| 78 | - | <svg |
|
| 79 | - | className="button-icon" |
|
| 80 | - | role="presentation" |
|
| 81 | - | aria-hidden="true" |
|
| 82 | - | > |
|
| 83 | - | <use href="/icons.svg#discord-icon"></use> |
|
| 84 | - | </svg> |
|
| 85 | - | Discord |
|
| 86 | - | </a> |
|
| 87 | - | </li> |
|
| 88 | - | <li> |
|
| 89 | - | <a href="https://x.com/vite_js" target="_blank"> |
|
| 90 | - | <svg |
|
| 91 | - | className="button-icon" |
|
| 92 | - | role="presentation" |
|
| 93 | - | aria-hidden="true" |
|
| 94 | - | > |
|
| 95 | - | <use href="/icons.svg#x-icon"></use> |
|
| 96 | - | </svg> |
|
| 97 | - | X.com |
|
| 98 | - | </a> |
|
| 99 | - | </li> |
|
| 100 | - | <li> |
|
| 101 | - | <a href="https://bsky.app/profile/vite.dev" target="_blank"> |
|
| 102 | - | <svg |
|
| 103 | - | className="button-icon" |
|
| 104 | - | role="presentation" |
|
| 105 | - | aria-hidden="true" |
|
| 106 | - | > |
|
| 107 | - | <use href="/icons.svg#bluesky-icon"></use> |
|
| 108 | - | </svg> |
|
| 109 | - | Bluesky |
|
| 110 | - | </a> |
|
| 111 | - | </li> |
|
| 112 | - | </ul> |
|
| 113 | - | </div> |
|
| 114 | - | </section> |
|
| 115 | - | ||
| 116 | - | <div className="ticks"></div> |
|
| 117 | - | <section id="spacer"></section> |
|
| 118 | - | </> |
|
| 119 | - | ) |
|
| 5 | + | return <Murmurations />; |
|
| 120 | 6 | } |
|
| 121 | 7 | ||
| 122 | - | export default App |
|
| 8 | + | export default App; |
| 1 | + | export interface MidiOutputInfo { |
|
| 2 | + | id: string; |
|
| 3 | + | name: string; |
|
| 4 | + | } |
|
| 5 | + | ||
| 6 | + | export interface SynthParams { |
|
| 7 | + | attack: number; |
|
| 8 | + | release: number; |
|
| 9 | + | cutoff: number; |
|
| 10 | + | detune: number; |
|
| 11 | + | polyphony: number; |
|
| 12 | + | chorus: number; |
|
| 13 | + | } |
|
| 14 | + | ||
| 15 | + | export const DEFAULT_SYNTH_PARAMS: SynthParams = { |
|
| 16 | + | attack: 0.6, |
|
| 17 | + | release: 1.4, |
|
| 18 | + | cutoff: 1800, |
|
| 19 | + | detune: 9, |
|
| 20 | + | polyphony: 6, |
|
| 21 | + | chorus: 0.45, |
|
| 22 | + | }; |
|
| 23 | + | ||
| 24 | + | export interface AudioEngine { |
|
| 25 | + | kind: 'midi' | 'synth'; |
|
| 26 | + | noteOn(note: number, velocity: number, durationMs: number): void; |
|
| 27 | + | setVolume?(v: number): void; |
|
| 28 | + | setSynthParams?(p: Partial<SynthParams>): void; |
|
| 29 | + | listOutputs?(): MidiOutputInfo[]; |
|
| 30 | + | selectOutput?(id: string): void; |
|
| 31 | + | currentOutputId?(): string | null; |
|
| 32 | + | dispose(): void; |
|
| 33 | + | } |
|
| 34 | + | ||
| 35 | + | interface PadVoice { |
|
| 36 | + | oscs: OscillatorNode[]; |
|
| 37 | + | gain: GainNode; |
|
| 38 | + | filter: BiquadFilterNode; |
|
| 39 | + | end: number; |
|
| 40 | + | } |
|
| 41 | + | ||
| 42 | + | class SynthEngine implements AudioEngine { |
|
| 43 | + | kind = 'synth' as const; |
|
| 44 | + | private ctx: AudioContext; |
|
| 45 | + | private master: GainNode; |
|
| 46 | + | private padBus: GainNode; |
|
| 47 | + | private wetBus: GainNode; |
|
| 48 | + | private active: PadVoice[] = []; |
|
| 49 | + | private params: SynthParams = { ...DEFAULT_SYNTH_PARAMS }; |
|
| 50 | + | private lastNoteAt = 0; |
|
| 51 | + | private minNoteGapMs = 35; |
|
| 52 | + | ||
| 53 | + | constructor() { |
|
| 54 | + | this.ctx = new (window.AudioContext || |
|
| 55 | + | (window as unknown as { webkitAudioContext: typeof AudioContext }) |
|
| 56 | + | .webkitAudioContext)(); |
|
| 57 | + | this.master = this.ctx.createGain(); |
|
| 58 | + | this.master.gain.value = 0.25; |
|
| 59 | + | this.master.connect(this.ctx.destination); |
|
| 60 | + | ||
| 61 | + | this.padBus = this.ctx.createGain(); |
|
| 62 | + | this.padBus.gain.value = 0.6; |
|
| 63 | + | ||
| 64 | + | this.wetBus = this.ctx.createGain(); |
|
| 65 | + | this.wetBus.gain.value = this.params.chorus; |
|
| 66 | + | ||
| 67 | + | this.buildChorus(this.padBus, this.wetBus); |
|
| 68 | + | this.padBus.connect(this.master); |
|
| 69 | + | this.wetBus.connect(this.master); |
|
| 70 | + | } |
|
| 71 | + | ||
| 72 | + | setSynthParams(p: Partial<SynthParams>) { |
|
| 73 | + | Object.assign(this.params, p); |
|
| 74 | + | if (p.chorus !== undefined) { |
|
| 75 | + | this.wetBus.gain.setTargetAtTime( |
|
| 76 | + | p.chorus, |
|
| 77 | + | this.ctx.currentTime, |
|
| 78 | + | 0.05, |
|
| 79 | + | ); |
|
| 80 | + | } |
|
| 81 | + | } |
|
| 82 | + | ||
| 83 | + | private buildChorus(input: GainNode, output: GainNode) { |
|
| 84 | + | const ctx = this.ctx; |
|
| 85 | + | const make = (delayMs: number, lfoHz: number, depthMs: number, phase: number) => { |
|
| 86 | + | const delay = ctx.createDelay(0.1); |
|
| 87 | + | delay.delayTime.value = delayMs / 1000; |
|
| 88 | + | const lfo = ctx.createOscillator(); |
|
| 89 | + | lfo.frequency.value = lfoHz; |
|
| 90 | + | const lfoGain = ctx.createGain(); |
|
| 91 | + | lfoGain.gain.value = depthMs / 1000; |
|
| 92 | + | lfo.connect(lfoGain).connect(delay.delayTime); |
|
| 93 | + | input.connect(delay).connect(output); |
|
| 94 | + | lfo.start(ctx.currentTime + phase); |
|
| 95 | + | }; |
|
| 96 | + | make(18, 0.6, 4, 0); |
|
| 97 | + | make(24, 0.83, 5, 0.25); |
|
| 98 | + | make(30, 0.41, 3.5, 0.5); |
|
| 99 | + | } |
|
| 100 | + | ||
| 101 | + | async resume() { |
|
| 102 | + | if (this.ctx.state !== 'running') await this.ctx.resume(); |
|
| 103 | + | } |
|
| 104 | + | ||
| 105 | + | setVolume(v: number) { |
|
| 106 | + | this.master.gain.setTargetAtTime(v, this.ctx.currentTime, 0.02); |
|
| 107 | + | } |
|
| 108 | + | ||
| 109 | + | noteOn(note: number, velocity: number) { |
|
| 110 | + | const nowMs = performance.now(); |
|
| 111 | + | if (nowMs - this.lastNoteAt < this.minNoteGapMs) return; |
|
| 112 | + | this.lastNoteAt = nowMs; |
|
| 113 | + | ||
| 114 | + | const { attack, release, cutoff, detune, polyphony } = this.params; |
|
| 115 | + | ||
| 116 | + | while (this.active.length >= polyphony) { |
|
| 117 | + | const oldest = this.active.shift()!; |
|
| 118 | + | const t0 = this.ctx.currentTime; |
|
| 119 | + | oldest.gain.gain.cancelScheduledValues(t0); |
|
| 120 | + | oldest.gain.gain.setTargetAtTime(0, t0, 0.08); |
|
| 121 | + | for (const o of oldest.oscs) o.stop(t0 + 0.4); |
|
| 122 | + | } |
|
| 123 | + | ||
| 124 | + | const t = this.ctx.currentTime; |
|
| 125 | + | const freq = 440 * Math.pow(2, (note - 69) / 12); |
|
| 126 | + | ||
| 127 | + | const hold = 0.15; |
|
| 128 | + | const total = attack + hold + release; |
|
| 129 | + | ||
| 130 | + | const polyScale = 1 / Math.sqrt(Math.max(1, polyphony)); |
|
| 131 | + | const peak = (velocity / 127) * 0.32 * polyScale; |
|
| 132 | + | ||
| 133 | + | const filter = this.ctx.createBiquadFilter(); |
|
| 134 | + | filter.type = 'lowpass'; |
|
| 135 | + | filter.Q.value = 0.6; |
|
| 136 | + | const cutoffStart = Math.min(freq * 1.8, cutoff * 0.4); |
|
| 137 | + | const cutoffPeak = Math.min(cutoff * 2.2, 8000); |
|
| 138 | + | filter.frequency.setValueAtTime(cutoffStart, t); |
|
| 139 | + | filter.frequency.linearRampToValueAtTime(cutoffPeak, t + attack); |
|
| 140 | + | filter.frequency.setTargetAtTime( |
|
| 141 | + | cutoffStart * 1.2, |
|
| 142 | + | t + attack + hold, |
|
| 143 | + | release / 3, |
|
| 144 | + | ); |
|
| 145 | + | ||
| 146 | + | const g = this.ctx.createGain(); |
|
| 147 | + | g.gain.setValueAtTime(0, t); |
|
| 148 | + | g.gain.linearRampToValueAtTime(peak, t + attack); |
|
| 149 | + | g.gain.setValueAtTime(peak, t + attack + hold); |
|
| 150 | + | g.gain.exponentialRampToValueAtTime(0.0001, t + attack + hold + release); |
|
| 151 | + | ||
| 152 | + | const detuneCents = [-detune, 0, detune]; |
|
| 153 | + | const oscs: OscillatorNode[] = []; |
|
| 154 | + | for (const cents of detuneCents) { |
|
| 155 | + | const osc = this.ctx.createOscillator(); |
|
| 156 | + | osc.type = 'sawtooth'; |
|
| 157 | + | osc.frequency.value = freq; |
|
| 158 | + | osc.detune.value = cents; |
|
| 159 | + | osc.connect(filter); |
|
| 160 | + | osc.start(t); |
|
| 161 | + | osc.stop(t + total + 0.1); |
|
| 162 | + | oscs.push(osc); |
|
| 163 | + | } |
|
| 164 | + | ||
| 165 | + | const subOsc = this.ctx.createOscillator(); |
|
| 166 | + | subOsc.type = 'sine'; |
|
| 167 | + | subOsc.frequency.value = freq / 2; |
|
| 168 | + | const subGain = this.ctx.createGain(); |
|
| 169 | + | subGain.gain.value = 0.3; |
|
| 170 | + | subOsc.connect(subGain).connect(filter); |
|
| 171 | + | subOsc.start(t); |
|
| 172 | + | subOsc.stop(t + total + 0.1); |
|
| 173 | + | oscs.push(subOsc); |
|
| 174 | + | ||
| 175 | + | filter.connect(g).connect(this.padBus); |
|
| 176 | + | ||
| 177 | + | const entry: PadVoice = { oscs, gain: g, filter, end: t + total }; |
|
| 178 | + | this.active.push(entry); |
|
| 179 | + | oscs[0].onended = () => { |
|
| 180 | + | const i = this.active.indexOf(entry); |
|
| 181 | + | if (i >= 0) this.active.splice(i, 1); |
|
| 182 | + | }; |
|
| 183 | + | } |
|
| 184 | + | ||
| 185 | + | dispose() { |
|
| 186 | + | this.ctx.close(); |
|
| 187 | + | } |
|
| 188 | + | } |
|
| 189 | + | ||
| 190 | + | class MidiEngine implements AudioEngine { |
|
| 191 | + | kind = 'midi' as const; |
|
| 192 | + | private access: MIDIAccess; |
|
| 193 | + | private outputId: string | null = null; |
|
| 194 | + | private channel = 0; |
|
| 195 | + | ||
| 196 | + | constructor(access: MIDIAccess) { |
|
| 197 | + | this.access = access; |
|
| 198 | + | const first = Array.from(access.outputs.values())[0]; |
|
| 199 | + | this.outputId = first ? first.id : null; |
|
| 200 | + | } |
|
| 201 | + | ||
| 202 | + | listOutputs(): MidiOutputInfo[] { |
|
| 203 | + | return Array.from(this.access.outputs.values()).map((o) => ({ |
|
| 204 | + | id: o.id, |
|
| 205 | + | name: o.name ?? o.id, |
|
| 206 | + | })); |
|
| 207 | + | } |
|
| 208 | + | ||
| 209 | + | selectOutput(id: string) { |
|
| 210 | + | this.outputId = id; |
|
| 211 | + | } |
|
| 212 | + | ||
| 213 | + | currentOutputId() { |
|
| 214 | + | return this.outputId; |
|
| 215 | + | } |
|
| 216 | + | ||
| 217 | + | private send(bytes: number[]) { |
|
| 218 | + | if (!this.outputId) return; |
|
| 219 | + | const out = this.access.outputs.get(this.outputId); |
|
| 220 | + | if (out) out.send(bytes); |
|
| 221 | + | } |
|
| 222 | + | ||
| 223 | + | noteOn(note: number, velocity: number, durationMs: number) { |
|
| 224 | + | const v = Math.max(1, Math.min(127, Math.round(velocity))); |
|
| 225 | + | const n = Math.max(0, Math.min(127, Math.round(note))); |
|
| 226 | + | this.send([0x90 | this.channel, n, v]); |
|
| 227 | + | setTimeout(() => { |
|
| 228 | + | this.send([0x80 | this.channel, n, 0]); |
|
| 229 | + | }, durationMs); |
|
| 230 | + | } |
|
| 231 | + | ||
| 232 | + | dispose() {} |
|
| 233 | + | } |
|
| 234 | + | ||
| 235 | + | export async function createEngine( |
|
| 236 | + | preferMidi: boolean, |
|
| 237 | + | ): Promise<AudioEngine> { |
|
| 238 | + | if (preferMidi && 'requestMIDIAccess' in navigator) { |
|
| 239 | + | try { |
|
| 240 | + | const access = await navigator.requestMIDIAccess({ sysex: false }); |
|
| 241 | + | if (access.outputs.size > 0) { |
|
| 242 | + | return new MidiEngine(access); |
|
| 243 | + | } |
|
| 244 | + | } catch { |
|
| 245 | + | // fall through to synth |
|
| 246 | + | } |
|
| 247 | + | } |
|
| 248 | + | const synth = new SynthEngine(); |
|
| 249 | + | await synth.resume(); |
|
| 250 | + | return synth; |
|
| 251 | + | } |
| 1 | + | export type ScaleName = |
|
| 2 | + | | 'major' |
|
| 3 | + | | 'minor' |
|
| 4 | + | | 'pentatonicMaj' |
|
| 5 | + | | 'pentatonicMin' |
|
| 6 | + | | 'dorian'; |
|
| 7 | + | ||
| 8 | + | export const SCALES: Record<ScaleName, number[]> = { |
|
| 9 | + | major: [0, 2, 4, 5, 7, 9, 11], |
|
| 10 | + | minor: [0, 2, 3, 5, 7, 8, 10], |
|
| 11 | + | pentatonicMaj: [0, 2, 4, 7, 9, 0, 2], |
|
| 12 | + | pentatonicMin: [0, 3, 5, 7, 10, 0, 3], |
|
| 13 | + | dorian: [0, 2, 3, 5, 7, 9, 10], |
|
| 14 | + | }; |
|
| 15 | + | ||
| 16 | + | export const NOTE_NAMES = [ |
|
| 17 | + | 'C', |
|
| 18 | + | 'C#', |
|
| 19 | + | 'D', |
|
| 20 | + | 'D#', |
|
| 21 | + | 'E', |
|
| 22 | + | 'F', |
|
| 23 | + | 'F#', |
|
| 24 | + | 'G', |
|
| 25 | + | 'G#', |
|
| 26 | + | 'A', |
|
| 27 | + | 'A#', |
|
| 28 | + | 'B', |
|
| 29 | + | ]; |
|
| 30 | + | ||
| 31 | + | export const COLS = 7; |
|
| 32 | + | export const ROWS = 4; |
|
| 33 | + | ||
| 34 | + | export function cellToMidi( |
|
| 35 | + | col: number, |
|
| 36 | + | row: number, |
|
| 37 | + | rootPc: number, |
|
| 38 | + | octaveBase: number, |
|
| 39 | + | scale: ScaleName, |
|
| 40 | + | ): number { |
|
| 41 | + | const intervals = SCALES[scale]; |
|
| 42 | + | const interval = intervals[col % intervals.length]; |
|
| 43 | + | const octave = octaveBase + (ROWS - 1 - row); |
|
| 44 | + | return 12 * (octave + 1) + rootPc + interval; |
|
| 45 | + | } |
| 1 | + | import type { MidiOutputInfo, SynthParams } from '../audio/engine'; |
|
| 2 | + | import { NOTE_NAMES, type ScaleName } from '../audio/scales'; |
|
| 3 | + | ||
| 4 | + | interface Props { |
|
| 5 | + | started: boolean; |
|
| 6 | + | onStart: () => void; |
|
| 7 | + | onStop: () => void; |
|
| 8 | + | scale: ScaleName; |
|
| 9 | + | setScale: (s: ScaleName) => void; |
|
| 10 | + | rootPc: number; |
|
| 11 | + | setRootPc: (n: number) => void; |
|
| 12 | + | octaveBase: number; |
|
| 13 | + | setOctaveBase: (n: number) => void; |
|
| 14 | + | engineKind: 'midi' | 'synth' | null; |
|
| 15 | + | outputs: MidiOutputInfo[]; |
|
| 16 | + | currentOutputId: string | null; |
|
| 17 | + | selectOutput: (id: string) => void; |
|
| 18 | + | volume: number; |
|
| 19 | + | setVolume: (v: number) => void; |
|
| 20 | + | synthParams: SynthParams; |
|
| 21 | + | setSynthParam: <K extends keyof SynthParams>(k: K, v: SynthParams[K]) => void; |
|
| 22 | + | } |
|
| 23 | + | ||
| 24 | + | const SCALE_OPTIONS: { value: ScaleName; label: string }[] = [ |
|
| 25 | + | { value: 'major', label: 'Major' }, |
|
| 26 | + | { value: 'minor', label: 'Minor' }, |
|
| 27 | + | { value: 'pentatonicMaj', label: 'Pentatonic Maj' }, |
|
| 28 | + | { value: 'pentatonicMin', label: 'Pentatonic Min' }, |
|
| 29 | + | { value: 'dorian', label: 'Dorian' }, |
|
| 30 | + | ]; |
|
| 31 | + | ||
| 32 | + | export default function Controls(props: Props) { |
|
| 33 | + | return ( |
|
| 34 | + | <div className="controls"> |
|
| 35 | + | <button type="button" onClick={props.started ? props.onStop : props.onStart}> |
|
| 36 | + | {props.started ? 'Stop' : 'Start'} |
|
| 37 | + | </button> |
|
| 38 | + | ||
| 39 | + | <label> |
|
| 40 | + | Scale |
|
| 41 | + | <select |
|
| 42 | + | value={props.scale} |
|
| 43 | + | onChange={(e) => props.setScale(e.target.value as ScaleName)} |
|
| 44 | + | > |
|
| 45 | + | {SCALE_OPTIONS.map((s) => ( |
|
| 46 | + | <option key={s.value} value={s.value}> |
|
| 47 | + | {s.label} |
|
| 48 | + | </option> |
|
| 49 | + | ))} |
|
| 50 | + | </select> |
|
| 51 | + | </label> |
|
| 52 | + | ||
| 53 | + | <label> |
|
| 54 | + | Root |
|
| 55 | + | <select |
|
| 56 | + | value={props.rootPc} |
|
| 57 | + | onChange={(e) => props.setRootPc(Number(e.target.value))} |
|
| 58 | + | > |
|
| 59 | + | {NOTE_NAMES.map((n, i) => ( |
|
| 60 | + | <option key={n} value={i}> |
|
| 61 | + | {n} |
|
| 62 | + | </option> |
|
| 63 | + | ))} |
|
| 64 | + | </select> |
|
| 65 | + | </label> |
|
| 66 | + | ||
| 67 | + | <label> |
|
| 68 | + | Octave |
|
| 69 | + | <select |
|
| 70 | + | value={props.octaveBase} |
|
| 71 | + | onChange={(e) => props.setOctaveBase(Number(e.target.value))} |
|
| 72 | + | > |
|
| 73 | + | {[1, 2, 3, 4, 5, 6].map((o) => ( |
|
| 74 | + | <option key={o} value={o}> |
|
| 75 | + | {o} |
|
| 76 | + | </option> |
|
| 77 | + | ))} |
|
| 78 | + | </select> |
|
| 79 | + | </label> |
|
| 80 | + | ||
| 81 | + | {props.engineKind === 'midi' && props.outputs.length > 0 && ( |
|
| 82 | + | <label> |
|
| 83 | + | MIDI |
|
| 84 | + | <select |
|
| 85 | + | value={props.currentOutputId ?? ''} |
|
| 86 | + | onChange={(e) => props.selectOutput(e.target.value)} |
|
| 87 | + | > |
|
| 88 | + | {props.outputs.map((o) => ( |
|
| 89 | + | <option key={o.id} value={o.id}> |
|
| 90 | + | {o.name} |
|
| 91 | + | </option> |
|
| 92 | + | ))} |
|
| 93 | + | </select> |
|
| 94 | + | </label> |
|
| 95 | + | )} |
|
| 96 | + | ||
| 97 | + | {props.engineKind === 'synth' && ( |
|
| 98 | + | <> |
|
| 99 | + | <div className="section">Built-in synth (no MIDI device connected)</div> |
|
| 100 | + | <label> |
|
| 101 | + | Volume |
|
| 102 | + | <input |
|
| 103 | + | type="range" |
|
| 104 | + | min={0} |
|
| 105 | + | max={1} |
|
| 106 | + | step={0.01} |
|
| 107 | + | value={props.volume} |
|
| 108 | + | onChange={(e) => props.setVolume(Number(e.target.value))} |
|
| 109 | + | /> |
|
| 110 | + | </label> |
|
| 111 | + | <label> |
|
| 112 | + | Attack |
|
| 113 | + | <input |
|
| 114 | + | type="range" |
|
| 115 | + | min={0.05} |
|
| 116 | + | max={2} |
|
| 117 | + | step={0.05} |
|
| 118 | + | value={props.synthParams.attack} |
|
| 119 | + | onChange={(e) => |
|
| 120 | + | props.setSynthParam('attack', Number(e.target.value)) |
|
| 121 | + | } |
|
| 122 | + | /> |
|
| 123 | + | </label> |
|
| 124 | + | <label> |
|
| 125 | + | Release |
|
| 126 | + | <input |
|
| 127 | + | type="range" |
|
| 128 | + | min={0.2} |
|
| 129 | + | max={3} |
|
| 130 | + | step={0.05} |
|
| 131 | + | value={props.synthParams.release} |
|
| 132 | + | onChange={(e) => |
|
| 133 | + | props.setSynthParam('release', Number(e.target.value)) |
|
| 134 | + | } |
|
| 135 | + | /> |
|
| 136 | + | </label> |
|
| 137 | + | <label> |
|
| 138 | + | Cutoff |
|
| 139 | + | <input |
|
| 140 | + | type="range" |
|
| 141 | + | min={300} |
|
| 142 | + | max={5000} |
|
| 143 | + | step={50} |
|
| 144 | + | value={props.synthParams.cutoff} |
|
| 145 | + | onChange={(e) => |
|
| 146 | + | props.setSynthParam('cutoff', Number(e.target.value)) |
|
| 147 | + | } |
|
| 148 | + | /> |
|
| 149 | + | </label> |
|
| 150 | + | <label> |
|
| 151 | + | Detune |
|
| 152 | + | <input |
|
| 153 | + | type="range" |
|
| 154 | + | min={0} |
|
| 155 | + | max={25} |
|
| 156 | + | step={1} |
|
| 157 | + | value={props.synthParams.detune} |
|
| 158 | + | onChange={(e) => |
|
| 159 | + | props.setSynthParam('detune', Number(e.target.value)) |
|
| 160 | + | } |
|
| 161 | + | /> |
|
| 162 | + | </label> |
|
| 163 | + | <label> |
|
| 164 | + | Chorus |
|
| 165 | + | <input |
|
| 166 | + | type="range" |
|
| 167 | + | min={0} |
|
| 168 | + | max={1} |
|
| 169 | + | step={0.01} |
|
| 170 | + | value={props.synthParams.chorus} |
|
| 171 | + | onChange={(e) => |
|
| 172 | + | props.setSynthParam('chorus', Number(e.target.value)) |
|
| 173 | + | } |
|
| 174 | + | /> |
|
| 175 | + | </label> |
|
| 176 | + | <label> |
|
| 177 | + | Polyphony |
|
| 178 | + | <input |
|
| 179 | + | type="range" |
|
| 180 | + | min={2} |
|
| 181 | + | max={12} |
|
| 182 | + | step={1} |
|
| 183 | + | value={props.synthParams.polyphony} |
|
| 184 | + | onChange={(e) => |
|
| 185 | + | props.setSynthParam('polyphony', Number(e.target.value)) |
|
| 186 | + | } |
|
| 187 | + | /> |
|
| 188 | + | </label> |
|
| 189 | + | </> |
|
| 190 | + | )} |
|
| 191 | + | ||
| 192 | + | <div className="status"> |
|
| 193 | + | {props.engineKind === null |
|
| 194 | + | ? 'audio idle' |
|
| 195 | + | : props.engineKind === 'midi' |
|
| 196 | + | ? 'midi out' |
|
| 197 | + | : 'built-in synth'} |
|
| 198 | + | </div> |
|
| 199 | + | </div> |
|
| 200 | + | ); |
|
| 201 | + | } |
| 1 | + | import { useEffect, useRef, useState } from 'react'; |
|
| 2 | + | import { createSim, drawBoid, MAX_SPEED, type Sim } from '../sim/boids'; |
|
| 3 | + | import { cellAt, drawGrid } from '../sim/grid'; |
|
| 4 | + | import { cellToMidi, type ScaleName } from '../audio/scales'; |
|
| 5 | + | import { |
|
| 6 | + | createEngine, |
|
| 7 | + | DEFAULT_SYNTH_PARAMS, |
|
| 8 | + | type AudioEngine, |
|
| 9 | + | type MidiOutputInfo, |
|
| 10 | + | type SynthParams, |
|
| 11 | + | } from '../audio/engine'; |
|
| 12 | + | import Controls from './Controls'; |
|
| 13 | + | ||
| 14 | + | const FLASH_MS = 280; |
|
| 15 | + | const NOTE_DUR_MS = 200; |
|
| 16 | + | ||
| 17 | + | export default function Murmurations() { |
|
| 18 | + | const canvasRef = useRef<HTMLCanvasElement | null>(null); |
|
| 19 | + | const simRef = useRef<Sim | null>(null); |
|
| 20 | + | const engineRef = useRef<AudioEngine | null>(null); |
|
| 21 | + | const flashesRef = useRef<Map<number, number>>(new Map()); |
|
| 22 | + | const rafRef = useRef<number | null>(null); |
|
| 23 | + | const sizeRef = useRef({ W: 0, H: 0 }); |
|
| 24 | + | ||
| 25 | + | const [started, setStarted] = useState(false); |
|
| 26 | + | const [scale, setScale] = useState<ScaleName>('pentatonicMaj'); |
|
| 27 | + | const [rootPc, setRootPc] = useState(0); |
|
| 28 | + | const [octaveBase, setOctaveBase] = useState(3); |
|
| 29 | + | const [engineKind, setEngineKind] = useState<'midi' | 'synth' | null>(null); |
|
| 30 | + | const [outputs, setOutputs] = useState<MidiOutputInfo[]>([]); |
|
| 31 | + | const [currentOutputId, setCurrentOutputId] = useState<string | null>(null); |
|
| 32 | + | const [volume, setVolume] = useState(0.25); |
|
| 33 | + | const [synthParams, setSynthParams] = useState<SynthParams>({ |
|
| 34 | + | ...DEFAULT_SYNTH_PARAMS, |
|
| 35 | + | }); |
|
| 36 | + | ||
| 37 | + | const settingsRef = useRef({ scale, rootPc, octaveBase }); |
|
| 38 | + | useEffect(() => { |
|
| 39 | + | settingsRef.current = { scale, rootPc, octaveBase }; |
|
| 40 | + | }, [scale, rootPc, octaveBase]); |
|
| 41 | + | ||
| 42 | + | useEffect(() => { |
|
| 43 | + | const canvas = canvasRef.current!; |
|
| 44 | + | const ctx = canvas.getContext('2d')!; |
|
| 45 | + | const DPR = Math.min(window.devicePixelRatio || 1, 2); |
|
| 46 | + | ||
| 47 | + | function viewport() { |
|
| 48 | + | const vv = window.visualViewport; |
|
| 49 | + | return { |
|
| 50 | + | w: vv ? vv.width : window.innerWidth, |
|
| 51 | + | h: vv ? vv.height : window.innerHeight, |
|
| 52 | + | }; |
|
| 53 | + | } |
|
| 54 | + | ||
| 55 | + | function resize() { |
|
| 56 | + | const { w, h } = viewport(); |
|
| 57 | + | sizeRef.current = { W: w, H: h }; |
|
| 58 | + | canvas.width = w * DPR; |
|
| 59 | + | canvas.height = h * DPR; |
|
| 60 | + | canvas.style.width = w + 'px'; |
|
| 61 | + | canvas.style.height = h + 'px'; |
|
| 62 | + | ctx.setTransform(DPR, 0, 0, DPR, 0, 0); |
|
| 63 | + | ctx.lineCap = 'round'; |
|
| 64 | + | if (simRef.current) simRef.current.resize(w, h); |
|
| 65 | + | } |
|
| 66 | + | ||
| 67 | + | resize(); |
|
| 68 | + | const { W, H } = sizeRef.current; |
|
| 69 | + | simRef.current = createSim(W, H); |
|
| 70 | + | ||
| 71 | + | window.addEventListener('resize', resize); |
|
| 72 | + | window.addEventListener('orientationchange', resize); |
|
| 73 | + | const vv = window.visualViewport; |
|
| 74 | + | if (vv) vv.addEventListener('resize', resize); |
|
| 75 | + | ||
| 76 | + | function loop() { |
|
| 77 | + | const { W, H } = sizeRef.current; |
|
| 78 | + | const sim = simRef.current!; |
|
| 79 | + | const engine = engineRef.current; |
|
| 80 | + | const settings = settingsRef.current; |
|
| 81 | + | const now = performance.now(); |
|
| 82 | + | ||
| 83 | + | ctx.fillStyle = 'rgba(18, 17, 19, 0.55)'; |
|
| 84 | + | ctx.fillRect(0, 0, W, H); |
|
| 85 | + | ||
| 86 | + | sim.step(); |
|
| 87 | + | ||
| 88 | + | ctx.strokeStyle = 'rgba(245, 243, 238, 0.78)'; |
|
| 89 | + | ctx.lineWidth = 1.4; |
|
| 90 | + | for (let i = 0; i < sim.state.boids.length; i++) { |
|
| 91 | + | const b = sim.state.boids[i]; |
|
| 92 | + | drawBoid(ctx, b); |
|
| 93 | + | ||
| 94 | + | const idx = cellAt(b.x, b.y, W, H); |
|
| 95 | + | if (b.lastCell !== -1 && idx !== b.lastCell) { |
|
| 96 | + | if (engine) { |
|
| 97 | + | const row = Math.floor(idx / 7); |
|
| 98 | + | const col = idx % 7; |
|
| 99 | + | const note = cellToMidi( |
|
| 100 | + | col, |
|
| 101 | + | row, |
|
| 102 | + | settings.rootPc, |
|
| 103 | + | settings.octaveBase, |
|
| 104 | + | settings.scale, |
|
| 105 | + | ); |
|
| 106 | + | const sp = Math.hypot(b.vx, b.vy); |
|
| 107 | + | const vel = Math.max( |
|
| 108 | + | 40, |
|
| 109 | + | Math.min(120, Math.round(40 + (sp / MAX_SPEED) * 70)), |
|
| 110 | + | ); |
|
| 111 | + | engine.noteOn(note, vel, NOTE_DUR_MS); |
|
| 112 | + | flashesRef.current.set(idx, now + FLASH_MS); |
|
| 113 | + | } |
|
| 114 | + | } |
|
| 115 | + | b.lastCell = idx; |
|
| 116 | + | } |
|
| 117 | + | ||
| 118 | + | drawGrid(ctx, W, H, flashesRef.current, now, FLASH_MS); |
|
| 119 | + | ||
| 120 | + | rafRef.current = requestAnimationFrame(loop); |
|
| 121 | + | } |
|
| 122 | + | rafRef.current = requestAnimationFrame(loop); |
|
| 123 | + | ||
| 124 | + | return () => { |
|
| 125 | + | if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); |
|
| 126 | + | window.removeEventListener('resize', resize); |
|
| 127 | + | window.removeEventListener('orientationchange', resize); |
|
| 128 | + | if (vv) vv.removeEventListener('resize', resize); |
|
| 129 | + | engineRef.current?.dispose(); |
|
| 130 | + | engineRef.current = null; |
|
| 131 | + | }; |
|
| 132 | + | }, []); |
|
| 133 | + | ||
| 134 | + | async function start() { |
|
| 135 | + | if (engineRef.current) { |
|
| 136 | + | setStarted(true); |
|
| 137 | + | return; |
|
| 138 | + | } |
|
| 139 | + | const engine = await createEngine(true); |
|
| 140 | + | engineRef.current = engine; |
|
| 141 | + | setEngineKind(engine.kind); |
|
| 142 | + | if (engine.listOutputs) { |
|
| 143 | + | const outs = engine.listOutputs(); |
|
| 144 | + | setOutputs(outs); |
|
| 145 | + | setCurrentOutputId(engine.currentOutputId?.() ?? null); |
|
| 146 | + | } |
|
| 147 | + | if (engine.setVolume) engine.setVolume(volume); |
|
| 148 | + | if (engine.setSynthParams) engine.setSynthParams(synthParams); |
|
| 149 | + | setStarted(true); |
|
| 150 | + | } |
|
| 151 | + | ||
| 152 | + | function setSynthParam<K extends keyof SynthParams>(k: K, v: SynthParams[K]) { |
|
| 153 | + | setSynthParams((prev) => { |
|
| 154 | + | const next = { ...prev, [k]: v }; |
|
| 155 | + | engineRef.current?.setSynthParams?.({ [k]: v } as Partial<SynthParams>); |
|
| 156 | + | return next; |
|
| 157 | + | }); |
|
| 158 | + | } |
|
| 159 | + | ||
| 160 | + | function stop() { |
|
| 161 | + | engineRef.current?.dispose(); |
|
| 162 | + | engineRef.current = null; |
|
| 163 | + | setEngineKind(null); |
|
| 164 | + | setOutputs([]); |
|
| 165 | + | setCurrentOutputId(null); |
|
| 166 | + | setStarted(false); |
|
| 167 | + | } |
|
| 168 | + | ||
| 169 | + | function selectOutput(id: string) { |
|
| 170 | + | engineRef.current?.selectOutput?.(id); |
|
| 171 | + | setCurrentOutputId(id); |
|
| 172 | + | } |
|
| 173 | + | ||
| 174 | + | function handleVolume(v: number) { |
|
| 175 | + | setVolume(v); |
|
| 176 | + | engineRef.current?.setVolume?.(v); |
|
| 177 | + | } |
|
| 178 | + | ||
| 179 | + | return ( |
|
| 180 | + | <> |
|
| 181 | + | <canvas ref={canvasRef} className="murmurations-canvas" /> |
|
| 182 | + | <Controls |
|
| 183 | + | started={started} |
|
| 184 | + | onStart={start} |
|
| 185 | + | onStop={stop} |
|
| 186 | + | scale={scale} |
|
| 187 | + | setScale={setScale} |
|
| 188 | + | rootPc={rootPc} |
|
| 189 | + | setRootPc={setRootPc} |
|
| 190 | + | octaveBase={octaveBase} |
|
| 191 | + | setOctaveBase={setOctaveBase} |
|
| 192 | + | engineKind={engineKind} |
|
| 193 | + | outputs={outputs} |
|
| 194 | + | currentOutputId={currentOutputId} |
|
| 195 | + | selectOutput={selectOutput} |
|
| 196 | + | volume={volume} |
|
| 197 | + | setVolume={handleVolume} |
|
| 198 | + | synthParams={synthParams} |
|
| 199 | + | setSynthParam={setSynthParam} |
|
| 200 | + | /> |
|
| 201 | + | </> |
|
| 202 | + | ); |
|
| 203 | + | } |
| 1 | 1 | :root { |
|
| 2 | - | --text: #6b6375; |
|
| 3 | - | --text-h: #08060d; |
|
| 4 | - | --bg: #fff; |
|
| 5 | - | --border: #e5e4e7; |
|
| 6 | - | --code-bg: #f4f3ec; |
|
| 7 | - | --accent: #aa3bff; |
|
| 8 | - | --accent-bg: rgba(170, 59, 255, 0.1); |
|
| 9 | - | --accent-border: rgba(170, 59, 255, 0.5); |
|
| 10 | - | --social-bg: rgba(244, 243, 236, 0.5); |
|
| 11 | - | --shadow: |
|
| 12 | - | rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; |
|
| 13 | - | ||
| 14 | - | --sans: system-ui, 'Segoe UI', Roboto, sans-serif; |
|
| 15 | - | --heading: system-ui, 'Segoe UI', Roboto, sans-serif; |
|
| 16 | - | --mono: ui-monospace, Consolas, monospace; |
|
| 17 | - | ||
| 18 | - | font: 18px/145% var(--sans); |
|
| 19 | - | letter-spacing: 0.18px; |
|
| 20 | - | color-scheme: light dark; |
|
| 21 | - | color: var(--text); |
|
| 22 | - | background: var(--bg); |
|
| 23 | - | font-synthesis: none; |
|
| 24 | - | text-rendering: optimizeLegibility; |
|
| 25 | - | -webkit-font-smoothing: antialiased; |
|
| 26 | - | -moz-osx-font-smoothing: grayscale; |
|
| 27 | - | ||
| 28 | - | @media (max-width: 1024px) { |
|
| 29 | - | font-size: 16px; |
|
| 30 | - | } |
|
| 2 | + | font-family: ui-monospace, Consolas, monospace; |
|
| 3 | + | color-scheme: dark; |
|
| 4 | + | color: #f5f3ee; |
|
| 5 | + | background: #121113; |
|
| 6 | + | font-synthesis: none; |
|
| 7 | + | text-rendering: optimizeLegibility; |
|
| 8 | + | -webkit-font-smoothing: antialiased; |
|
| 9 | + | -moz-osx-font-smoothing: grayscale; |
|
| 31 | 10 | } |
|
| 32 | 11 | ||
| 33 | - | @media (prefers-color-scheme: dark) { |
|
| 34 | - | :root { |
|
| 35 | - | --text: #9ca3af; |
|
| 36 | - | --text-h: #f3f4f6; |
|
| 37 | - | --bg: #16171d; |
|
| 38 | - | --border: #2e303a; |
|
| 39 | - | --code-bg: #1f2028; |
|
| 40 | - | --accent: #c084fc; |
|
| 41 | - | --accent-bg: rgba(192, 132, 252, 0.15); |
|
| 42 | - | --accent-border: rgba(192, 132, 252, 0.5); |
|
| 43 | - | --social-bg: rgba(47, 48, 58, 0.5); |
|
| 44 | - | --shadow: |
|
| 45 | - | rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; |
|
| 46 | - | } |
|
| 47 | - | ||
| 48 | - | #social .button-icon { |
|
| 49 | - | filter: invert(1) brightness(2); |
|
| 50 | - | } |
|
| 51 | - | } |
|
| 52 | - | ||
| 12 | + | html, |
|
| 13 | + | body, |
|
| 53 | 14 | #root { |
|
| 54 | - | width: 1126px; |
|
| 55 | - | max-width: 100%; |
|
| 56 | - | margin: 0 auto; |
|
| 57 | - | text-align: center; |
|
| 58 | - | border-inline: 1px solid var(--border); |
|
| 59 | - | min-height: 100svh; |
|
| 60 | - | display: flex; |
|
| 61 | - | flex-direction: column; |
|
| 62 | - | box-sizing: border-box; |
|
| 63 | - | } |
|
| 64 | - | ||
| 65 | - | body { |
|
| 66 | - | margin: 0; |
|
| 67 | - | } |
|
| 68 | - | ||
| 69 | - | h1, |
|
| 70 | - | h2 { |
|
| 71 | - | font-family: var(--heading); |
|
| 72 | - | font-weight: 500; |
|
| 73 | - | color: var(--text-h); |
|
| 74 | - | } |
|
| 75 | - | ||
| 76 | - | h1 { |
|
| 77 | - | font-size: 56px; |
|
| 78 | - | letter-spacing: -1.68px; |
|
| 79 | - | margin: 32px 0; |
|
| 80 | - | @media (max-width: 1024px) { |
|
| 81 | - | font-size: 36px; |
|
| 82 | - | margin: 20px 0; |
|
| 83 | - | } |
|
| 84 | - | } |
|
| 85 | - | h2 { |
|
| 86 | - | font-size: 24px; |
|
| 87 | - | line-height: 118%; |
|
| 88 | - | letter-spacing: -0.24px; |
|
| 89 | - | margin: 0 0 8px; |
|
| 90 | - | @media (max-width: 1024px) { |
|
| 91 | - | font-size: 20px; |
|
| 92 | - | } |
|
| 93 | - | } |
|
| 94 | - | p { |
|
| 95 | - | margin: 0; |
|
| 96 | - | } |
|
| 97 | - | ||
| 98 | - | code, |
|
| 99 | - | .counter { |
|
| 100 | - | font-family: var(--mono); |
|
| 101 | - | display: inline-flex; |
|
| 102 | - | border-radius: 4px; |
|
| 103 | - | color: var(--text-h); |
|
| 104 | - | } |
|
| 105 | - | ||
| 106 | - | code { |
|
| 107 | - | font-size: 15px; |
|
| 108 | - | line-height: 135%; |
|
| 109 | - | padding: 4px 8px; |
|
| 110 | - | background: var(--code-bg); |
|
| 15 | + | margin: 0; |
|
| 16 | + | padding: 0; |
|
| 17 | + | width: 100%; |
|
| 18 | + | height: 100%; |
|
| 19 | + | overflow: hidden; |
|
| 20 | + | background: #121113; |
|
| 111 | 21 | } |
| 1 | + | const TOPOLOGICAL_K = 7; |
|
| 2 | + | const PERCEPTION = 70; |
|
| 3 | + | const SEP_RANGE = 14; |
|
| 4 | + | export const MAX_SPEED = 3.4; |
|
| 5 | + | const MIN_SPEED = 2.2; |
|
| 6 | + | const MAX_FORCE = 0.12; |
|
| 7 | + | ||
| 8 | + | const W_SEP = 1.8; |
|
| 9 | + | const W_ALIGN = 1.4; |
|
| 10 | + | const W_COH = 0.9; |
|
| 11 | + | const W_ATTRACT = 0.06; |
|
| 12 | + | const W_NOISE = 0.015; |
|
| 13 | + | ||
| 14 | + | export class Boid { |
|
| 15 | + | x: number; |
|
| 16 | + | y: number; |
|
| 17 | + | vx: number; |
|
| 18 | + | vy: number; |
|
| 19 | + | ax = 0; |
|
| 20 | + | ay = 0; |
|
| 21 | + | lastCell = -1; |
|
| 22 | + | ||
| 23 | + | constructor(W: number, H: number) { |
|
| 24 | + | this.x = Math.random() * W; |
|
| 25 | + | this.y = Math.random() * H; |
|
| 26 | + | const a = Math.random() * Math.PI * 2; |
|
| 27 | + | const s = MIN_SPEED + Math.random() * (MAX_SPEED - MIN_SPEED); |
|
| 28 | + | this.vx = Math.cos(a) * s; |
|
| 29 | + | this.vy = Math.sin(a) * s; |
|
| 30 | + | } |
|
| 31 | + | ||
| 32 | + | edges(W: number, H: number) { |
|
| 33 | + | const m = 20; |
|
| 34 | + | if (this.x < -m) this.x = W + m; |
|
| 35 | + | else if (this.x > W + m) this.x = -m; |
|
| 36 | + | if (this.y < -m) this.y = H + m; |
|
| 37 | + | else if (this.y > H + m) this.y = -m; |
|
| 38 | + | } |
|
| 39 | + | ||
| 40 | + | flock(candidates: Boid[], attractor: { x: number; y: number }) { |
|
| 41 | + | const dists: { o: Boid; d2: number; dx: number; dy: number }[] = []; |
|
| 42 | + | for (let i = 0; i < candidates.length; i++) { |
|
| 43 | + | const o = candidates[i]; |
|
| 44 | + | if (o === this) continue; |
|
| 45 | + | const dx = o.x - this.x; |
|
| 46 | + | const dy = o.y - this.y; |
|
| 47 | + | const d2 = dx * dx + dy * dy; |
|
| 48 | + | if (d2 < PERCEPTION * PERCEPTION && d2 > 0) { |
|
| 49 | + | dists.push({ o, d2, dx, dy }); |
|
| 50 | + | } |
|
| 51 | + | } |
|
| 52 | + | dists.sort((a, b) => a.d2 - b.d2); |
|
| 53 | + | const k = Math.min(TOPOLOGICAL_K, dists.length); |
|
| 54 | + | ||
| 55 | + | let alignX = 0, |
|
| 56 | + | alignY = 0; |
|
| 57 | + | let cohX = 0, |
|
| 58 | + | cohY = 0; |
|
| 59 | + | let sepX = 0, |
|
| 60 | + | sepY = 0; |
|
| 61 | + | let sepCount = 0; |
|
| 62 | + | ||
| 63 | + | for (let i = 0; i < k; i++) { |
|
| 64 | + | const { o, d2, dx, dy } = dists[i]; |
|
| 65 | + | alignX += o.vx; |
|
| 66 | + | alignY += o.vy; |
|
| 67 | + | cohX += o.x; |
|
| 68 | + | cohY += o.y; |
|
| 69 | + | ||
| 70 | + | if (d2 < SEP_RANGE * SEP_RANGE) { |
|
| 71 | + | const d = Math.sqrt(d2); |
|
| 72 | + | const f = 1 / (d + 0.001); |
|
| 73 | + | sepX -= (dx / d) * f; |
|
| 74 | + | sepY -= (dy / d) * f; |
|
| 75 | + | sepCount++; |
|
| 76 | + | } |
|
| 77 | + | } |
|
| 78 | + | ||
| 79 | + | let fx = 0, |
|
| 80 | + | fy = 0; |
|
| 81 | + | ||
| 82 | + | if (k > 0) { |
|
| 83 | + | alignX /= k; |
|
| 84 | + | alignY /= k; |
|
| 85 | + | const m = Math.hypot(alignX, alignY); |
|
| 86 | + | if (m > 0) { |
|
| 87 | + | alignX = (alignX / m) * MAX_SPEED - this.vx; |
|
| 88 | + | alignY = (alignY / m) * MAX_SPEED - this.vy; |
|
| 89 | + | const sm = Math.hypot(alignX, alignY); |
|
| 90 | + | if (sm > MAX_FORCE) { |
|
| 91 | + | alignX = (alignX / sm) * MAX_FORCE; |
|
| 92 | + | alignY = (alignY / sm) * MAX_FORCE; |
|
| 93 | + | } |
|
| 94 | + | fx += alignX * W_ALIGN; |
|
| 95 | + | fy += alignY * W_ALIGN; |
|
| 96 | + | } |
|
| 97 | + | ||
| 98 | + | cohX = cohX / k - this.x; |
|
| 99 | + | cohY = cohY / k - this.y; |
|
| 100 | + | const cm = Math.hypot(cohX, cohY); |
|
| 101 | + | if (cm > 0) { |
|
| 102 | + | cohX = (cohX / cm) * MAX_SPEED - this.vx; |
|
| 103 | + | cohY = (cohY / cm) * MAX_SPEED - this.vy; |
|
| 104 | + | const sm = Math.hypot(cohX, cohY); |
|
| 105 | + | if (sm > MAX_FORCE) { |
|
| 106 | + | cohX = (cohX / sm) * MAX_FORCE; |
|
| 107 | + | cohY = (cohY / sm) * MAX_FORCE; |
|
| 108 | + | } |
|
| 109 | + | fx += cohX * W_COH; |
|
| 110 | + | fy += cohY * W_COH; |
|
| 111 | + | } |
|
| 112 | + | } |
|
| 113 | + | ||
| 114 | + | if (sepCount > 0) { |
|
| 115 | + | const m = Math.hypot(sepX, sepY); |
|
| 116 | + | if (m > 0) { |
|
| 117 | + | sepX = (sepX / m) * MAX_SPEED - this.vx; |
|
| 118 | + | sepY = (sepY / m) * MAX_SPEED - this.vy; |
|
| 119 | + | const sm = Math.hypot(sepX, sepY); |
|
| 120 | + | if (sm > MAX_FORCE * 2) { |
|
| 121 | + | sepX = (sepX / sm) * MAX_FORCE * 2; |
|
| 122 | + | sepY = (sepY / sm) * MAX_FORCE * 2; |
|
| 123 | + | } |
|
| 124 | + | fx += sepX * W_SEP; |
|
| 125 | + | fy += sepY * W_SEP; |
|
| 126 | + | } |
|
| 127 | + | } |
|
| 128 | + | ||
| 129 | + | const adx = attractor.x - this.x; |
|
| 130 | + | const ady = attractor.y - this.y; |
|
| 131 | + | const ad = Math.hypot(adx, ady); |
|
| 132 | + | if (ad > 0) { |
|
| 133 | + | fx += (adx / ad) * W_ATTRACT; |
|
| 134 | + | fy += (ady / ad) * W_ATTRACT; |
|
| 135 | + | } |
|
| 136 | + | ||
| 137 | + | fx += (Math.random() - 0.5) * W_NOISE; |
|
| 138 | + | fy += (Math.random() - 0.5) * W_NOISE; |
|
| 139 | + | ||
| 140 | + | this.ax += fx; |
|
| 141 | + | this.ay += fy; |
|
| 142 | + | } |
|
| 143 | + | ||
| 144 | + | update() { |
|
| 145 | + | this.vx += this.ax; |
|
| 146 | + | this.vy += this.ay; |
|
| 147 | + | const sp = Math.hypot(this.vx, this.vy); |
|
| 148 | + | if (sp > MAX_SPEED) { |
|
| 149 | + | this.vx = (this.vx / sp) * MAX_SPEED; |
|
| 150 | + | this.vy = (this.vy / sp) * MAX_SPEED; |
|
| 151 | + | } else if (sp < MIN_SPEED && sp > 0) { |
|
| 152 | + | this.vx = (this.vx / sp) * MIN_SPEED; |
|
| 153 | + | this.vy = (this.vy / sp) * MIN_SPEED; |
|
| 154 | + | } |
|
| 155 | + | this.x += this.vx; |
|
| 156 | + | this.y += this.vy; |
|
| 157 | + | this.ax = 0; |
|
| 158 | + | this.ay = 0; |
|
| 159 | + | } |
|
| 160 | + | } |
|
| 161 | + | ||
| 162 | + | export function drawBoid(ctx: CanvasRenderingContext2D, b: Boid) { |
|
| 163 | + | const angle = Math.atan2(b.vy, b.vx); |
|
| 164 | + | const cos = Math.cos(angle); |
|
| 165 | + | const sin = Math.sin(angle); |
|
| 166 | + | const len = 3.2; |
|
| 167 | + | ctx.beginPath(); |
|
| 168 | + | ctx.moveTo(b.x - cos * len, b.y - sin * len); |
|
| 169 | + | ctx.lineTo(b.x + cos * len * 0.6, b.y + sin * len * 0.6); |
|
| 170 | + | ctx.stroke(); |
|
| 171 | + | } |
|
| 172 | + | ||
| 173 | + | export type Sim = ReturnType<typeof createSim>; |
|
| 174 | + | ||
| 175 | + | export function createSim(W: number, H: number) { |
|
| 176 | + | const state = { |
|
| 177 | + | W, |
|
| 178 | + | H, |
|
| 179 | + | boids: [] as Boid[], |
|
| 180 | + | attractor: { |
|
| 181 | + | x: W / 2, |
|
| 182 | + | y: H / 2, |
|
| 183 | + | t: Math.random() * 1000, |
|
| 184 | + | }, |
|
| 185 | + | }; |
|
| 186 | + | ||
| 187 | + | const count = Math.min(420, Math.floor((W * H) / 3200)); |
|
| 188 | + | const cx0 = W / 2, |
|
| 189 | + | cy0 = H / 2; |
|
| 190 | + | for (let i = 0; i < count; i++) { |
|
| 191 | + | const b = new Boid(W, H); |
|
| 192 | + | const r = Math.random() * Math.min(W, H) * 0.25; |
|
| 193 | + | const a = Math.random() * Math.PI * 2; |
|
| 194 | + | b.x = cx0 + Math.cos(a) * r; |
|
| 195 | + | b.y = cy0 + Math.sin(a) * r; |
|
| 196 | + | state.boids.push(b); |
|
| 197 | + | } |
|
| 198 | + | ||
| 199 | + | const cellSize = PERCEPTION; |
|
| 200 | + | const grid = new Map<string, Boid[]>(); |
|
| 201 | + | ||
| 202 | + | function buildGrid() { |
|
| 203 | + | grid.clear(); |
|
| 204 | + | for (let i = 0; i < state.boids.length; i++) { |
|
| 205 | + | const b = state.boids[i]; |
|
| 206 | + | const cx = Math.floor(b.x / cellSize); |
|
| 207 | + | const cy = Math.floor(b.y / cellSize); |
|
| 208 | + | const k = cx + ',' + cy; |
|
| 209 | + | let arr = grid.get(k); |
|
| 210 | + | if (!arr) { |
|
| 211 | + | arr = []; |
|
| 212 | + | grid.set(k, arr); |
|
| 213 | + | } |
|
| 214 | + | arr.push(b); |
|
| 215 | + | } |
|
| 216 | + | } |
|
| 217 | + | ||
| 218 | + | function neighbors(b: Boid): Boid[] { |
|
| 219 | + | const cx = Math.floor(b.x / cellSize); |
|
| 220 | + | const cy = Math.floor(b.y / cellSize); |
|
| 221 | + | const out: Boid[] = []; |
|
| 222 | + | for (let dx = -1; dx <= 1; dx++) { |
|
| 223 | + | for (let dy = -1; dy <= 1; dy++) { |
|
| 224 | + | const arr = grid.get(cx + dx + ',' + (cy + dy)); |
|
| 225 | + | if (arr) for (let i = 0; i < arr.length; i++) out.push(arr[i]); |
|
| 226 | + | } |
|
| 227 | + | } |
|
| 228 | + | return out; |
|
| 229 | + | } |
|
| 230 | + | ||
| 231 | + | function updateAttractor() { |
|
| 232 | + | const a = state.attractor; |
|
| 233 | + | a.t += 0.003; |
|
| 234 | + | const cx = state.W / 2, |
|
| 235 | + | cy = state.H / 2; |
|
| 236 | + | const rx = state.W * 0.32, |
|
| 237 | + | ry = state.H * 0.28; |
|
| 238 | + | a.x = cx + Math.sin(a.t * 1.3) * rx + Math.cos(a.t * 0.7) * rx * 0.3; |
|
| 239 | + | a.y = cy + Math.cos(a.t * 1.1) * ry + Math.sin(a.t * 1.7) * ry * 0.25; |
|
| 240 | + | } |
|
| 241 | + | ||
| 242 | + | function step() { |
|
| 243 | + | updateAttractor(); |
|
| 244 | + | buildGrid(); |
|
| 245 | + | for (let i = 0; i < state.boids.length; i++) { |
|
| 246 | + | state.boids[i].flock(neighbors(state.boids[i]), state.attractor); |
|
| 247 | + | } |
|
| 248 | + | for (let i = 0; i < state.boids.length; i++) { |
|
| 249 | + | state.boids[i].update(); |
|
| 250 | + | state.boids[i].edges(state.W, state.H); |
|
| 251 | + | } |
|
| 252 | + | } |
|
| 253 | + | ||
| 254 | + | function resize(W: number, H: number) { |
|
| 255 | + | state.W = W; |
|
| 256 | + | state.H = H; |
|
| 257 | + | } |
|
| 258 | + | ||
| 259 | + | return { state, step, resize }; |
|
| 260 | + | } |
| 1 | + | import { COLS, ROWS } from '../audio/scales'; |
|
| 2 | + | ||
| 3 | + | export function cellAt( |
|
| 4 | + | x: number, |
|
| 5 | + | y: number, |
|
| 6 | + | W: number, |
|
| 7 | + | H: number, |
|
| 8 | + | ): number { |
|
| 9 | + | const col = Math.min(COLS - 1, Math.max(0, Math.floor((x / W) * COLS))); |
|
| 10 | + | const row = Math.min(ROWS - 1, Math.max(0, Math.floor((y / H) * ROWS))); |
|
| 11 | + | return row * COLS + col; |
|
| 12 | + | } |
|
| 13 | + | ||
| 14 | + | export function cellRowCol(idx: number): { row: number; col: number } { |
|
| 15 | + | return { row: Math.floor(idx / COLS), col: idx % COLS }; |
|
| 16 | + | } |
|
| 17 | + | ||
| 18 | + | export function drawGrid( |
|
| 19 | + | ctx: CanvasRenderingContext2D, |
|
| 20 | + | W: number, |
|
| 21 | + | H: number, |
|
| 22 | + | flashes: Map<number, number>, |
|
| 23 | + | now: number, |
|
| 24 | + | flashMs: number, |
|
| 25 | + | ) { |
|
| 26 | + | const cw = W / COLS; |
|
| 27 | + | const ch = H / ROWS; |
|
| 28 | + | ||
| 29 | + | for (const [idx, end] of flashes) { |
|
| 30 | + | const remain = end - now; |
|
| 31 | + | if (remain <= 0) { |
|
| 32 | + | flashes.delete(idx); |
|
| 33 | + | continue; |
|
| 34 | + | } |
|
| 35 | + | const a = (remain / flashMs) * 0.18; |
|
| 36 | + | const { row, col } = cellRowCol(idx); |
|
| 37 | + | ctx.fillStyle = `rgba(245, 243, 238, ${a})`; |
|
| 38 | + | ctx.fillRect(col * cw, row * ch, cw, ch); |
|
| 39 | + | } |
|
| 40 | + | ||
| 41 | + | ctx.strokeStyle = 'rgba(245, 243, 238, 0.08)'; |
|
| 42 | + | ctx.lineWidth = 1; |
|
| 43 | + | ctx.beginPath(); |
|
| 44 | + | for (let c = 1; c < COLS; c++) { |
|
| 45 | + | const x = c * cw; |
|
| 46 | + | ctx.moveTo(x, 0); |
|
| 47 | + | ctx.lineTo(x, H); |
|
| 48 | + | } |
|
| 49 | + | for (let r = 1; r < ROWS; r++) { |
|
| 50 | + | const y = r * ch; |
|
| 51 | + | ctx.moveTo(0, y); |
|
| 52 | + | ctx.lineTo(W, y); |
|
| 53 | + | } |
|
| 54 | + | ctx.stroke(); |
|
| 55 | + | } |