feat: mvp 5c472968
Steve · 2026-05-03 13:46 21 file(s) · +1114 −419
index.html +24 −2
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>
public/android-chrome-192x192.png (added) +0 −0

Binary file — no preview.

public/android-chrome-512x512.png (added) +0 −0

Binary file — no preview.

public/apple-touch-icon.png (added) +0 −0

Binary file — no preview.

public/favicon-16x16.png (added) +0 −0

Binary file — no preview.

public/favicon-32x32.png (added) +0 −0

Binary file — no preview.

public/favicon.ico (added) +0 −0

Binary file — no preview.

public/favicon.svg (deleted) +0 −1
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>
public/icon.png (added) +0 −0

Binary file — no preview.

public/icons.svg (deleted) +0 −24
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>
public/og.png (added) +0 −0

Binary file — no preview.

public/site.webmanifest (added) +1 −0
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"}
src/App.css +54 −168
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
}
src/App.tsx +4 −118
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;
src/audio/engine.ts (added) +251 −0
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 +
}
src/audio/scales.ts (added) +45 −0
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 +
}
src/components/Controls.tsx (added) +201 −0
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 +
}
src/components/Murmurations.tsx (added) +203 −0
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 +
}
src/index.css +16 −106
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
}
src/sim/boids.ts (added) +260 −0
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 +
}
src/sim/grid.ts (added) +55 −0
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 +
}