diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index d119fd4..5de0cff 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -35,12 +35,12 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Lint, format, test, and build - run: pnpm run check - - name: Install Chromium run: pnpm exec playwright install --with-deps chromium + - name: Lint, format, test, and build + run: pnpm run check + - name: Test the built site and worker run: pnpm run test:browser diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 7920b74..f077b5f 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -30,11 +30,11 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Lint, format, test, and build - run: pnpm run check - - name: Install Chromium run: pnpm exec playwright install --with-deps chromium + - name: Lint, format, test, and build + run: pnpm run check + - name: Test the built site and worker run: pnpm run test:browser diff --git a/docs/architecture.md b/docs/architecture.md index 17f6288..1dbbe01 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -13,7 +13,7 @@ Paths below are relative to `src/`; `@/` aliases that directory. | `components/playground/`, `components/dialogs/` | Interactive controls, editor, and MIDI export | | `components/ui/` | Shared shadcn Base UI primitives | | `components/visualizations/` | React SVG charts and envelope diagram | -| `controllers/` | Playground/player coordination and settings connections | +| `hooks/` | React effects for players, audio settings, sorting, and cleanup | | `state/` | Jotai settings, envelopes, playback preferences, and algorithm overrides | | `sorting/` | Recorder, worker protocol, registries, and algorithm implementations | | `generators/`, `midi/` | Input patterns, musical data, and MIDI encoding | @@ -31,11 +31,21 @@ About/API import Markdown from `docs/` through `components/docs-page.tsx` and remain readable without JavaScript. Browser audio and editor modules do not run during prerendering. -`controllers/create-playground.mjs` coordinates data generation, workers, settings -connections, sample preloading, and two players. Each player publishes frames, -position, and visualization selection. React reads runtime snapshots through -`useSyncExternalStore` and settings through the application-scoped Jotai store. -Audio clocks, nodes, and sample caches stay outside React and Jotai. +The document's Jotai `Provider` owns state across client-side route changes. +Components use `useAtomValue`, `useAtom`, and `useSetAtom` directly. Settings, +algorithm overrides, visualization choice, and player snapshots live in atoms; +there is no module-level store or parallel subscription system. + +`components/playground/playground-context.tsx` provides playback, editing, and +export actions. `hooks/use-players.ts` creates and disposes the audio resources, +then synchronizes waveform, volume, tempo, instrument, and visualization through +React effects. `hooks/use-sort.ts` manages input data, sample preloading, and +worker requests; effect cleanup cancels pending edits and ignores stale replies. + +Audio clocks, nodes, and sample caches are imperative services owned by the +player effect. Their callbacks read current values from the provider's store +through `audio/audio-settings.ts`, without creating their own subscriptions. +Player callbacks write snapshots into atoms for React to render. React owns SVG children; D3 supplies array, scale, color, and path utilities. Trajectory geometry is memoized by frame data. The waveform preview canvas is @@ -46,8 +56,9 @@ Base UI handles dialog focus and dismissal. Ace loads on demand, with pending initialization cancelled on close. Tab panels stay mounted for editor/canvas lifetime. Invalid source leaves the algorithm catalog unchanged. -Cached-page suspension disconnects effects, pauses audio, cancels workers and -pending resumes, and closes dialogs. Returning reconnects without automatically playing. +Cached-page suspension pauses audio immediately, cancels workers and pending +resumes, and closes dialogs. React effects synchronize current settings when the +page returns, without automatically playing. Leaving Home disposes the playground and its owned resources. The shared AudioContext remains library-owned. diff --git a/docs/development.md b/docs/development.md index eae857e..0485806 100644 --- a/docs/development.md +++ b/docs/development.md @@ -42,9 +42,14 @@ repository's Pages source to **GitHub Actions**. No Node server is deployed. | `pnpm run format` / `pnpm run format:check` | Format files / check formatting | | `pnpm run spellcheck` | CSpell checks | | `pnpm run typecheck` | Strict checking of application TypeScript; `.mjs` is not type-checked | -| `pnpm test` / `pnpm run test:watch` | Unit tests / watch mode | +| `pnpm test` / `pnpm run test:watch` | Node and Chromium hook tests / watch mode | -Run browser tests against a completed build: +Vitest runs pure unit tests in Node and React hook tests in Chromium through its +Playwright provider. Install Chromium once with `pnpm exec playwright install chromium` +before running `pnpm test` or `pnpm run check`. To run only Node tests, use +`pnpm test --project=node`. + +The separate Playwright suite tests the completed production build: ```sh pnpm exec playwright install chromium diff --git a/oxlint.config.ts b/oxlint.config.ts index ca4d28a..788ab26 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "oxlint"; export default defineConfig({ - plugins: ["eslint", "unicorn", "oxc", "vitest", "typescript", "jsx-a11y"], + plugins: ["eslint", "unicorn", "oxc", "vitest", "typescript", "jsx-a11y", "react"], categories: { correctness: "error" }, env: { node: true }, ignorePatterns: [ @@ -16,6 +16,8 @@ export default defineConfig({ "test-results/**", ], rules: { + "react/rules-of-hooks": "error", + "react/exhaustive-deps": "error", // SVG/canvas images and live status regions intentionally use ARIA roles. "jsx-a11y/prefer-tag-over-role": "off", "typescript/consistent-type-definitions": ["error", "type"], @@ -25,6 +27,12 @@ export default defineConfig({ "one-var": ["error", "never"], }, overrides: [ + { + // These effects initialize browser-only resources or read the client clock + // after hydration; neither operation can run during prerendering. + files: ["src/hooks/use-players.ts", "src/components/layout/footer.tsx"], + rules: { "react/set-state-in-effect": "off" }, + }, { files: ["src/components/docs-page.tsx"], // Keyboard users must be able to scroll wide documentation tables. diff --git a/package.json b/package.json index 946e88d..3066789 100644 --- a/package.json +++ b/package.json @@ -61,12 +61,17 @@ "@axe-core/playwright": "^4.13.0", "@playwright/test": "^1.63.0", "@tailwindcss/vite": "^4.3.3", + "@testing-library/react": "^16.3.3", + "@types/d3-array": "^3.2.2", "@types/d3-color": "^3.1.3", + "@types/d3-scale": "^4.0.9", "@types/d3-shape": "^3.2.0", + "@types/file-saver": "^2.0.7", "@types/node": "^26.5.0", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.7", "@vitejs/plugin-react": "^6.1.1", + "@vitest/browser-playwright": "^5.0.0", "cspell": "^10.2.2", "oxfmt": "^0.67.0", "oxlint": "^1.82.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 02d7535..c4fc04d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -186,12 +186,24 @@ importers: '@tailwindcss/vite': specifier: ^4.3.3 version: 4.3.3(vite@8.2.2(@types/node@26.5.0)(jiti@2.7.0)(yaml@2.9.0)) + '@testing-library/react': + specifier: ^16.3.3 + version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@types/d3-array': + specifier: ^3.2.2 + version: 3.2.2 '@types/d3-color': specifier: ^3.1.3 version: 3.1.3 + '@types/d3-scale': + specifier: ^4.0.9 + version: 4.0.9 '@types/d3-shape': specifier: ^3.2.0 version: 3.2.0 + '@types/file-saver': + specifier: ^2.0.7 + version: 2.0.7 '@types/node': specifier: ^26.5.0 version: 26.5.0 @@ -204,6 +216,9 @@ importers: '@vitejs/plugin-react': specifier: ^6.1.1 version: 6.1.1(vite@8.2.2(@types/node@26.5.0)(jiti@2.7.0)(yaml@2.9.0)) + '@vitest/browser-playwright': + specifier: ^5.0.0 + version: 5.0.0(playwright@1.63.0)(vite@8.2.2(@types/node@26.5.0)(jiti@2.7.0)(yaml@2.9.0))(vitest@5.0.0) cspell: specifier: ^10.2.2 version: 10.2.2 @@ -227,7 +242,7 @@ importers: version: 8.2.2(@types/node@26.5.0)(jiti@2.7.0)(yaml@2.9.0) vitest: specifier: ^5.0.0 - version: 5.0.0(@types/node@26.5.0)(vite@8.2.2(@types/node@26.5.0)(jiti@2.7.0)(yaml@2.9.0)) + version: 5.0.0(@types/node@26.5.0)(@vitest/browser-playwright@5.0.0)(@vitest/ui@5.0.0)(vite@8.2.2(@types/node@26.5.0)(jiti@2.7.0)(yaml@2.9.0)) packages: @@ -400,6 +415,9 @@ packages: '@types/react': optional: true + '@blazediff/core@1.10.0': + resolution: {integrity: sha512-AOQff0zgR7cGsZL+4E7hVkmujoPUpm0J9xzWGWZj5wCjd3gmxESXAPfKyuzs93VdpQNFhHlBhfOjrcZ+XTERtQ==} + '@cspell/cspell-bundled-dicts@10.2.2': resolution: {integrity: sha512-327sQUcfHjr9TnkX2FYluLuQHSoWjSm9AiPFBpULxHofk7VvKfye12Ur4xMbOzyv7cxMROx/OeYF0oytLuLsvQ==} engines: {node: '>=22.18.0'} @@ -1304,21 +1322,52 @@ packages: resolution: {integrity: sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==} engines: {node: '>=20.19'} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.3': + resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@ts-morph/common@0.27.0': resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + '@types/d3-color@3.1.3': resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} '@types/d3-path@3.1.1': resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + '@types/d3-shape@3.2.0': resolution: {integrity: sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==} + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -1331,6 +1380,9 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/file-saver@2.0.7': + resolution: {integrity: sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==} + '@types/hast@3.0.5': resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} @@ -1499,6 +1551,17 @@ packages: oxc-transform-react: optional: true + '@vitest/browser-playwright@5.0.0': + resolution: {integrity: sha512-N+gED9y4/8pypaHjz/x0ah3CjoBr+N0hWWT+Gq4VXtMzyB+rUIdHni0ulzpD1j4H77iWy5Jg8PRMlVn6kjxo6g==} + peerDependencies: + playwright: '*' + vitest: 5.0.0 + + '@vitest/browser@5.0.0': + resolution: {integrity: sha512-JC9FG5xIRxPHXJPcdCaluIJcEoeM0IwGQ3xneuJk09LXKHRNs40BqWDWymQikbb20yOpYvzpKzmYgPRuSzKmvg==} + peerDependencies: + vitest: 5.0.0 + '@vitest/mocker@5.0.0': resolution: {integrity: sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==} peerDependencies: @@ -1510,9 +1573,20 @@ packages: vite: optional: true + '@vitest/pretty-format@5.0.0': + resolution: {integrity: sha512-PVRNuB3wpReb4SQEs4zTKM4KWFhQ5pw3spE8naoDJNB5T5aWRzGKHwXcLUllr0WeOTXpB6bSr3CJLo5+7XQSSQ==} + '@vitest/spy@5.0.0': resolution: {integrity: sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==} + '@vitest/ui@5.0.0': + resolution: {integrity: sha512-h2FIFwggCY2GxUd2UdQoYNVQkOIqEQLPhNREcl3FUiRsdzQep7NWwYbSmhGEA9nFLPDq5pXzRMcBZQU8Py83sg==} + peerDependencies: + vitest: 5.0.0 + + '@vitest/utils@5.0.0': + resolution: {integrity: sha512-dO++xL3vDfvhTAVimfkuQUA3k+JClIF1i1vAkPqpcGAthRmeWnXmHB7YPViPvgCwviX8u7Y5W1u2N//AaQr3fw==} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -1551,6 +1625,10 @@ packages: resolution: {integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==} engines: {node: '>=12'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansis@4.3.1: resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} engines: {node: '>=14'} @@ -1558,6 +1636,9 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + array-timsort@1.0.3: resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==} @@ -1908,6 +1989,9 @@ packages: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dot-prop@6.0.1: resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==} engines: {node: '>=10'} @@ -2061,6 +2145,9 @@ packages: fetchdts@0.1.7: resolution: {integrity: sha512-YoZjBdafyLIop9lSxXVI33oLD5kN31q4Td+CasofLLYeLXRFeOsuOw0Uo+XNRi9PZlbfdlN2GmRtm4tCEQ9/KA==} + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + figures@6.1.0: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} @@ -2591,6 +2678,10 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -2961,6 +3052,10 @@ packages: engines: {node: '>=20'} hasBin: true + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + postcss-selector-parser@7.1.6: resolution: {integrity: sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==} engines: {node: '>=4'} @@ -2982,6 +3077,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-ms@9.3.1: resolution: {integrity: sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==} engines: {node: '>=18'} @@ -3017,6 +3116,9 @@ packages: peerDependencies: react: ^19.2.8 + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-markdown@10.1.0: resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} peerDependencies: @@ -3306,6 +3408,10 @@ packages: resolution: {integrity: sha512-9YodfrxS9g9IbFr/KOjE5bAeJ0p61n3bW6mqvy0jtoeKd1kTW1Cxm0oulm6KX2lyM9Gl6WIe8nEbY7LWv5ZJww==} engines: {node: ^20.0.0 || >=22.0.0} + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -3566,6 +3672,18 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + wsl-utils@1.0.0: resolution: {integrity: sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA==} engines: {node: '>=20'} @@ -3832,6 +3950,8 @@ snapshots: optionalDependencies: '@types/react': 19.2.18 + '@blazediff/core@1.10.0': {} + '@cspell/cspell-bundled-dicts@10.2.2': dependencies: '@cspell/dict-ada': 4.1.1 @@ -4623,25 +4743,56 @@ snapshots: '@tanstack/virtual-file-routes@1.162.0': {} + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/react@16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) + '@ts-morph/common@0.27.0': dependencies: fast-glob: 3.3.3 minimatch: 10.2.6 path-browserify: 1.0.1 + '@types/aria-query@5.0.4': {} + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/d3-array@3.2.2': {} + '@types/d3-color@3.1.3': {} '@types/d3-path@3.1.1': {} + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + '@types/d3-shape@3.2.0': dependencies: '@types/d3-path': 3.1.1 + '@types/d3-time@3.0.4': {} + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -4654,6 +4805,8 @@ snapshots: '@types/estree@1.0.9': {} + '@types/file-saver@2.0.7': {} + '@types/hast@3.0.5': dependencies: '@types/unist': 3.0.3 @@ -4749,6 +4902,37 @@ snapshots: '@rolldown/pluginutils': 1.0.1 vite: 8.2.2(@types/node@26.5.0)(jiti@2.7.0)(yaml@2.9.0) + '@vitest/browser-playwright@5.0.0(playwright@1.63.0)(vite@8.2.2(@types/node@26.5.0)(jiti@2.7.0)(yaml@2.9.0))(vitest@5.0.0)': + dependencies: + '@vitest/browser': 5.0.0(vite@8.2.2(@types/node@26.5.0)(jiti@2.7.0)(yaml@2.9.0))(vitest@5.0.0) + '@vitest/mocker': 5.0.0(vite@8.2.2(@types/node@26.5.0)(jiti@2.7.0)(yaml@2.9.0)) + playwright: 1.63.0 + tinyrainbow: 3.1.1 + vitest: 5.0.0(@types/node@26.5.0)(@vitest/browser-playwright@5.0.0)(@vitest/ui@5.0.0)(vite@8.2.2(@types/node@26.5.0)(jiti@2.7.0)(yaml@2.9.0)) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/browser@5.0.0(vite@8.2.2(@types/node@26.5.0)(jiti@2.7.0)(yaml@2.9.0))(vitest@5.0.0)': + dependencies: + '@blazediff/core': 1.10.0 + '@vitest/mocker': 5.0.0(vite@8.2.2(@types/node@26.5.0)(jiti@2.7.0)(yaml@2.9.0)) + '@vitest/ui': 5.0.0(vitest@5.0.0) + '@vitest/utils': 5.0.0 + magic-string: 1.2.3 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.1.1 + vitest: 5.0.0(@types/node@26.5.0)(@vitest/browser-playwright@5.0.0)(@vitest/ui@5.0.0)(vite@8.2.2(@types/node@26.5.0)(jiti@2.7.0)(yaml@2.9.0)) + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + '@vitest/mocker@5.0.0(vite@8.2.2(@types/node@26.5.0)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -4758,8 +4942,28 @@ snapshots: optionalDependencies: vite: 8.2.2(@types/node@26.5.0)(jiti@2.7.0)(yaml@2.9.0) + '@vitest/pretty-format@5.0.0': + dependencies: + tinyrainbow: 3.1.1 + '@vitest/spy@5.0.0': {} + '@vitest/ui@5.0.0(vitest@5.0.0)': + dependencies: + '@vitest/utils': 5.0.0 + fflate: 0.8.3 + flatted: 3.4.4 + pathe: 2.0.3 + sirv: 3.0.2 + tinyrainbow: 3.1.1 + vitest: 5.0.0(@types/node@26.5.0)(@vitest/browser-playwright@5.0.0)(@vitest/ui@5.0.0)(vite@8.2.2(@types/node@26.5.0)(jiti@2.7.0)(yaml@2.9.0)) + + '@vitest/utils@5.0.0': + dependencies: + '@vitest/pretty-format': 5.0.0 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -4788,10 +4992,16 @@ snapshots: ansi-regex@6.3.0: {} + ansi-styles@5.2.0: {} + ansis@4.3.1: {} argparse@2.0.1: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + array-timsort@1.0.3: {} assertion-error@2.0.1: {} @@ -5138,6 +5348,8 @@ snapshots: diff@8.0.4: {} + dom-accessibility-api@0.5.16: {} + dot-prop@6.0.1: dependencies: is-obj: 2.0.0 @@ -5310,6 +5522,8 @@ snapshots: fetchdts@0.1.7: {} + fflate@0.8.3: {} + figures@6.1.0: dependencies: is-unicode-supported: 2.1.0 @@ -5709,6 +5923,8 @@ snapshots: dependencies: react: 19.2.8 + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -6281,6 +6497,8 @@ snapshots: dependencies: playwright-core: 1.63.0 + pngjs@7.0.0: {} + postcss-selector-parser@7.1.6: dependencies: cssesc: 3.0.0 @@ -6298,6 +6516,12 @@ snapshots: prettier@3.9.6: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + pretty-ms@9.3.1: dependencies: parse-ms: 4.0.0 @@ -6335,6 +6559,8 @@ snapshots: react: 19.2.8 scheduler: 0.27.0 + react-is@17.0.2: {} + react-markdown@10.1.0(@types/react@19.2.18)(react@19.2.8): dependencies: '@types/hast': 3.0.5 @@ -6689,6 +6915,8 @@ snapshots: tinypool@2.1.2: {} + tinyrainbow@3.1.1: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -6842,7 +7070,7 @@ snapshots: optionalDependencies: vite: 8.2.2(@types/node@26.5.0)(jiti@2.7.0)(yaml@2.9.0) - vitest@5.0.0(@types/node@26.5.0)(vite@8.2.2(@types/node@26.5.0)(jiti@2.7.0)(yaml@2.9.0)): + vitest@5.0.0(@types/node@26.5.0)(@vitest/browser-playwright@5.0.0)(@vitest/ui@5.0.0)(vite@8.2.2(@types/node@26.5.0)(jiti@2.7.0)(yaml@2.9.0)): dependencies: '@types/chai': 5.2.3 '@vitest/mocker': 5.0.0(vite@8.2.2(@types/node@26.5.0)(jiti@2.7.0)(yaml@2.9.0)) @@ -6860,6 +7088,8 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 26.5.0 + '@vitest/browser-playwright': 5.0.0(playwright@1.63.0)(vite@8.2.2(@types/node@26.5.0)(jiti@2.7.0)(yaml@2.9.0))(vitest@5.0.0) + '@vitest/ui': 5.0.0(vitest@5.0.0) transitivePeerDependencies: - msw @@ -6884,6 +7114,8 @@ snapshots: wrappy@1.0.2: {} + ws@8.21.3: {} + wsl-utils@1.0.0: dependencies: is-wsl: 3.1.1 diff --git a/src/audio/audio-settings.ts b/src/audio/audio-settings.ts new file mode 100644 index 0000000..55267db --- /dev/null +++ b/src/audio/audio-settings.ts @@ -0,0 +1,15 @@ +import type { useStore } from "jotai"; +import { settingsAtom } from "../state/settings"; +import { selectedWaveformAtom } from "../state/waveforms"; + +// Audio clock callbacks read current values without subscribing or owning a store. +export function createAudioSettings(store: ReturnType) { + return { + getSelected(key: string, fallback?: unknown) { + const selected = store.get(settingsAtom); + return Object.hasOwn(selected, key) ? selected[key as keyof typeof selected] : fallback; + }, + getSelectedWaveformInfo: () => store.get(selectedWaveformAtom), + getTempoString: () => `bpm${store.get(settingsAtom).tempo} l16`, + }; +} diff --git a/src/controllers/create-player.mjs b/src/audio/create-player.mjs similarity index 92% rename from src/controllers/create-player.mjs rename to src/audio/create-player.mjs index 1206751..677436f 100644 --- a/src/controllers/create-player.mjs +++ b/src/audio/create-player.mjs @@ -1,9 +1,9 @@ -import { createTimbreAudio } from "../audio/create-timbre-audio.mjs"; -import { createTransport } from "../audio/create-transport.ts"; -import { timbre } from "../audio/timbre.mjs"; +import { createTimbreAudio } from "./create-timbre-audio.mjs"; +import { createTransport } from "./create-transport.ts"; +import { timbre } from "./timbre.mjs"; import { visualizations } from "../visualizations/visualization-types.ts"; import { createMidiBytes } from "../midi/create-midi-bytes.mjs"; -import { drawStringPreview } from "../audio/string-preview.ts"; +import { drawStringPreview } from "./string-preview.ts"; // Owns transport and audio resources; React renders the published chart data. export function createPlayer({ settings, getMidiNumber, soundfont, isLooping, onUpdate }) { diff --git a/src/components/dialogs/algorithm-dialog.tsx b/src/components/dialogs/algorithm-dialog.tsx index 5613741..74d4f78 100644 --- a/src/components/dialogs/algorithm-dialog.tsx +++ b/src/components/dialogs/algorithm-dialog.tsx @@ -1,6 +1,6 @@ import { Button } from "@/components/ui/button"; import { useEffect, useRef, useState } from "react"; -import { useAtomValue } from "jotai"; +import { useAtomValue, useSetAtom } from "jotai"; import { settingsAtom } from "../../state/settings.ts"; import { algorithmCatalogAtom, @@ -11,17 +11,14 @@ import { algorithms } from "../../sorting/algorithm-registry.mjs"; import { sources } from "../../sorting/algorithm-sources.mjs"; import { getFunctionBody } from "../../sorting/sort-requests.ts"; import type { createCodeEditor } from "./create-code-editor.mjs"; -import type { Props } from "../../controllers/playground-types.ts"; import { PlayerDialog as Dialog } from "./player-dialog.tsx"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { Input } from "@/components/ui/input"; -export function AlgorithmDialog({ - runtime, - adding, - onClose, -}: Props & { adding: boolean; onClose: () => void }) { - const { sort } = useAtomValue(settingsAtom, { store: runtime.store }); - const catalog = useAtomValue(algorithmCatalogAtom, { store: runtime.store }); +export function AlgorithmDialog({ adding, onClose }: { adding: boolean; onClose: () => void }) { + const addAlgorithm = useSetAtom(addAlgorithmAtom); + const editAlgorithm = useSetAtom(editAlgorithmAtom); + const { sort } = useAtomValue(settingsAtom); + const catalog = useAtomValue(algorithmCatalogAtom); const algorithm = catalog[sort]!; const [name, setName] = useState(""); const [tab, setTab] = useState(adding ? "algorithm" : "information"); @@ -70,12 +67,12 @@ export function AlgorithmDialog({ try { if (adding) { if (!name.trim()) return; - runtime.store.set(addAlgorithmAtom, { + addAlgorithm({ id: `custom_${crypto.randomUUID()}`, name: name.trim(), source: editor.current.getValue(), }); - } else runtime.store.set(editAlgorithmAtom, { id: sort, source: editor.current.getValue() }); + } else editAlgorithm({ id: sort, source: editor.current.getValue() }); onClose(); } catch (error) { setSaveError(error instanceof Error ? error.message : String(error)); diff --git a/src/components/dialogs/midi-dialog.tsx b/src/components/dialogs/midi-dialog.tsx index ec0160d..8d1e473 100644 --- a/src/components/dialogs/midi-dialog.tsx +++ b/src/components/dialogs/midi-dialog.tsx @@ -1,15 +1,13 @@ +import { usePlayground } from "../playground/playground-context"; import { useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { NativeSelect } from "@/components/ui/native-select"; import { instruments } from "../../midi/instruments.ts"; import { PlayerDialog as Dialog } from "./player-dialog.tsx"; -import type { Props, PlayerId } from "../../controllers/playground-types.ts"; -export function MidiDialog({ - runtime, - id, - onClose, -}: Props & { id: PlayerId; onClose: () => void }) { +import type { PlayerId } from "../../state/players"; +export function MidiDialog({ id, onClose }: { id: PlayerId; onClose: () => void }) { + const actions = usePlayground(); const [name, setName] = useState(""); const [placeholder] = useState(() => `AudioSort_${Date.now()}`); const [channel, setChannel] = useState(0); @@ -24,7 +22,7 @@ export function MidiDialog({ type="button" id="midi-export-btn" variant="default" - onClick={() => runtime.exportMidi(id, name.trim() || placeholder, channel, instrument)} + onClick={() => actions.exportMidi(id, name.trim() || placeholder, channel, instrument)} > Export As Midi diff --git a/src/components/layout/site-document.tsx b/src/components/layout/site-document.tsx index 314c7da..769d6f0 100644 --- a/src/components/layout/site-document.tsx +++ b/src/components/layout/site-document.tsx @@ -1,3 +1,4 @@ +import { Provider } from "jotai"; import type { ReactNode } from "react"; import { HeadContent, Scripts } from "@tanstack/react-router"; import { Header } from "./header.tsx"; @@ -25,7 +26,7 @@ export function SiteDocument({ children }: { children: ReactNode }) {
- {children} + {children}