From c719b7e5b35c715cc06b84403457aebfb5a54b62 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 16 May 2026 14:03:27 +0100 Subject: [PATCH 01/20] Clean up --- assets/fonts/comfortaa-v40-latin-regular.woff | Bin 15100 -> 0 bytes assets/fonts/comfortaa-v40-latin-regular.woff2 | Bin 12028 -> 0 bytes assets/icons/brush.svg | 6 ------ assets/icons/download.svg | 2 +- assets/icons/info.svg | 2 +- assets/icons/maximize.svg | 2 +- assets/icons/minimize.svg | 2 +- assets/icons/restart.svg | 2 +- assets/icons/settings.svg | 2 +- assets/icons/sound.svg | 4 ++-- 10 files changed, 8 insertions(+), 14 deletions(-) delete mode 100644 assets/fonts/comfortaa-v40-latin-regular.woff delete mode 100644 assets/fonts/comfortaa-v40-latin-regular.woff2 delete mode 100644 assets/icons/brush.svg diff --git a/assets/fonts/comfortaa-v40-latin-regular.woff b/assets/fonts/comfortaa-v40-latin-regular.woff deleted file mode 100644 index c54393b0f232a808940e24118be9d4c56f29553c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15100 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

wmmAf(Ia5sSiLTs)4zmfsP_ibo1X7^{SoZPb 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 amd?$S( - - - - - \ No newline at end of file diff --git a/assets/icons/download.svg b/assets/icons/download.svg index f880e05..423bc7d 100644 --- a/assets/icons/download.svg +++ b/assets/icons/download.svg @@ -2,7 +2,7 @@ + diff --git a/assets/icons/maximize.svg b/assets/icons/maximize.svg index 9c8558e..7239da3 100644 --- a/assets/icons/maximize.svg +++ b/assets/icons/maximize.svg @@ -1,4 +1,4 @@ - + diff --git a/assets/icons/minimize.svg b/assets/icons/minimize.svg index 05d54a8..2ffe1f7 100644 --- a/assets/icons/minimize.svg +++ b/assets/icons/minimize.svg @@ -1,4 +1,4 @@ - + diff --git a/assets/icons/restart.svg b/assets/icons/restart.svg index f87e22b..a58d2a6 100644 --- a/assets/icons/restart.svg +++ b/assets/icons/restart.svg @@ -1,4 +1,4 @@ - + diff --git a/assets/icons/settings.svg b/assets/icons/settings.svg index 176c63c..ba7478f 100644 --- a/assets/icons/settings.svg +++ b/assets/icons/settings.svg @@ -1,4 +1,4 @@ - + diff --git a/assets/icons/sound.svg b/assets/icons/sound.svg index 78dbb2b..d440a9c 100644 --- a/assets/icons/sound.svg +++ b/assets/icons/sound.svg @@ -1,3 +1,3 @@ - - + + From 9256377c13755f88e6c59bc367ede955ba7e8e6f Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 16 May 2026 14:04:43 +0100 Subject: [PATCH 02/20] Clean up CI --- .claude/settings.json | 5 ----- .forgejo/workflows/deploy.yml | 23 ++++++++++------------- 2 files changed, 10 insertions(+), 18 deletions(-) delete mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 9030888..0000000 --- a/.claude/settings.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "enabledPlugins": { - "frontend-design@claude-plugins-official": true - } -} diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 93748c6..feaf56e 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -1,4 +1,4 @@ -name: Deploy to Pages +name: Check & deploy on: push: @@ -25,25 +25,22 @@ jobs: cache: 'npm' - name: Install dependencies - run: npm ci - - - name: Install Playwright browsers - run: npx playwright install --with-deps chromium + run: | + npm ci + npx playwright install --with-deps chromium - name: Lint run: npm run lint - name: Typecheck - run: npm run typecheck - - - name: Typecheck browser tests - run: npm run typecheck:e2e + run: | + npm run typecheck + npm run typecheck:e2e - name: Test - run: npm test - - - name: Browser tests - run: npm run test:e2e + run: | + npm test + npm run test:e2e - name: Upload Playwright report if: failure() From 20433bd9f0d41a79a0c24e32658f7e62b92abe33 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 16 May 2026 14:21:10 +0100 Subject: [PATCH 03/20] Remove rnadom script --- .forgejo/workflows/deploy.yml | 3 +- package-lock.json | 931 ++++++++++++++++++++++++++++++- package.json | 8 +- public/404.html | 43 -- scripts/check-unused-exports.mjs | 197 ------- 5 files changed, 934 insertions(+), 248 deletions(-) delete mode 100644 public/404.html delete mode 100644 scripts/check-unused-exports.mjs diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index feaf56e..710c622 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -30,10 +30,11 @@ jobs: npx playwright install --with-deps chromium - name: Lint - run: npm run lint + run: npm run lint:check - name: Typecheck run: | + npm run unused:check npm run typecheck npm run typecheck:e2e diff --git a/package-lock.json b/package-lock.json index 7c9269f..054ae44 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "eslint-plugin-unused-imports": "^4.4.1", "gl-matrix": "^3.4.4", "globals": "^17.6.0", + "knip": "^6.14.1", "lightningcss": "^1.32.0", "npm-check-updates": "^22.1.0", "prettier": "^3.8.3", @@ -911,6 +912,372 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@oxc-parser/binding-android-arm-eabi": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.130.0.tgz", + "integrity": "sha512-h/xYU8/7ADWzVSf5I+YalLpj33LOy9CI/zgbJNIZ5eunRBG+Czqa3lZsvuPHHf3rOt6z1c5+UzoxjbAzAvhwVw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-android-arm64": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.130.0.tgz", + "integrity": "sha512-oFWFJrsGv9siFM4HjMqKNB7IuIZD/SMmZdCXl8xyx7lDplGvPKyewpOo272rSWgMXe2Wx7bWI0Yj+gkHv4qbeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-arm64": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.130.0.tgz", + "integrity": "sha512-sGUzupdTplK9jQg7eJZ878HfEgQjJNBc6dAYVWJ9W5aU+J8rLfRJhTVsKThiu1pNwm6Y1qKCcbC6WhNWSXR3Ig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-x64": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.130.0.tgz", + "integrity": "sha512-PsB4cdCISbC00Uy8eiD8bc2AkGWjZqrSrJnkBFuG2ptrrf6mZ2F5gLFSjOAVMMgZPg8B1D7OydJwLWSfyI2Plg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-freebsd-x64": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.130.0.tgz", + "integrity": "sha512-DgABp3l38hS77JbXCV4qk1+n6DPym5u8zzwuweokezm2tX194nDSJDENbDRECxVsiNbprKATLbk+Z5wlHT0OHw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.130.0.tgz", + "integrity": "sha512-4Kn3CTEmwFrzhTSC/JuUW16qovmaMdX7jeSKbL8w0pLtLww7To1a2XJi9Z5uD8QWUkfUHhqfV+VD6dVzBnWzoA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-musleabihf": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.130.0.tgz", + "integrity": "sha512-D35KZM3F4rRu1uAFKyBlg3Gaf/ybCjyaPR1hfgvk5ex8NtcTmRgc0JgSighEyNg96TPrFhemFba68SZuxaha8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-gnu": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.130.0.tgz", + "integrity": "sha512-Q9o7oVlo955KHwS8l1u0bCzIx+JsZUA3XToLXC+MsMhye/9LeBQbt84nh120cl2XLy+TEzvugYDiHShg5yaX6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-musl": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.130.0.tgz", + "integrity": "sha512-EiJ/gC0ljbcwVpycC8YWw6ggMbtsPX8XMOt0mPx0aqWeMsNR+L9m05Flbvd5T+GlivG+GkSWQL7tM9SRFpM/dw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-ppc64-gnu": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.130.0.tgz", + "integrity": "sha512-b+h/lsLLurp756dMGizNs5uPaJfyEdWrTcV5t8M609jWm1DEHB1StpRXCkyvwtkJx3m+qL5BNQ0dEKan/4yGFA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-gnu": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.130.0.tgz", + "integrity": "sha512-O19Cil83XAyjEFfo8WhkMwY58ALqZ7ckjGL+25mjMIuF84urWBeANH0FC8B8BsSSygWU3/1aY3ADdDbp+wlBnw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-musl": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.130.0.tgz", + "integrity": "sha512-BgXRVC0+83n3YzCscLQjj6nbyeBIVeZYPTI4fFMAE4WNm2+4RXhWp03IVizL7esIz36kgmT48aebk1iM+cs8sw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-s390x-gnu": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.130.0.tgz", + "integrity": "sha512-6tJz0xvnGhsokE7N1WlUSBXibpYmT9xSJFS1Ce41Km/+8gQvdlW8MLhRv8PD0L7ix8vRG0FDDepp3jdOFzdVdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-gnu": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.130.0.tgz", + "integrity": "sha512-9aCWj83dp3heTQGmGnZGdIWgxjZrr/7VQ0TGFHH5PKByxJKF2Hcr4qvaSUHhhGEa3MSsDjTL1YDP8RAgdL5/Cg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-musl": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.130.0.tgz", + "integrity": "sha512-afXt87aZBqrUVli8TB/I8H1G50RDWcwirjWtXGXYqJ2ZqWEiErH7V72j3LUSDZaivmtu2OLX0KQ/mbhP81mr7A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-openharmony-arm64": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.130.0.tgz", + "integrity": "sha512-I0NCrZV/YZuCGWgqwNN/GO/iXlLF2z+Wgc7u+Aa9N4P51oYeIa0XT+zVBUne4csO9GqxskXgI4g8JzzWGRpfOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-wasm32-wasi": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.130.0.tgz", + "integrity": "sha512-sJgQkGaBX0WJvPUDfwciex6IcTk5O5NLQ1bhEb6f3nBruh1GshKMRSMt2bxZlYrgBzjyBbJzsnO+InPG0bg+fA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-arm64-msvc": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.130.0.tgz", + "integrity": "sha512-bjcma99sQrNh6RY4mPO9yTkfxql6TDFoN3HWdK31RCKXwNhcDgJXW/l8PUtzKNiQ+9vpKJfJtQq+LklBuxSOBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-ia32-msvc": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.130.0.tgz", + "integrity": "sha512-hRYbv6HhpSTzT4xTiIkadLI7upLQxuOdLPR/9nL1fTjwhgutBTPXrwaAPb/jTFVx6/8C7Jb5HcUKhmNwloTbFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-x64-msvc": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.130.0.tgz", + "integrity": "sha512-RBpA9TsRucJq6HNVNCFF1iKg+QeTkLdZf7hi4xaOGCPvMZWvDHjQgSOEZMUpuW4JNciHbxNhLEYmz5CVygjVGQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@oxc-project/types": { "version": "0.127.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", @@ -921,6 +1288,313 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@oxc-resolver/binding-android-arm-eabi": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.19.1.tgz", + "integrity": "sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oxc-resolver/binding-android-arm64": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.19.1.tgz", + "integrity": "sha512-oolbkRX+m7Pq2LNjr/kKgYeC7bRDMVTWPgxBGMjSpZi/+UskVo4jsMU3MLheZV55jL6c3rNelPl4oD60ggYmqA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oxc-resolver/binding-darwin-arm64": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.19.1.tgz", + "integrity": "sha512-nUC6d2i3R5B12sUW4O646qD5cnMXf2oBGPLIIeaRfU9doJRORAbE2SGv4eW6rMqhD+G7nf2Y8TTJTLiiO3Q/dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxc-resolver/binding-darwin-x64": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.19.1.tgz", + "integrity": "sha512-cV50vE5+uAgNcFa3QY1JOeKDSkM/9ReIcc/9wn4TavhW/itkDGrXhw9jaKnkQnGbjJ198Yh5nbX/Gr2mr4Z5jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxc-resolver/binding-freebsd-x64": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.19.1.tgz", + "integrity": "sha512-xZOQiYGFxtk48PBKff+Zwoym7ScPAIVp4c14lfLxizO2LTTTJe5sx9vQNGrBymrf/vatSPNMD4FgsaaRigPkqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.19.1.tgz", + "integrity": "sha512-lXZYWAC6kaGe/ky2su94e9jN9t6M0/6c+GrSlCqL//XO1cxi5lpAhnJYdyrKfm0ZEr/c7RNyAx3P7FSBcBd5+A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm-musleabihf": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.19.1.tgz", + "integrity": "sha512-veG1kKsuK5+t2IsO9q0DErYVSw2azvCVvWHnfTOS73WE0STdLLB7Q1bB9WR+yHPQM76ASkFyRbogWo1GR1+WbQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm64-gnu": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.19.1.tgz", + "integrity": "sha512-heV2+jmXyYnUrpUXSPugqWDRpnsQcDm2AX4wzTuvgdlZfoNYO0O3W2AVpJYaDn9AG4JdM6Kxom8+foE7/BcSig==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm64-musl": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.19.1.tgz", + "integrity": "sha512-jvo2Pjs1c9KPxMuMPIeQsgu0mOJF9rEb3y3TdpsrqwxRM+AN6/nDDwv45n5ZrUnQMsdBy5gIabioMKnQfWo9ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-ppc64-gnu": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.19.1.tgz", + "integrity": "sha512-vLmdNxWCdN7Uo5suays6A/+ywBby2PWBBPXctWPg5V0+eVuzsJxgAn6MMB4mPlshskYbppjpN2Zg83ArHze9gQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-riscv64-gnu": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.19.1.tgz", + "integrity": "sha512-/b+WgR+VTSBxzgOhDO7TlMXC1ufPIMR6Vj1zN+/x+MnyXGW7prTLzU9eW85Aj7Th7CCEG9ArCbTeqxCzFWdg2w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-riscv64-musl": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.19.1.tgz", + "integrity": "sha512-YlRdeWb9j42p29ROh+h4eg/OQ3dTJlpHSa+84pUM9+p6i3djtPz1q55yLJhgW9XfDch7FN1pQ/Vd6YP+xfRIuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-s390x-gnu": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.19.1.tgz", + "integrity": "sha512-EDpafVOQWF8/MJynsjOGFThcqhRHy417sRyLfQmeiamJ8qVhSKAn2Dn2VVKUGCjVB9C46VGjhNo7nOPUi1x6uA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-x64-gnu": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.19.1.tgz", + "integrity": "sha512-NxjZe+rqWhr+RT8/Ik+5ptA3oz7tUw361Wa5RWQXKnfqwSSHdHyrw6IdcTfYuml9dM856AlKWZIUXDmA9kkiBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-x64-musl": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.19.1.tgz", + "integrity": "sha512-cM/hQwsO3ReJg5kR+SpI69DMfvNCp+A/eVR4b4YClE5bVZwz8rh2Nh05InhwI5HR/9cArbEkzMjcKgTHS6UaNw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-openharmony-arm64": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.19.1.tgz", + "integrity": "sha512-QF080IowFB0+9Rh6RcD19bdgh49BpQHUW5TajG1qvWHvmrQznTZZjYlgE2ltLXyKY+qs4F/v5xuX1XS7Is+3qA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.19.1.tgz", + "integrity": "sha512-w8UCKhX826cP/ZLokXDS6+milN8y4X7zidsAttEdWlVoamTNf6lhBJldaWr3ukTDiye7s4HRcuPEPOXNC432Vg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oxc-resolver/binding-win32-arm64-msvc": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.19.1.tgz", + "integrity": "sha512-nJ4AsUVZrVKwnU/QRdzPCCrO0TrabBqgJ8pJhXITdZGYOV28TIYystV1VFLbQ7DtAcaBHpocT5/ZJnF78YJPtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxc-resolver/binding-win32-ia32-msvc": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-11.19.1.tgz", + "integrity": "sha512-EW+ND5q2Tl+a3pH81l1QbfgbF3HmqgwLfDfVithRFheac8OTcnbXt/JxqD2GbDkb7xYEqy1zNaVFRr3oeG8npA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxc-resolver/binding-win32-x64-msvc": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.19.1.tgz", + "integrity": "sha512-6hIU3RQu45B+VNTY4Ru8ppFwjVS/S5qwYyGhBotmjxfEKk41I2DlGtRfGJndZ5+6lneE2pwloqunlOyZuX/XAw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@parcel/watcher": { "version": "2.5.6", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", @@ -2679,6 +3353,16 @@ "dev": true, "license": "MIT" }, + "node_modules/fd-package-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fd-package-json/-/fd-package-json-2.0.0.tgz", + "integrity": "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "walk-up-path": "^4.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2761,6 +3445,22 @@ "dev": true, "license": "ISC" }, + "node_modules/formatly": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/formatly/-/formatly-0.3.0.tgz", + "integrity": "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fd-package-json": "^2.0.0" + }, + "bin": { + "formatly": "bin/index.mjs" + }, + "engines": { + "node": ">=18.3.0" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2776,6 +3476,19 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/gl-matrix": { "version": "3.4.4", "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", @@ -2891,9 +3604,9 @@ "license": "ISC" }, "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "dev": true, "license": "MIT", "bin": { @@ -2951,6 +3664,46 @@ "json-buffer": "3.0.1" } }, + "node_modules/knip": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/knip/-/knip-6.14.1.tgz", + "integrity": "sha512-SN3Ly0ixzj5CQkY/rc4OPHpWrCC0XRIIjgdP76G9Cni5k72ur5jBYOyvJuF5oPTM14v8eHcMUgPbElHa+lnR0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/webpro" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/knip" + } + ], + "license": "ISC", + "dependencies": { + "fdir": "^6.5.0", + "formatly": "^0.3.0", + "get-tsconfig": "4.14.0", + "jiti": "^2.7.0", + "minimist": "^1.2.8", + "oxc-parser": "^0.130.0", + "oxc-resolver": "^11.19.1", + "picomatch": "^4.0.4", + "smol-toml": "^1.6.1", + "strip-json-comments": "5.0.3", + "tinyglobby": "^0.2.16", + "unbash": "^3.0.0", + "yaml": "^2.9.0", + "zod": "^4.1.11" + }, + "bin": { + "knip": "bin/knip.js", + "knip-bun": "bin/knip-bun.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -3307,6 +4060,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3399,6 +4162,86 @@ "node": ">= 0.8.0" } }, + "node_modules/oxc-parser": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.130.0.tgz", + "integrity": "sha512-X0PJ+NmOok8qP3vK9uaW431ngkdM9UPEK7KG466urtIL2+EYTEgbZK2yqe2MWKJKBjRlFweP/pJPx0x9muMEVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "^0.130.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-parser/binding-android-arm-eabi": "0.130.0", + "@oxc-parser/binding-android-arm64": "0.130.0", + "@oxc-parser/binding-darwin-arm64": "0.130.0", + "@oxc-parser/binding-darwin-x64": "0.130.0", + "@oxc-parser/binding-freebsd-x64": "0.130.0", + "@oxc-parser/binding-linux-arm-gnueabihf": "0.130.0", + "@oxc-parser/binding-linux-arm-musleabihf": "0.130.0", + "@oxc-parser/binding-linux-arm64-gnu": "0.130.0", + "@oxc-parser/binding-linux-arm64-musl": "0.130.0", + "@oxc-parser/binding-linux-ppc64-gnu": "0.130.0", + "@oxc-parser/binding-linux-riscv64-gnu": "0.130.0", + "@oxc-parser/binding-linux-riscv64-musl": "0.130.0", + "@oxc-parser/binding-linux-s390x-gnu": "0.130.0", + "@oxc-parser/binding-linux-x64-gnu": "0.130.0", + "@oxc-parser/binding-linux-x64-musl": "0.130.0", + "@oxc-parser/binding-openharmony-arm64": "0.130.0", + "@oxc-parser/binding-wasm32-wasi": "0.130.0", + "@oxc-parser/binding-win32-arm64-msvc": "0.130.0", + "@oxc-parser/binding-win32-ia32-msvc": "0.130.0", + "@oxc-parser/binding-win32-x64-msvc": "0.130.0" + } + }, + "node_modules/oxc-parser/node_modules/@oxc-project/types": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", + "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/oxc-resolver": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.19.1.tgz", + "integrity": "sha512-qE/CIg/spwrTBFt5aKmwe3ifeDdLfA2NESN30E42X/lII5ClF8V7Wt6WIJhcGZjp0/Q+nQ+9vgxGk//xZNX2hg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-resolver/binding-android-arm-eabi": "11.19.1", + "@oxc-resolver/binding-android-arm64": "11.19.1", + "@oxc-resolver/binding-darwin-arm64": "11.19.1", + "@oxc-resolver/binding-darwin-x64": "11.19.1", + "@oxc-resolver/binding-freebsd-x64": "11.19.1", + "@oxc-resolver/binding-linux-arm-gnueabihf": "11.19.1", + "@oxc-resolver/binding-linux-arm-musleabihf": "11.19.1", + "@oxc-resolver/binding-linux-arm64-gnu": "11.19.1", + "@oxc-resolver/binding-linux-arm64-musl": "11.19.1", + "@oxc-resolver/binding-linux-ppc64-gnu": "11.19.1", + "@oxc-resolver/binding-linux-riscv64-gnu": "11.19.1", + "@oxc-resolver/binding-linux-riscv64-musl": "11.19.1", + "@oxc-resolver/binding-linux-s390x-gnu": "11.19.1", + "@oxc-resolver/binding-linux-x64-gnu": "11.19.1", + "@oxc-resolver/binding-linux-x64-musl": "11.19.1", + "@oxc-resolver/binding-openharmony-arm64": "11.19.1", + "@oxc-resolver/binding-wasm32-wasi": "11.19.1", + "@oxc-resolver/binding-win32-arm64-msvc": "11.19.1", + "@oxc-resolver/binding-win32-ia32-msvc": "11.19.1", + "@oxc-resolver/binding-win32-x64-msvc": "11.19.1" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -3634,6 +4477,16 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/rolldown": { "version": "1.0.0-rc.17", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", @@ -3794,6 +4647,19 @@ "is-arrayish": "^0.3.1" } }, + "node_modules/smol-toml": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", + "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3818,6 +4684,19 @@ "dev": true, "license": "MIT" }, + "node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/synckit": { "version": "0.11.12", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", @@ -3979,6 +4858,16 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/unbash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unbash/-/unbash-3.0.0.tgz", + "integrity": "sha512-FeFPZ/WFT0mbRCuydiZzpPFlrYN8ZUpphQKoq4EeElVIYjYyGzPMxQR/simUwCOJIyVhpFk4RbtyO7RuMpMnHA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + } + }, "node_modules/unconfig": { "version": "7.5.0", "resolved": "https://registry.npmjs.org/unconfig/-/unconfig-7.5.0.tgz", @@ -4248,6 +5137,16 @@ } } }, + "node_modules/walk-up-path": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz", + "integrity": "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==", + "dev": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4291,6 +5190,22 @@ "node": ">=0.10.0" } }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -4303,6 +5218,16 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index f0b074d..b566e03 100644 --- a/package.json +++ b/package.json @@ -8,18 +8,17 @@ "dev": "vite --host 0.0.0.0", "build": "vite build", "preview": "vite preview", - "lint": "npm run lint:check", "lint:check": "eslint --rule \"prettier/prettier: off\" \"src/**/*.ts\" && npm run unused:check", "lint:fix": "eslint --fix \"src/**/*.ts\"", - "format": "prettier --write \"index.html\" \"src/**/*.{ts,scss,json,html}\" \"scripts/**/*.mjs\" \"e2e/**/*.ts\" \".forgejo/workflows/*.yml\" \"*.{json,js,ts,md}\" \".prettierrc\"", - "format:check": "prettier --check \"index.html\" \"src/**/*.{ts,scss,json,html}\" \"scripts/**/*.mjs\" \"e2e/**/*.ts\" \".forgejo/workflows/*.yml\" \"*.{json,js,ts,md}\" \".prettierrc\"", + "format": "prettier --write \"index.html\" \"src/**/*.{ts,scss,json,html}\" \"e2e/**/*.ts\" \".forgejo/workflows/*.yml\" \"*.{json,js,ts,md}\" \".prettierrc\"", + "format:check": "prettier --check \"index.html\" \"src/**/*.{ts,scss,json,html}\" \"e2e/**/*.ts\" \".forgejo/workflows/*.yml\" \"*.{json,js,ts,md}\" \".prettierrc\"", "typecheck": "tsc --noEmit", "typecheck:e2e": "tsc --noEmit --project tsconfig.playwright.json", "test": "vitest run", "test:e2e": "npm run build && playwright test", "test:e2e:ui": "npm run build && playwright test --ui", "test:watch": "vitest", - "unused:check": "node scripts/check-unused-exports.mjs", + "unused:check": "knip --exports --include-entry-exports", "generate-icons": "pwa-assets-generator", "update": "ncu" }, @@ -57,6 +56,7 @@ "eslint-plugin-unused-imports": "^4.4.1", "gl-matrix": "^3.4.4", "globals": "^17.6.0", + "knip": "^6.14.1", "lightningcss": "^1.32.0", "npm-check-updates": "^22.1.0", "prettier": "^3.8.3", diff --git a/public/404.html b/public/404.html deleted file mode 100644 index 7c62e5c..0000000 --- a/public/404.html +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - Not found - - - - - - - - diff --git a/scripts/check-unused-exports.mjs b/scripts/check-unused-exports.mjs deleted file mode 100644 index 9eeeddd..0000000 --- a/scripts/check-unused-exports.mjs +++ /dev/null @@ -1,197 +0,0 @@ -import { existsSync, readdirSync, readFileSync } from 'node:fs'; -import path from 'node:path'; -import ts from 'typescript'; - -const projectRoot = process.cwd(); -const sourceRoot = path.join(projectRoot, 'src'); - -const toPosix = (value) => value.split(path.sep).join('/'); - -const listTypeScriptFiles = (directory) => - readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { - const entryPath = path.join(directory, entry.name); - if (entry.isDirectory()) { - return listTypeScriptFiles(entryPath); - } - return entry.isFile() && entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts') - ? [entryPath] - : []; - }); - -const files = listTypeScriptFiles(sourceRoot); -const fileSet = new Set(files.map((file) => path.resolve(file))); - -const resolveModule = (fromFile, specifier) => { - if (!specifier.startsWith('.')) { - return null; - } - - const base = path.resolve(path.dirname(fromFile), specifier); - const candidates = [ - `${base}.ts`, - path.join(base, 'index.ts'), - base.endsWith('.ts') ? base : null, - ].filter(Boolean); - - return ( - candidates.find((candidate) => existsSync(candidate) && fileSet.has(candidate)) ?? - null - ); -}; - -const exportKey = (file, name) => `${path.resolve(file)}:${name}`; -const isExported = (node) => - ts.canHaveModifiers(node) && - (ts.getModifiers(node) ?? []).some( - (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword - ); -const isDefaultExported = (node) => - ts.canHaveModifiers(node) && - (ts.getModifiers(node) ?? []).some( - (modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword - ); - -const exportedDeclarations = new Map(); -const usedExports = new Set(); -const wildcardUsedFiles = new Set(); - -const markUsed = (fromFile, name) => { - usedExports.add(exportKey(fromFile, name)); -}; - -const collectImportUsage = (file, sourceFile) => { - sourceFile.forEachChild((node) => { - if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) { - const resolved = resolveModule(file, node.moduleSpecifier.text); - if (!resolved || !node.importClause) { - return; - } - - if (node.importClause.name) { - markUsed(resolved, 'default'); - } - - const namedBindings = node.importClause.namedBindings; - if (namedBindings && ts.isNamedImports(namedBindings)) { - namedBindings.elements.forEach((element) => { - markUsed(resolved, (element.propertyName ?? element.name).text); - }); - } else if (namedBindings && ts.isNamespaceImport(namedBindings)) { - wildcardUsedFiles.add(path.resolve(resolved)); - } - return; - } - - if ( - ts.isExportDeclaration(node) && - node.moduleSpecifier && - ts.isStringLiteral(node.moduleSpecifier) - ) { - const resolved = resolveModule(file, node.moduleSpecifier.text); - if (!resolved) { - return; - } - - if (!node.exportClause) { - wildcardUsedFiles.add(path.resolve(resolved)); - return; - } - - if (ts.isNamedExports(node.exportClause)) { - node.exportClause.elements.forEach((element) => { - markUsed(resolved, (element.propertyName ?? element.name).text); - }); - } - } - }); -}; - -const collectExportedDeclarations = (file, sourceFile) => { - if (file.endsWith('.test.ts')) { - return; - } - - sourceFile.forEachChild((node) => { - if (ts.isVariableStatement(node) && isExported(node)) { - node.declarationList.declarations.forEach((declaration) => { - if (ts.isIdentifier(declaration.name)) { - exportedDeclarations.set(exportKey(file, declaration.name.text), { - file, - name: declaration.name.text, - }); - } - }); - return; - } - - if ( - (ts.isFunctionDeclaration(node) || - ts.isClassDeclaration(node) || - ts.isInterfaceDeclaration(node) || - ts.isTypeAliasDeclaration(node) || - ts.isEnumDeclaration(node)) && - isExported(node) - ) { - if (isDefaultExported(node)) { - exportedDeclarations.set(exportKey(file, 'default'), { file, name: 'default' }); - return; - } - - if (node.name) { - exportedDeclarations.set(exportKey(file, node.name.text), { - file, - name: node.name.text, - }); - } - return; - } - - if ( - ts.isExportDeclaration(node) && - !node.moduleSpecifier && - node.exportClause && - ts.isNamedExports(node.exportClause) - ) { - node.exportClause.elements.forEach((element) => { - exportedDeclarations.set(exportKey(file, element.name.text), { - file, - name: element.name.text, - }); - }); - } - }); -}; - -const parsedFiles = files.map((file) => ({ - file, - sourceFile: ts.createSourceFile( - file, - readFileSync(file, 'utf8'), - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TS - ), -})); - -parsedFiles.forEach(({ file, sourceFile }) => collectImportUsage(file, sourceFile)); -parsedFiles.forEach(({ file, sourceFile }) => - collectExportedDeclarations(file, sourceFile) -); - -const unusedExports = Array.from(exportedDeclarations.entries()) - .filter( - ([key, declaration]) => - !usedExports.has(key) && !wildcardUsedFiles.has(declaration.file) - ) - .map(([, declaration]) => declaration) - .sort((left, right) => - `${left.file}:${left.name}`.localeCompare(`${right.file}:${right.name}`) - ); - -if (unusedExports.length > 0) { - console.error('Unused exported declarations found:'); - unusedExports.forEach(({ file, name }) => { - console.error(`- ${toPosix(path.relative(projectRoot, file))}: ${name}`); - }); - process.exitCode = 1; -} From 70423851bad517af3f90bdc255b307ea4f6d1a66 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 16 May 2026 15:05:23 +0100 Subject: [PATCH 04/20] Clena up CSS --- src/style/_app-shell.scss | 17 +- src/style/_garden-prompt.scss | 2 +- src/style/_loading.scss | 4 +- src/style/_panels.scss | 74 +---- src/style/_range-input.scss | 49 ---- src/style/_toolbar.scss | 539 +++++++++++----------------------- src/style/common.scss | 16 +- src/style/fonts.scss | 13 - src/style/mixins.scss | 132 --------- src/style/vars.scss | 24 -- 10 files changed, 188 insertions(+), 682 deletions(-) delete mode 100644 src/style/_range-input.scss diff --git a/src/style/_app-shell.scss b/src/style/_app-shell.scss index c7e43ae..1a6fa85 100644 --- a/src/style/_app-shell.scss +++ b/src/style/_app-shell.scss @@ -1,4 +1,4 @@ -html > body { +html>body { width: 100%; min-height: 100vh; min-height: 100dvh; @@ -9,7 +9,7 @@ html > body { position: relative; background: var(--garden-background, #10151f); - > .canvas-container { + >.canvas-container { min-height: 100vh; min-height: 100dvh; height: 100%; @@ -18,16 +18,13 @@ html > body { position: relative; overflow: hidden; - > canvas { + >canvas { height: 100%; width: 100%; touch-action: none; - cursor: - url('../../assets/icons/brush.svg') 0 24, - auto; } - > .eraser-preview { + >.eraser-preview { position: absolute; top: 0; left: 0; @@ -54,7 +51,7 @@ html > body { } } - > .dev-stats-overlay { + >.dev-stats-overlay { position: absolute; top: max(10px, env(safe-area-inset-top, 0px)); left: max(10px, env(safe-area-inset-left, 0px)); @@ -79,7 +76,7 @@ html > body { } } - > .errors-container { + >.errors-container { position: absolute; top: 0; left: 0; @@ -92,4 +89,4 @@ html > body { } } } -} +} \ No newline at end of file diff --git a/src/style/_garden-prompt.scss b/src/style/_garden-prompt.scss index ed56320..1ba7945 100644 --- a/src/style/_garden-prompt.scss +++ b/src/style/_garden-prompt.scss @@ -37,7 +37,7 @@ html > body > .canvas-container > .garden-prompt { backdrop-filter: blur(12px); color: rgb(255 255 255 / 94%); font: - 600 20px/1.2 'Open Sans', + 400 20px/1.2 'Open Sans', sans-serif; text-shadow: 0 1px 12px rgb(0 0 0 / 58%); } diff --git a/src/style/_loading.scss b/src/style/_loading.scss index a8ca9b8..72eac98 100644 --- a/src/style/_loading.scss +++ b/src/style/_loading.scss @@ -47,7 +47,7 @@ > .loading-status { color: rgb(255 255 255 / 88%); font: - 600 16px/1.25 'Open Sans', + 400 16px/1.25 'Open Sans', sans-serif; text-align: center; text-shadow: 0 1px 12px rgb(0 0 0 / 60%); @@ -90,7 +90,6 @@ html > body.is-loading { aside.control-dock { opacity: 0; visibility: hidden; - transform: translateY(var(--dock-hidden-translate-y)); } } @@ -109,7 +108,6 @@ html > body.is-loading { @media (prefers-reduced-motion: reduce) { .loading-indicator > .loading-dots > .loading-dot { - animation: none; transform: scale(0.85); opacity: 0.85; } diff --git a/src/style/_panels.scss b/src/style/_panels.scss index 5b3fe9a..3359387 100644 --- a/src/style/_panels.scss +++ b/src/style/_panels.scss @@ -1,8 +1,6 @@ @use 'mixins' as *; -@use 'range-input' as *; html > body > aside.control-dock > .pages { - @include blurred-background(#fff); width: min(calc(100vw - 1rem), 560px); max-height: min(58vh, 520px); max-height: min(58dvh, 520px); @@ -11,9 +9,11 @@ html > body > aside.control-dock > .pages { overflow-y: auto; border: 1px solid rgb(255 255 255 / 54%); border-radius: 8px; + background-color: rgb(255 255 255 / 34%); box-shadow: 0 18px 48px rgb(0 0 0 / 28%), 0 2px 10px rgb(0 0 0 / 16%); + backdrop-filter: blur(12px); scrollbar-width: thin; scrollbar-color: var(--main-color) transparent; transition: @@ -64,7 +64,7 @@ html > body > aside.control-dock > .pages { a { color: rgb(0 84 120); - font-weight: 700; + font-weight: 400; &:focus-visible { outline: 2px solid currentColor; @@ -96,75 +96,17 @@ html > body > aside.control-dock > .pages { } p { - @include main-font(); margin-bottom: var(--small-margin); - line-height: 1.65; + color: var(--normal-text-color); + font: + 400 1.1rem/1.65 'Open Sans', + sans-serif; + hyphens: auto; } a { color: var(--accent-color); } - - .slider { - $track-height: 8px; - $thumb-size: 22px; - margin-bottom: var(--small-margin); - user-select: none; - - p { - display: flex; - justify-content: space-between; - gap: var(--small-margin); - margin-bottom: 0.35rem; - font-size: 0.95rem; - } - - input[type='range'] { - @include settings-range-input(); - - &::-webkit-slider-runnable-track { - @include range-track(rgb(49 52 63 / 14%), $track-height, 1000px, null); - } - - &::-webkit-slider-thumb { - @include range-thumb-base( - $thumb-size, - $thumb-size, - 2px solid var(--accent-color), - 1000px - ); - margin-top: -7px; - appearance: none; - background: white; - box-shadow: 0 3px 10px rgb(0 0 0 / 20%); - transition: transform var(--transition-time); - - &:hover { - transform: scale(1.08); - } - } - - &::-moz-range-track { - @include range-track(rgb(49 52 63 / 14%), $track-height, 1000px, null); - } - - &::-moz-range-thumb { - @include range-thumb-base( - $thumb-size, - $thumb-size, - 2px solid var(--accent-color), - 1000px - ); - background: white; - box-shadow: 0 3px 10px rgb(0 0 0 / 20%); - } - } - } - - .large-button { - margin: var(--small-margin) 0 0 auto; - border-radius: 8px; - } } @include on-small-screen { diff --git a/src/style/_range-input.scss b/src/style/_range-input.scss deleted file mode 100644 index a55899e..0000000 --- a/src/style/_range-input.scss +++ /dev/null @@ -1,49 +0,0 @@ -@mixin toolbar-range-input() { - position: relative; - z-index: 1; - width: 100%; - height: 100%; - appearance: none; - background: transparent; - cursor: ew-resize; - outline: none; - touch-action: pan-y; - - &:focus-visible { - outline: 2px solid white; - outline-offset: 2px; - border-radius: 8px; - } -} - -@mixin settings-range-input() { - width: 100%; - height: 44px; - appearance: none; - background: transparent; - outline: none; -} - -@mixin range-track( - $background, - $height: 7px, - $border-radius: 999px, - $box-shadow: inset 0 1px 2px rgb(0 0 0 / 24%) -) { - height: $height; - cursor: pointer; - border-radius: $border-radius; - background: $background; - - @if $box-shadow != null { - box-shadow: $box-shadow; - } -} - -@mixin range-thumb-base($width, $height, $border, $border-radius) { - width: $width; - height: $height; - cursor: pointer; - border: $border; - border-radius: $border-radius; -} diff --git a/src/style/_toolbar.scss b/src/style/_toolbar.scss index 74a8592..1d4f7b8 100644 --- a/src/style/_toolbar.scss +++ b/src/style/_toolbar.scss @@ -1,5 +1,39 @@ @use 'mixins' as *; -@use 'range-input' as *; + +@mixin toolbar-track() { + height: 7px; + border-radius: 999px; + background: linear-gradient( + 90deg, + rgb(var(--control-rgb) / 72%) 0 var(--control-progress), + rgb(255 255 255 / 24%) var(--control-progress) 100% + ); + box-shadow: inset 0 1px 2px rgb(0 0 0 / 24%); + cursor: ew-resize; +} + +@mixin toolbar-thumb() { + width: var(--thumb-width); + height: var(--thumb-height); + border: 2px solid rgb(255 255 255 / 92%); + border-radius: var(--thumb-radius); + background: var(--thumb-background); + box-shadow: + inset 0 1px 2px rgb(255 255 255 / 22%), + 0 4px 12px rgb(0 0 0 / 30%); + cursor: ew-resize; + transform: var(--thumb-transform); +} + +$toolbar-icons: ( + info: 'info', + maximize-full-screen: 'maximize', + minimize-full-screen: 'minimize', + settings: 'settings', + sound: 'sound', + export-4k: 'download', + restart: 'restart', +); html > body > aside.control-dock > .toolbar-row { display: flex; @@ -8,11 +42,17 @@ html > body > aside.control-dock > .toolbar-row { width: fit-content; max-width: 100%; margin: 0 auto; + padding-inline: clamp(8px, 1.4vw, 14px); gap: clamp(6px, 1.8vw, 14px); + border-radius: 12px; color: rgb(245 250 244 / 92%); font: - 600 13px/1 'Open Sans', + 400 13px/1 'Open Sans', sans-serif; + transition: + backdrop-filter var(--transition-time-long), + background-color var(--transition-time-long), + box-shadow var(--transition-time-long); button { min-width: 44px; @@ -25,8 +65,14 @@ html > body > aside.control-dock > .toolbar-row { border-color var(--transition-time), color var(--transition-time), box-shadow var(--transition-time), + opacity var(--transition-time), transform var(--transition-time); + &:disabled { + cursor: progress; + opacity: 0.58; + } + &:focus-visible { outline: 2px solid white; outline-offset: 2px; @@ -35,40 +81,32 @@ html > body > aside.control-dock > .toolbar-row { > .toolbar-shell { display: grid; - grid-template-columns: minmax(0, 1fr); grid-template-areas: 'swatches' 'nav'; + grid-template-columns: minmax(0, 1fr); align-items: center; justify-content: center; gap: 8px; min-width: 0; min-height: 86px; padding: 8px 9px; - border: 1px solid transparent; - border-radius: 10px; - background: transparent; - backdrop-filter: none; - box-shadow: none; } > .vibe-button { + position: relative; display: grid; place-items: center; - position: relative; width: 52px; height: auto; min-height: 66px; flex: 0 0 auto; padding: 0; - border: 0; border-radius: 0; background: transparent; color: rgb(255 255 255 / 70%); font-size: 0; line-height: 1; - text-shadow: none; - box-shadow: none; &::before { content: ''; @@ -92,12 +130,28 @@ html > body > aside.control-dock > .toolbar-row { background: transparent; color: color-mix(in srgb, var(--accent-color) 70%, white); box-shadow: none; - transform: translateY(-2px); } &:active { - transform: translateY(0); + transform: translateX(0); } + + &.previous-vibe:hover { + transform: translateX(-2px); + } + + &.next-vibe:hover { + transform: translateX(2px); + } + } + + &.needs-contrast-background { + background: linear-gradient(180deg, rgb(22 28 36 / 72%), rgb(5 8 13 / 82%)); + box-shadow: + inset 0 0 0 1px rgb(255 255 255 / 16%), + inset 0 1px 0 rgb(255 255 255 / 7%), + 0 14px 34px rgb(0 0 0 / 28%); + backdrop-filter: blur(18px) brightness(0.62) saturate(0.82); } > .toolbar-shell > nav.buttons { @@ -149,31 +203,16 @@ html > body > aside.control-dock > .toolbar-row { &.active { border-color: color-mix(in srgb, var(--accent-color) 55%, white 15%); background: color-mix(in srgb, var(--accent-color) 30%, transparent); - box-shadow: none; } &.active::after { background-color: white; } - &.info::after { - mask-image: url('../../assets/icons/info.svg'); - } - - &.maximize-full-screen::after { - mask-image: url('../../assets/icons/maximize.svg'); - } - - &.minimize-full-screen::after { - mask-image: url('../../assets/icons/minimize.svg'); - } - - &.settings::after { - mask-image: url('../../assets/icons/settings.svg'); - } - - &.sound::after { - mask-image: url('../../assets/icons/sound.svg'); + @each $class, $icon in $toolbar-icons { + &.#{$class}::after { + mask-image: url('../../assets/icons/#{$icon}.svg'); + } } &.sound.muted::before { @@ -193,14 +232,6 @@ html > body > aside.control-dock > .toolbar-row { &.sound.muted::after { background-color: rgb(255 255 255 / 46%); } - - &.export-4k::after { - mask-image: url('../../assets/icons/download.svg'); - } - - &.restart::after { - mask-image: url('../../assets/icons/restart.svg'); - } } > .export-status { @@ -238,7 +269,7 @@ html > body > aside.control-dock > .toolbar-row { min-height: 58px; padding: 6px 10px; - > button { + > .color-swatch { position: relative; width: 44px; height: 44px; @@ -262,168 +293,16 @@ html > body > aside.control-dock > .toolbar-row { } } - > .eraser-size-control { - --eraser-control-scale: 1; - --eraser-progress: 33%; - - position: relative; - display: grid; - align-items: center; - width: 184px; - height: 46px; - flex: 0 0 184px; - padding: 0 12px; - overflow: hidden; - border: 1px solid rgb(255 255 255 / 14%); - border-radius: 8px; - background: - radial-gradient( - circle at 24% 78%, - rgb(255 226 215 / 42%) 0 1px, - transparent 1.5px - ), - radial-gradient( - circle at 47% 72%, - rgb(255 226 215 / 34%) 0 1px, - transparent 1.5px - ), - radial-gradient( - circle at 67% 81%, - rgb(255 226 215 / 38%) 0 1px, - transparent 1.5px - ), - linear-gradient(180deg, rgb(255 255 255 / 9%), rgb(255 255 255 / 4%)); - box-shadow: - inset 0 0 0 1px rgb(255 255 255 / 6%), - 0 3px 10px rgb(0 0 0 / 18%); - cursor: ew-resize; - transition: - border-color var(--transition-time), - background-color var(--transition-time), - box-shadow var(--transition-time), - transform var(--transition-time); - - &::before { - content: ''; - position: absolute; - right: 12px; - bottom: 8px; - left: 12px; - height: 2px; - border-radius: 999px; - background: linear-gradient( - 90deg, - rgb(255 140 117 / 56%) 0 var(--eraser-progress), - rgb(255 255 255 / 18%) var(--eraser-progress) 100% - ); - box-shadow: 0 1px 4px rgb(0 0 0 / 22%); - } - - &:hover { - transform: translateY(-2px); - border-color: rgb(255 255 255 / 24%); - } - - &.active { - border-color: rgb(255 212 202 / 72%); - background-color: rgb(255 140 117 / 11%); - box-shadow: - inset 0 0 0 1px rgb(255 255 255 / 10%), - 0 0 0 5px rgb(255 140 117 / 34%), - 0 6px 15px rgb(0 0 0 / 22%); - } - - input[type='range'] { - @include toolbar-range-input(); - - &::-webkit-slider-runnable-track { - @include range-track( - linear-gradient( - 90deg, - rgb(255 140 117 / 72%) 0 var(--eraser-progress), - rgb(255 255 255 / 24%) var(--eraser-progress) 100% - ) - ); - } - - &::-webkit-slider-thumb { - @include range-thumb-base( - calc(34px * var(--eraser-control-scale)), - calc(21px * var(--eraser-control-scale)), - 2px solid rgb(255 239 233 / 94%), - calc(6px * var(--eraser-control-scale)) - ); - margin-top: calc((7px - (21px * var(--eraser-control-scale))) / 2); - appearance: none; - background: - linear-gradient( - 110deg, - transparent 0 12%, - rgb(255 255 255 / 44%) 13% 20%, - transparent 21% 100% - ), - linear-gradient( - 90deg, - #ff8fa3 0 52%, - rgb(54 46 51 / 78%) 53% 56%, - #f5eee5 57% 100% - ); - box-shadow: - inset 0 -2px 3px rgb(117 46 58 / 22%), - inset 0 2px 3px rgb(255 255 255 / 28%), - 0 4px 10px rgb(0 0 0 / 28%); - transform: rotate(-10deg); - transition: - height var(--transition-time), - margin-top var(--transition-time), - width var(--transition-time); - } - - &::-moz-range-track { - @include range-track( - linear-gradient( - 90deg, - rgb(255 140 117 / 72%) 0 var(--eraser-progress), - rgb(255 255 255 / 24%) var(--eraser-progress) 100% - ) - ); - } - - &::-moz-range-thumb { - @include range-thumb-base( - calc(34px * var(--eraser-control-scale)), - calc(21px * var(--eraser-control-scale)), - 2px solid rgb(255 239 233 / 94%), - calc(6px * var(--eraser-control-scale)) - ); - background: - linear-gradient( - 110deg, - transparent 0 12%, - rgb(255 255 255 / 44%) 13% 20%, - transparent 21% 100% - ), - linear-gradient( - 90deg, - #ff8fa3 0 52%, - rgb(54 46 51 / 78%) 53% 56%, - #f5eee5 57% 100% - ); - box-shadow: - inset 0 -2px 3px rgb(117 46 58 / 22%), - inset 0 2px 3px rgb(255 255 255 / 28%), - 0 4px 10px rgb(0 0 0 / 28%); - transform: rotate(-10deg); - transition: - height var(--transition-time), - width var(--transition-time); - } - } - } - + > .eraser-size-control, > .mirror-segment-control { - --mirror-progress: 0%; - --mirror-angle: 360deg; + --control-progress: 0%; + --control-rgb: 255 255 255; + --thumb-background: rgb(var(--control-rgb)); + --thumb-height: 28px; + --thumb-hover-transform: scale(1.03); + --thumb-radius: 50%; + --thumb-transform: none; + --thumb-width: 28px; position: relative; display: grid; @@ -435,23 +314,7 @@ html > body > aside.control-dock > .toolbar-row { overflow: hidden; border: 1px solid rgb(255 255 255 / 14%); border-radius: 8px; - background: - radial-gradient( - circle at 24% 78%, - rgb(197 255 234 / 38%) 0 1px, - transparent 1.5px - ), - radial-gradient( - circle at 47% 72%, - rgb(197 255 234 / 30%) 0 1px, - transparent 1.5px - ), - radial-gradient( - circle at 67% 81%, - rgb(197 255 234 / 34%) 0 1px, - transparent 1.5px - ), - linear-gradient(180deg, rgb(255 255 255 / 9%), rgb(255 255 255 / 4%)); + background: linear-gradient(180deg, rgb(255 255 255 / 9%), rgb(255 255 255 / 4%)); box-shadow: inset 0 0 0 1px rgb(255 255 255 / 6%), 0 3px 10px rgb(0 0 0 / 18%); @@ -462,108 +325,113 @@ html > body > aside.control-dock > .toolbar-row { box-shadow var(--transition-time), transform var(--transition-time); - &::before { - content: ''; - position: absolute; - right: 12px; - bottom: 8px; - left: 12px; - height: 2px; - border-radius: 999px; - background: linear-gradient( - 90deg, - rgb(148 233 203 / 56%) 0 var(--mirror-progress), - rgb(255 255 255 / 18%) var(--mirror-progress) 100% - ); - box-shadow: 0 1px 4px rgb(0 0 0 / 22%); - } - &:hover { - transform: translateY(-2px); border-color: rgb(255 255 255 / 24%); + transform: translateY(-2px); } &.active { - border-color: rgb(167 245 219 / 74%); - background-color: rgb(92 206 177 / 12%); + border-color: rgb(var(--control-rgb) / 72%); + background-color: rgb(var(--control-rgb) / 11%); box-shadow: inset 0 0 0 1px rgb(255 255 255 / 10%), - 0 0 0 5px rgb(92 206 177 / 28%), + 0 0 0 5px rgb(var(--control-rgb) / 28%), 0 6px 15px rgb(0 0 0 / 22%); } input[type='range'] { - @include toolbar-range-input(); + position: relative; + z-index: 1; + width: 100%; + height: 100%; + appearance: none; + background: transparent; + cursor: ew-resize; + outline: none; + touch-action: pan-y; + + &:focus-visible { + border-radius: 8px; + outline: 2px solid white; + outline-offset: 2px; + } &::-webkit-slider-runnable-track { - @include range-track( - linear-gradient( - 90deg, - rgb(148 233 203 / 78%) 0 var(--mirror-progress), - rgb(255 255 255 / 24%) var(--mirror-progress) 100% - ) - ); + @include toolbar-track(); } &::-webkit-slider-thumb { - @include range-thumb-base(44px, 44px, 2px solid rgb(240 255 251 / 94%), 50%); - margin-top: -18.5px; + @include toolbar-thumb(); + margin-top: calc((7px - var(--thumb-height)) / 2); appearance: none; - background: - radial-gradient(circle, white 0 3px, rgb(9 20 18 / 78%) 3.5px 8px), - repeating-conic-gradient( - from -90deg, - rgb(218 255 241) 0 8deg, - rgb(8 22 19 / 94%) 8deg var(--mirror-angle) - ); - box-shadow: - inset 0 0 0 7px rgb(0 0 0 / 18%), - 0 0 0 5px rgb(92 206 177 / 16%), - 0 5px 14px rgb(0 0 0 / 30%); transition: box-shadow var(--transition-time), - transform var(--transition-time); + height var(--transition-time), + margin-top var(--transition-time), + transform var(--transition-time), + width var(--transition-time); } &::-webkit-slider-thumb:hover { box-shadow: - inset 0 0 0 7px rgb(0 0 0 / 18%), - 0 0 0 7px rgb(92 206 177 / 24%), - 0 6px 16px rgb(0 0 0 / 34%); - transform: scale(1.04); + inset 0 1px 2px rgb(255 255 255 / 22%), + 0 0 0 4px rgb(var(--control-rgb) / 22%), + 0 5px 14px rgb(0 0 0 / 34%); + transform: var(--thumb-hover-transform); } &::-moz-range-track { - @include range-track( - linear-gradient( - 90deg, - rgb(148 233 203 / 78%) 0 var(--mirror-progress), - rgb(255 255 255 / 24%) var(--mirror-progress) 100% - ) - ); + @include toolbar-track(); } &::-moz-range-thumb { - @include range-thumb-base(44px, 44px, 2px solid rgb(240 255 251 / 94%), 50%); - background: - radial-gradient(circle, white 0 3px, rgb(9 20 18 / 78%) 3.5px 8px), - repeating-conic-gradient( - from -90deg, - rgb(218 255 241) 0 8deg, - rgb(8 22 19 / 94%) 8deg var(--mirror-angle) - ); - box-shadow: - inset 0 0 0 7px rgb(0 0 0 / 18%), - 0 0 0 5px rgb(92 206 177 / 16%), - 0 5px 14px rgb(0 0 0 / 30%); + @include toolbar-thumb(); } } } + + > .eraser-size-control { + --control-progress: var(--eraser-progress, 33%); + --control-rgb: 255 140 117; + --thumb-background: + linear-gradient( + 110deg, + transparent 0 12%, + rgb(255 255 255 / 44%) 13% 20%, + transparent 21% 100% + ), + linear-gradient( + 90deg, + #ff8fa3 0 52%, + rgb(54 46 51 / 78%) 53% 56%, + #f5eee5 57% 100% + ); + --thumb-height: calc(21px * var(--eraser-control-scale, 1)); + --thumb-hover-transform: rotate(-10deg) scale(1.03); + --thumb-radius: calc(6px * var(--eraser-control-scale, 1)); + --thumb-transform: rotate(-10deg); + --thumb-width: calc(34px * var(--eraser-control-scale, 1)); + } + + > .mirror-segment-control { + --control-progress: var(--mirror-progress, 0%); + --control-rgb: 148 233 203; + --thumb-background: + radial-gradient(circle, white 0 3px, rgb(9 20 18 / 78%) 3.5px 8px), + repeating-conic-gradient( + from -90deg, + rgb(218 255 241) 0 8deg, + rgb(8 22 19 / 94%) 8deg var(--mirror-angle, 360deg) + ); + --thumb-height: 44px; + --thumb-width: 44px; + } } } @include on-small-screen { width: 100%; + padding-inline: 6px; gap: 6px; > .vibe-button { @@ -577,17 +445,14 @@ html > body > aside.control-dock > .toolbar-row { } > .toolbar-shell { - flex: 1 1 auto; min-width: 0; + flex: 1 1 auto; gap: 8px; padding: 4px 8px; > nav.buttons { - grid-area: nav; - justify-content: center; gap: 2px; padding-top: 3px; - border-top: 1px solid rgb(255 255 255 / 12%); > button { width: 44px; @@ -608,110 +473,46 @@ html > body > aside.control-dock > .toolbar-row { } > .garden-controls { - grid-area: swatches; - display: flex; - justify-content: center; gap: 8px; padding: 2px 4px; > .swatches { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); - flex: 1 1 100%; align-items: center; justify-items: center; justify-content: stretch; - column-gap: 7px; - row-gap: 8px; width: 100%; min-width: 0; min-height: 54px; + flex: 1 1 100%; padding: 4px 6px; + column-gap: 7px; + row-gap: 8px; > .color-swatch { grid-column: span 2; - width: 44px; - height: 44px; + } + + > .eraser-size-control, + > .mirror-segment-control { + justify-self: stretch; + width: 100%; + min-width: 0; + height: 42px; + flex-basis: auto; + padding: 0 8px; } > .eraser-size-control { grid-column: 1 / span 3; - justify-self: stretch; - width: 100%; - min-width: 0; - height: 42px; - flex-basis: auto; - padding: 0 8px; - - &::before { - right: 8px; - left: 8px; - } } > .mirror-segment-control { + --thumb-height: 38px; + --thumb-width: 38px; + grid-column: 4 / span 3; - justify-self: stretch; - width: 100%; - min-width: 0; - height: 42px; - flex-basis: auto; - padding: 0 8px; - - &::before { - right: 8px; - left: 8px; - } - - input[type='range'] { - &::-webkit-slider-thumb { - @include range-thumb-base( - 38px, - 38px, - 2px solid rgb(240 255 251 / 94%), - 50% - ); - margin-top: -15.5px; - background: - radial-gradient(circle, white 0 2.5px, rgb(9 20 18 / 78%) 3px 7px), - repeating-conic-gradient( - from -90deg, - rgb(218 255 241) 0 8deg, - rgb(8 22 19 / 94%) 8deg var(--mirror-angle) - ); - box-shadow: - inset 0 0 0 6px rgb(0 0 0 / 18%), - 0 0 0 3px rgb(92 206 177 / 16%), - 0 4px 10px rgb(0 0 0 / 28%); - } - - &::-webkit-slider-thumb:hover { - box-shadow: - inset 0 0 0 6px rgb(0 0 0 / 18%), - 0 0 0 4px rgb(92 206 177 / 24%), - 0 5px 12px rgb(0 0 0 / 32%); - } - - &::-moz-range-thumb { - @include range-thumb-base( - 38px, - 38px, - 2px solid rgb(240 255 251 / 94%), - 50% - ); - background: - radial-gradient(circle, white 0 2.5px, rgb(9 20 18 / 78%) 3px 7px), - repeating-conic-gradient( - from -90deg, - rgb(218 255 241) 0 8deg, - rgb(8 22 19 / 94%) 8deg var(--mirror-angle) - ); - box-shadow: - inset 0 0 0 6px rgb(0 0 0 / 18%), - 0 0 0 3px rgb(92 206 177 / 16%), - 0 4px 10px rgb(0 0 0 / 28%); - } - } } } } diff --git a/src/style/common.scss b/src/style/common.scss index f33c2e1..f048023 100644 --- a/src/style/common.scss +++ b/src/style/common.scss @@ -1,6 +1,5 @@ @use 'vars'; @use 'fonts'; -@use 'mixins' as *; *, *::before, @@ -21,7 +20,7 @@ h3, h4, h5, h6 { - font-family: 'Comfortaa', sans-serif; + font-family: 'Open Sans', sans-serif; margin-bottom: var(--small-margin); } @@ -48,16 +47,3 @@ html { white-space: nowrap !important; border: 0 !important; } - -.large-button { - min-height: 44px; - border: none; - background-color: var(--accent-color); - cursor: pointer; - border-radius: var(--border-radius); - padding: calc(var(--small-margin) / 2) var(--small-margin); - margin: var(--small-margin) auto; - align-self: flex-end; - @include main-font(); - color: white; -} diff --git a/src/style/fonts.scss b/src/style/fonts.scss index a00d604..d244446 100644 --- a/src/style/fonts.scss +++ b/src/style/fonts.scss @@ -1,16 +1,3 @@ -/* comfortaa-regular - latin */ -@font-face { - font-family: 'Comfortaa'; - font-style: normal; - font-weight: 400; - font-display: swap; - src: - local(''), - url('../../assets/fonts/comfortaa-v40-latin-regular.woff2') format('woff2'), - /* Chrome 26+, Opera 23+, Firefox 39+ */ - url('../../assets/fonts/comfortaa-v40-latin-regular.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ -} - /* open-sans-regular - latin */ @font-face { font-family: 'Open Sans'; diff --git a/src/style/mixins.scss b/src/style/mixins.scss index 4ed95f5..46f2dd9 100644 --- a/src/style/mixins.scss +++ b/src/style/mixins.scss @@ -1,6 +1,3 @@ -@use 'sass:math'; -@use 'sass:color'; - $breakpoint-width: 600px !default; @mixin on-small-screen() { @@ -8,132 +5,3 @@ $breakpoint-width: 600px !default; @content; } } - -@mixin on-large-screen() { - @media (min-width: $breakpoint-width) { - @content; - } -} - -@mixin title-fragment-link() { - position: relative; - - &:before { - content: '#'; - position: absolute; - left: -0.5ch; - top: 50%; - opacity: 0; - transform: translateX(-100%) translateY(-50%); - transition: opacity var(--transition-time); - } - - &:hover:before { - opacity: 0.5; - } -} - -@mixin center-children() { - display: flex; - align-items: center; - justify-content: center; -} - -@mixin absolute-center() { - position: absolute; - left: 50%; - top: 50%; - transform: translateX(-50%) translateY(-50%); -} - -@mixin blurred-background($color: transparent) { - background-color: color.adjust($color, $alpha: -0.66); - backdrop-filter: blur(var(--blur-radius)); -} - -@mixin square($size) { - width: $size; - height: $size; -} - -@mixin title-font() { - font: - 400 3rem 'Comfortaa', - sans-serif; - color: var(--normal-text-color); - line-height: 1; - - @include on-small-screen { - font-size: 3rem; - line-height: 1.1; - } -} - -@mixin sub-title-font() { - font: - 400 1.75rem 'Comfortaa', - sans-serif; - color: var(--normal-text-color); - hyphens: auto; -} - -@mixin main-font() { - font: - 400 1.1rem 'Open Sans', - sans-serif; - color: var(--normal-text-color); - line-height: 1.8; - hyphens: auto; -} - -@mixin special-text-font() { - font: - 400 1rem 'Open Sans', - sans-serif; - color: var(--special-text-color); - hyphens: auto; - font-style: italic; -} - -@mixin link { - $border-shift: 10px; - $line-width: 2px; - - @include special-text-font(); - cursor: pointer; - position: relative; - display: inline-block; - overflow: hidden; - - padding: 0 3px $line-width 0; - - &:before, - &:after { - content: ''; - display: block; - position: absolute; - bottom: 0; - } - - &:before { - width: calc(100% + #{$border-shift}); - border-bottom: $line-width dashed var(--accent-color); - transition: transform var(--transition-time); - } - - &:after { - width: 100%; - height: $line-width; - background: linear-gradient( - 90deg, - var(--card-color) 0, - transparent 4px, - transparent calc(100% - 4px), - var(--card-color) 100% - ); - } - - &:hover:before { - transform: translateX(-$border-shift); - } -} diff --git a/src/style/vars.scss b/src/style/vars.scss index aa832ca..1c85b40 100644 --- a/src/style/vars.scss +++ b/src/style/vars.scss @@ -1,33 +1,9 @@ -@use 'mixins' as *; - :root { --transition-time: 200ms; --transition-time-long: 350ms; - --line-width: 4px; - --line-height: 1.125rem; --accent-color: rgb(6.39851188659668, 70.28645324707031, 102.23043060302734); - --very-light-text-color: #ffffff; --main-color: #aaa; --normal-text-color: #31343f; - --blurred-card-color: transparent; - --blur-radius: 12px; - --special-text-color: var(--accent-color); - --border-radius: 0.6rem; - - --large-margin: 4.6rem; --normal-margin: 2rem; --small-margin: 1rem; - --shadow: 0 0 5px 2px rgba(0, 0, 0, 0.15), 0 0 1px rgba(0, 0, 0, 0.2); - --icon-size: 2.5rem; - --large-icon-size: 3.75rem; - --body-width: min(80%, 60rem); -} - -@include on-small-screen { - :root { - --body-width: 90%; - --large-margin: 2.8rem; - --normal-margin: 2rem; - --icon-size: 2rem; - } } From 1fe5015056328f5be0553945e74e29be8e0025b9 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 16 May 2026 15:05:35 +0100 Subject: [PATCH 05/20] , --- definitions.d.ts | 2 + package-lock.json | 7 + package.json | 1 + playwright.config.ts | 2 +- src/analytics.ts | 69 +++++ src/audio/garden-audio-config.ts | 11 - src/audio/garden-audio-graph.ts | 65 ++-- src/audio/garden-audio-input.ts | 13 +- src/audio/garden-audio-music.ts | 12 +- src/audio/garden-audio-types.ts | 4 +- src/audio/garden-audio.test.ts | 110 ++++++- src/audio/garden-audio.ts | 74 +++-- src/audio/piano-sampler.test.ts | 126 ++++++++ src/audio/piano-sampler.ts | 76 +++-- src/audio/piano-samples.ts | 101 ++++++- src/config.ts | 17 -- src/config/color-interactions.ts | 41 --- src/config/runtime-settings.ts | 8 - src/config/types.ts | 7 +- src/config/vibe-presets.ts | 14 +- src/game-loop/frame-performance.test.ts | 41 ++- src/game-loop/frame-performance.ts | 20 +- src/game-loop/game-loop-ping-pong.test.ts | 26 +- src/game-loop/game-loop-resources.ts | 14 +- src/game-loop/game-loop-settings.ts | 1 - src/game-loop/game-loop-types.ts | 6 + src/game-loop/game-loop.ts | 19 +- src/game-loop/intro-title-agents.ts | 4 +- src/game-loop/pointer-input.ts | 12 +- src/game-loop/simulation-frame.ts | 107 +++---- src/game-loop/simulation-textures.ts | 31 ++ .../toolbar-contrast-monitor.test.ts | 68 +++++ src/game-loop/toolbar-contrast-monitor.ts | 284 ++++++++++++++++++ src/index.ts | 151 ++++++++-- .../agent-generation/agent-compaction.wgsl | 62 +++- .../agent-generation-pipeline.test.ts | 235 +++++++++++++++ .../agent-generation-pipeline.ts | 45 ++- .../agent-generation/agent-schema.test.ts | 23 +- src/pipelines/agents/agent.wgsl | 48 ++- src/pipelines/brush/brush-pipeline.ts | 163 ++++++---- src/pipelines/brush/brush.wgsl | 59 +++- src/pipelines/diffusion/diffuse.wgsl | 87 +++++- .../diffusion/diffusion-pipeline.test.ts | 8 + src/pipelines/diffusion/diffusion-pipeline.ts | 17 -- src/pipelines/eraser/eraser-agent-pipeline.ts | 251 ++++++---------- src/pipelines/eraser/eraser-agent.wgsl | 52 +--- .../eraser/eraser-texture-pipeline.ts | 130 ++++---- src/pipelines/eraser/eraser-texture.wgsl | 30 +- src/pipelines/render/render-pipeline.ts | 7 +- src/pipelines/wgsl-uniform-layout.test.ts | 8 +- src/utils/error-handler.ts | 1 - src/utils/graphics/initialize-context.ts | 1 + src/utils/graphics/resizable-texture.ts | 8 +- src/vibes.test.ts | 6 +- src/vibes.ts | 18 +- 55 files changed, 2077 insertions(+), 726 deletions(-) create mode 100644 src/analytics.ts create mode 100644 src/audio/piano-sampler.test.ts create mode 100644 src/game-loop/toolbar-contrast-monitor.test.ts create mode 100644 src/game-loop/toolbar-contrast-monitor.ts create mode 100644 src/pipelines/agents/agent-generation/agent-generation-pipeline.test.ts diff --git a/definitions.d.ts b/definitions.d.ts index c90ad44..9b4e880 100644 --- a/definitions.d.ts +++ b/definitions.d.ts @@ -6,3 +6,5 @@ declare module '*.wgsl?raw' { interface HTMLCanvasElement { getContext(contextId: 'webgpu'): GPUCanvasContext | null; } + +declare var webkitOfflineAudioContext: typeof OfflineAudioContext | undefined; diff --git a/package-lock.json b/package-lock.json index 054ae44..f0eb6fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.2.0", "license": "Unlicense", "dependencies": { + "@plausible-analytics/tracker": "^0.4.5", "tweakpane": "^4.0.5" }, "devDependencies": { @@ -1936,6 +1937,12 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@plausible-analytics/tracker": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@plausible-analytics/tracker/-/tracker-0.4.5.tgz", + "integrity": "sha512-6BfAGejXY+YA3Cw6LYT2Zpn4hTxDtPQAawFsYUsQCOg78wIS5C4deAGXTfJffa5VleMWITv5lpJ/EYuQBl1tPA==", + "license": "MIT" + }, "node_modules/@playwright/test": { "version": "1.60.0", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", diff --git a/package.json b/package.json index b566e03..16d5168 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "vitest": "^4.1.5" }, "dependencies": { + "@plausible-analytics/tracker": "^0.4.5", "tweakpane": "^4.0.5" } } diff --git a/playwright.config.ts b/playwright.config.ts index e252a37..f222fa0 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -19,7 +19,7 @@ export default defineConfig({ webServer: { command: `npm run preview -- --host 127.0.0.1 --port ${port}`, ignoreHTTPSErrors: true, - reuseExistingServer: !isCi, + reuseExistingServer: false, timeout: 120_000, url: baseURL, }, diff --git a/src/analytics.ts b/src/analytics.ts new file mode 100644 index 0000000..d7cccb5 --- /dev/null +++ b/src/analytics.ts @@ -0,0 +1,69 @@ +import { + init as plausibleInit, + track as plausibleTrack, + type PlausibleEventOptions, +} from '@plausible-analytics/tracker'; + +let isInitialized = false; + +const track = (eventName: string, options: PlausibleEventOptions = {}) => { + try { + plausibleTrack(eventName, options); + } catch (error) { + console.warn(`Could not track analytics event "${eventName}".`, error); + } +}; + +export const initAnalytics = () => { + if (isInitialized) { + return; + } + + try { + plausibleInit({ + domain: 'schmelczer.dev/floating', + endpoint: 'https://stats.schmelczer.dev/status', + autoCapturePageviews: true, + captureOnLocalhost: true, + logging: true, + fileDownloads: true, + outboundLinks: true, + hashBasedRouting: true, + }); + isInitialized = true; + } catch (error) { + console.warn('Could not initialize analytics.', error); + } +}; + +export const trackVibeChange = ({ + vibeId, + vibeName, + source, +}: { + vibeId: string; + vibeName: string; + source: string; +}) => { + track('Vibe Change', { + props: { + vibeId, + vibeName, + source, + }, + }); +}; + +export const trackExport = ({ vibeId }: { vibeId: string }) => { + track('Export', { + props: { + format: 'png', + resolution: '4k', + vibeId, + }, + }); +}; + +export const trackSettingsOpen = () => { + track('Settings Open'); +}; diff --git a/src/audio/garden-audio-config.ts b/src/audio/garden-audio-config.ts index c7a2c99..4751bf4 100644 --- a/src/audio/garden-audio-config.ts +++ b/src/audio/garden-audio-config.ts @@ -26,14 +26,6 @@ export interface GardenAudioConfig { fadeInSeconds: number; updateRampSeconds: number; highPassFrequencyHz: number; - fallbackVibeId: string; - compressor: { - thresholdDb: number; - kneeDb: number; - ratio: number; - attackSeconds: number; - releaseSeconds: number; - }; delay: { timeSeconds: number; feedback: number; @@ -47,9 +39,6 @@ export interface GardenAudioConfig { releaseSeconds: number; lowpassHz: number; }; - input: { - pressureFallback: number; - }; rhythm: { bpm: number; stepsPerBeat: number; diff --git a/src/audio/garden-audio-graph.ts b/src/audio/garden-audio-graph.ts index d4bb9ae..61b9cdf 100644 --- a/src/audio/garden-audio-graph.ts +++ b/src/audio/garden-audio-graph.ts @@ -2,6 +2,9 @@ import type { GardenAudioEngineConfig } from '../config'; import { clamp } from '../utils/clamp'; import { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config'; +const UNLOCK_TICK_SECONDS = 0.035; +const UNLOCK_TICK_FREQUENCY_HZ = 440; + export class GardenAudioGraph { public context: AudioContext | null = null; public eventBus: GainNode | null = null; @@ -12,7 +15,6 @@ export class GardenAudioGraph { private delayNode: DelayNode | null = null; private delayFeedback: GainNode | null = null; private delayOutput: GainNode | null = null; - private hasUnlocked = false; public constructor( private readonly config: GardenAudioConfig, @@ -28,23 +30,26 @@ export class GardenAudioGraph { return null; } - const context = new AudioContext({ latencyHint: 'interactive' }); + const AudioContextConstructor = globalThis.AudioContext; + if (!AudioContextConstructor) { + return null; + } + + let context: AudioContext; + try { + context = new AudioContextConstructor({ latencyHint: 'interactive' }); + } catch { + context = new AudioContextConstructor(); + } const masterGain = context.createGain(); const highPass = context.createBiquadFilter(); - const compressor = context.createDynamicsCompressor(); masterGain.gain.value = 0; highPass.type = 'highpass'; highPass.frequency.value = this.config.highPassFrequencyHz; - compressor.threshold.value = this.config.compressor.thresholdDb; - compressor.knee.value = this.config.compressor.kneeDb; - compressor.ratio.value = this.config.compressor.ratio; - compressor.attack.value = this.config.compressor.attackSeconds; - compressor.release.value = this.config.compressor.releaseSeconds; masterGain.connect(highPass); - highPass.connect(compressor); - compressor.connect(context.destination); + highPass.connect(context.destination); this.context = context; this.masterGain = masterGain; @@ -55,24 +60,37 @@ export class GardenAudioGraph { return context; } - // iOS WebKit (Safari + Chrome iOS) only fully unlocks audio output once - // a buffer source has been started inside a user-gesture handler. Calling - // resume() alone leaves the context "running" but silent. + // iOS WebKit can report "running" while the hardware output is still silent. + // A very short, nearly inaudible oscillator in the gesture stack is more + // reliable than a fully silent buffer on recent Safari versions. public unlock(): void { - if (!this.context || this.hasUnlocked) { + if (!this.context) { return; } - const buffer = this.context.createBuffer( - 1, - this.engineConfig.graph.unlockBufferLength, - this.engineConfig.graph.unlockSampleRate + const now = this.context.currentTime; + const source = this.context.createOscillator(); + const gain = this.context.createGain(); + + source.type = 'sine'; + source.frequency.setValueAtTime(UNLOCK_TICK_FREQUENCY_HZ, now); + gain.gain.setValueAtTime(this.engineConfig.piano.minGain, now); + gain.gain.exponentialRampToValueAtTime( + this.engineConfig.piano.minGain, + now + UNLOCK_TICK_SECONDS + ); + source.connect(gain); + gain.connect(this.context.destination); + source.start(now); + source.stop(now + UNLOCK_TICK_SECONDS); + source.addEventListener( + 'ended', + () => { + source.disconnect(); + gain.disconnect(); + }, + { once: true } ); - const source = this.context.createBufferSource(); - source.buffer = buffer; - source.connect(this.context.destination); - source.start(0); - this.hasUnlocked = true; } public setMasterGain(targetGain: number, timeConstantSeconds: number): void { @@ -201,6 +219,5 @@ export class GardenAudioGraph { this.delayNode = null; this.delayFeedback = null; this.delayOutput = null; - this.hasUnlocked = false; } } diff --git a/src/audio/garden-audio-input.ts b/src/audio/garden-audio-input.ts index b3fa64b..6e9ab09 100644 --- a/src/audio/garden-audio-input.ts +++ b/src/audio/garden-audio-input.ts @@ -13,14 +13,13 @@ export interface GardenAudioStrokeMetrics { export const getStrokeMetrics = ( stroke: GardenAudioStroke, speedForFullEnergyPixelsPerSecond: number, - fallbackPressure: number, inputConfig: GardenAudioEngineConfig['input'] ): GardenAudioStrokeMetrics => { const dx = stroke.to[0] - stroke.from[0]; const dy = stroke.to[1] - stroke.from[1]; const distancePixels = Math.hypot(dx, dy); const speedPixelsPerSecond = getStrokeVelocity(stroke, distancePixels, inputConfig); - const pressure = getPressureAmount(stroke, fallbackPressure, inputConfig); + const pressure = getPressureAmount(stroke); const speedAmount = clamp01(speedPixelsPerSecond / speedForFullEnergyPixelsPerSecond); const strokeEnergy = clamp01( inputConfig.strokeEnergyBase + @@ -58,11 +57,7 @@ const getStrokeVelocity = ( return distancePixels / inputConfig.fallbackFrameSeconds; }; -const getPressureAmount = ( - stroke: GardenAudioStroke, - fallbackPressure: number, - inputConfig: GardenAudioEngineConfig['input'] -): number => { +const getPressureAmount = (stroke: GardenAudioStroke): number => { if ( stroke.pressure !== undefined && Number.isFinite(stroke.pressure) && @@ -71,7 +66,5 @@ const getPressureAmount = ( return clamp01(stroke.pressure); } - return stroke.pointerType === 'pen' - ? Math.max(inputConfig.penMinPressure, clamp01(fallbackPressure)) - : clamp01(fallbackPressure); + return 0; }; diff --git a/src/audio/garden-audio-music.ts b/src/audio/garden-audio-music.ts index 11ee580..602ff05 100644 --- a/src/audio/garden-audio-music.ts +++ b/src/audio/garden-audio-music.ts @@ -12,10 +12,14 @@ export const normalizeColorIndex = (index: number): GardenAudioColorIndex => export const getVibeProfile = ( config: GardenAudioConfig, vibe: VibePreset -): GardenAudioVibeProfile => - config.vibes[vibe.id] ?? - config.vibes[config.fallbackVibeId] ?? - Object.values(config.vibes)[0]; +): GardenAudioVibeProfile => { + const profile = config.vibes[vibe.id]; + if (!profile) { + throw new Error(`Missing audio profile for vibe "${vibe.id}"`); + } + + return profile; +}; export const getChordIntervals = ( chord: GardenAudioChord, diff --git a/src/audio/garden-audio-types.ts b/src/audio/garden-audio-types.ts index 71eb314..8f03c41 100644 --- a/src/audio/garden-audio-types.ts +++ b/src/audio/garden-audio-types.ts @@ -21,7 +21,6 @@ export interface GardenAudioStroke { elapsedSeconds?: number; eraserSizePixels?: number; mirrorSegmentCount?: number; - pointerType?: string; } export interface GardenAudioTouchDown { @@ -31,7 +30,6 @@ export interface GardenAudioTouchDown { canvasSize?: ArrayLike; mirrorSegmentCount?: number; pressure?: number; - pointerType?: string; } export interface GardenAudioStartOptions { @@ -45,7 +43,7 @@ export interface LoadedPianoSample { export interface ActivePianoVoice { gain: GainNode; - source: AudioBufferSourceNode; + source: AudioScheduledSourceNode; startAt: number; stopAt: number; } diff --git a/src/audio/garden-audio.test.ts b/src/audio/garden-audio.test.ts index 6d4f7da..7b48e60 100644 --- a/src/audio/garden-audio.test.ts +++ b/src/audio/garden-audio.test.ts @@ -1,17 +1,26 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { appConfig } from '../config'; +import { ErrorHandler, Severity } from '../utils/error-handler'; import { VIBE_PRESETS } from '../vibes'; import { GardenAudio } from './garden-audio'; import { gardenAudioConfig, GardenAudioConfig } from './garden-audio-config'; +import { loadPianoSamples, resetPianoSampleCacheForTest } from './piano-samples'; + +type FakeScheduledSourceNode = { + start: ReturnType; + stop: ReturnType; +}; const calls = { constructed: 0, resumed: 0, sourcesStarted: 0, + sources: [] as Array, }; let contextState: AudioContextState = 'suspended'; +let resumeError: Error | null = null; class FakeAudioParam { public value = 0; @@ -24,6 +33,7 @@ class FakeAudioParam { class FakeAudioNode { public readonly gain = new FakeAudioParam(); public readonly frequency = new FakeAudioParam(); + public readonly playbackRate = new FakeAudioParam(); public readonly Q = new FakeAudioParam(); public readonly threshold = new FakeAudioParam(); public readonly knee = new FakeAudioParam(); @@ -54,6 +64,7 @@ class FakeAudioContext { public readonly currentTime = 1; public readonly sampleRate = 16; public readonly destination = new FakeAudioNode() as unknown as AudioDestinationNode; + public readonly decodeAudioData = vi.fn(async () => ({}) as AudioBuffer); public constructor() { calls.constructed += 1; @@ -75,10 +86,6 @@ class FakeAudioContext { return new FakeAudioNode() as unknown as BiquadFilterNode; } - public createDynamicsCompressor(): DynamicsCompressorNode { - return new FakeAudioNode() as unknown as DynamicsCompressorNode; - } - public createDelay(): DelayNode { return new FakeAudioNode() as unknown as DelayNode; } @@ -100,13 +107,30 @@ class FakeAudioContext { node.buffer = null; node.start = vi.fn(() => { calls.sourcesStarted += 1; - }); - node.stop = vi.fn(); + }) as unknown as typeof node.start; + node.stop = vi.fn() as unknown as typeof node.stop; + calls.sources.push(node as unknown as FakeScheduledSourceNode); + return node; + } + + public createOscillator(): OscillatorNode { + const node = new FakeAudioNode() as unknown as OscillatorNode & { + start: () => void; + stop: () => void; + }; + node.start = vi.fn(() => { + calls.sourcesStarted += 1; + }) as unknown as typeof node.start; + node.stop = vi.fn() as unknown as typeof node.stop; + calls.sources.push(node as unknown as FakeScheduledSourceNode); return node; } public async resume(): Promise { calls.resumed += 1; + if (resumeError) { + throw resumeError; + } contextState = 'running'; } } @@ -120,13 +144,18 @@ describe('GardenAudio startup policy', () => { calls.constructed = 0; calls.resumed = 0; calls.sourcesStarted = 0; + calls.sources = []; contextState = 'suspended'; + resumeError = null; + resetPianoSampleCacheForTest(); vi.stubGlobal('AudioContext', FakeAudioContext); vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('not loaded in tests'))); }); afterEach(() => { + vi.restoreAllMocks(); vi.unstubAllGlobals(); + resetPianoSampleCacheForTest(); }); it('does not create an AudioContext from passive audio paths', () => { @@ -171,7 +200,27 @@ describe('GardenAudio startup policy', () => { expect(calls.resumed).toBe(1); }); - it('skips cold piano fallback while preserving eraser noise', () => { + it('reports AudioContext resume failures as warnings', async () => { + const audio = new GardenAudio( + makeConfig(), + appConfig.audioEngine, + appConfig.simulation.maxMirrorSegmentCount + ); + const vibe = VIBE_PRESETS[0]; + resumeError = new Error('resume rejected'); + const addException = vi.spyOn(ErrorHandler, 'addException'); + + audio.start(vibe, { userGesture: true }); + await Promise.resolve(); + await Promise.resolve(); + + expect(addException).toHaveBeenCalledWith(resumeError, { + fallbackMessage: 'Could not resume audio playback.', + severity: Severity.WARNING, + }); + }); + + it('stays silent without piano samples while preserving eraser noise', () => { const audio = new GardenAudio( makeConfig(), appConfig.audioEngine, @@ -217,4 +266,51 @@ describe('GardenAudio startup policy', () => { expect(calls.sourcesStarted).toBe(2); }); + + it('quickly stops active piano voices when the vibe changes', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + arrayBuffer: async () => new ArrayBuffer(8), + ok: true, + })) + ); + await loadPianoSamples(new FakeAudioContext() as unknown as AudioContext); + + const audio = new GardenAudio( + makeConfig(), + appConfig.audioEngine, + appConfig.simulation.maxMirrorSegmentCount + ); + const vibe = VIBE_PRESETS[0]; + + audio.start(vibe, { userGesture: true }); + audio.beginGesture(); + audio.touchDown({ + vibe, + colorIndex: 1, + position: [30, 40], + canvasSize: [100, 100], + pressure: 0.7, + }); + + const activePianoSources = calls.sources.filter( + (source) => source.stop.mock.calls.length === 1 + ); + expect(activePianoSources.length).toBeGreaterThan(0); + + const stopCounts = activePianoSources.map((source) => source.stop.mock.calls.length); + audio.changeVibe(VIBE_PRESETS[1], { userGesture: true }); + + const stoppedVoices = activePianoSources.filter( + (source, index) => source.stop.mock.calls.length === stopCounts[index] + 1 + ); + expect(stoppedVoices.length).toBeGreaterThan(0); + stoppedVoices.forEach((source) => { + expect(source.stop.mock.calls.at(-1)?.[0]).toBeCloseTo( + 1 + appConfig.audioEngine.piano.voiceStealStopSeconds, + 3 + ); + }); + }); }); diff --git a/src/audio/garden-audio.ts b/src/audio/garden-audio.ts index b736d9c..ea54e63 100644 --- a/src/audio/garden-audio.ts +++ b/src/audio/garden-audio.ts @@ -1,5 +1,6 @@ import type { GardenAudioEngineConfig } from '../config'; import { clamp, clamp01 } from '../utils/clamp'; +import { ErrorHandler, Severity } from '../utils/error-handler'; import { VibePreset } from '../vibes'; import { GardenAudioConfig } from './garden-audio-config'; import { GardenAudioEnergy } from './garden-audio-energy'; @@ -71,40 +72,66 @@ export class GardenAudio { return; } + const startupRampSeconds = + options.userGesture === true + ? this.engineConfig.muteRampSeconds + : this.config.fadeInSeconds; + const needsResume = context.state !== 'running' && context.state !== 'closed'; + let resumePromise: Promise | null = null; + + if (needsResume) { + if (options.userGesture !== true) { + return; + } + resumePromise = context.resume(); + } + if (options.userGesture === true) { this.graph.unlock(); } - if (context.state === 'suspended') { - if (options.userGesture !== true) { - return; - } - void context.resume().catch(() => undefined); + if (resumePromise) { + void resumePromise + .then(() => { + if (this.graph.context === context && !this.isDestroyed && !this.isMuted) { + this.graph.unlock(); + this.graph.setMasterGain(this.config.masterVolume, startupRampSeconds); + } + }) + .catch((error) => { + ErrorHandler.addException(error, { + fallbackMessage: 'Could not resume audio playback.', + severity: Severity.WARNING, + }); + }); } this.hasStarted = true; this.applyVibe(vibe); this.pianoEngine.prime(context.currentTime); - this.graph.setMasterGain( - this.config.masterVolume, - options.userGesture === true - ? this.engineConfig.muteRampSeconds - : this.config.fadeInSeconds - ); + this.graph.setMasterGain(this.config.masterVolume, startupRampSeconds); if (!this.hasQueuedPianoLoad) { this.hasQueuedPianoLoad = true; - void this.piano.load(context).then(() => { - if (this.graph.context === context && !this.isDestroyed) { - this.pianoEngine.cue(context.currentTime); - } - }); + void this.piano + .load(context) + .then(() => { + if (this.graph.context === context && !this.isDestroyed) { + this.pianoEngine.cue(context.currentTime); + } + }) + .catch(() => undefined); } } public changeVibe(vibe: VibePreset, options: GardenAudioStartOptions = {}): void { const previousVibeId = this.currentVibeId; this.start(vibe, options); + const didChangeVibe = previousVibeId !== null && previousVibeId !== vibe.id; + + if (didChangeVibe) { + this.piano.stopAll(); + } const context = this.graph.context; if ( @@ -112,8 +139,7 @@ export class GardenAudio { (context.state === 'running' || options.userGesture === true) && !this.isMuted && !this.isDestroyed && - previousVibeId !== null && - previousVibeId !== vibe.id + didChangeVibe ) { this.playVibeChangeStinger(vibe); } @@ -158,7 +184,7 @@ export class GardenAudio { this.selectedColorIndex = normalizeColorIndex(touch.colorIndex); const mirrorAmount = this.getMirrorAmount(touch.mirrorSegmentCount ?? 1); - const pressure = this.getTouchPressure(touch.pressure, touch.pointerType); + const pressure = this.getPressure(touch.pressure); const strength = clamp01(0.36 + pressure * 0.34 + mirrorAmount * 0.22); const frame = this.gestureState.recordTouchDown({ touch, @@ -225,7 +251,6 @@ export class GardenAudio { const metrics = getStrokeMetrics( stroke, this.config.rhythm.speedForFullEnergyPixelsPerSecond, - this.config.input.pressureFallback, this.engineConfig.input ); const now = context.currentTime; @@ -375,16 +400,11 @@ export class GardenAudio { return clamp01((segmentCount - 1) / (maxMirrorSegmentCount - 1)); } - private getTouchPressure(pressure: number | undefined, pointerType?: string): number { + private getPressure(pressure: number | undefined): number { if (pressure !== undefined && Number.isFinite(pressure) && pressure > 0) { return clamp01(pressure); } - return pointerType === 'pen' - ? Math.max( - this.engineConfig.input.penMinPressure, - this.config.input.pressureFallback - ) - : this.config.input.pressureFallback; + return 0; } } diff --git a/src/audio/piano-sampler.test.ts b/src/audio/piano-sampler.test.ts new file mode 100644 index 0000000..24c71f9 --- /dev/null +++ b/src/audio/piano-sampler.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { appConfig } from '../config'; +import { gardenAudioConfig } from './garden-audio-config'; +import type { GardenAudioGraph } from './garden-audio-graph'; +import { PianoSampler } from './piano-sampler'; +import { pianoSampleDefinitions, resetPianoSampleCacheForTest } from './piano-samples'; + +const calls = { + bufferSourcesStarted: 0, +}; + +class FakeAudioParam { + public value = 0; + public setTargetAtTime = vi.fn(); + public setValueAtTime = vi.fn(); + public exponentialRampToValueAtTime = vi.fn(); + public cancelScheduledValues = vi.fn(); +} + +class FakeAudioNode { + public readonly gain = new FakeAudioParam(); + public readonly frequency = new FakeAudioParam(); + public readonly playbackRate = new FakeAudioParam(); + public readonly Q = new FakeAudioParam(); + public readonly pan = new FakeAudioParam(); + public buffer: AudioBuffer | null = null; + public type = ''; + public addEventListener = vi.fn(); + public connect = vi.fn(); + public disconnect = vi.fn(); + public start = vi.fn(); + public stop = vi.fn(); +} + +class FakeAudioContext { + public readonly currentTime = 1; + public readonly decodeAudioData = vi.fn(async () => ({}) as AudioBuffer); + + public createGain(): GainNode { + return new FakeAudioNode() as unknown as GainNode; + } + + public createBiquadFilter(): BiquadFilterNode { + return new FakeAudioNode() as unknown as BiquadFilterNode; + } + + public createStereoPanner(): StereoPannerNode { + return new FakeAudioNode() as unknown as StereoPannerNode; + } + + public createBufferSource(): AudioBufferSourceNode { + const node = new FakeAudioNode() as unknown as AudioBufferSourceNode & { + start: () => void; + stop: () => void; + }; + node.start = vi.fn(() => { + calls.bufferSourcesStarted += 1; + }); + node.stop = vi.fn(); + return node; + } +} + +const makeSampler = (context: AudioContext): PianoSampler => { + const eventBus = new FakeAudioNode() as unknown as GainNode; + const graph = { + context, + delayInput: null, + eventBus, + } as unknown as GardenAudioGraph; + + return new PianoSampler(gardenAudioConfig, appConfig.audioEngine, graph); +}; + +describe('PianoSampler', () => { + beforeEach(() => { + calls.bufferSourcesStarted = 0; + resetPianoSampleCacheForTest(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + resetPianoSampleCacheForTest(); + }); + + it('loads every piano sample before playback', async () => { + const context = new FakeAudioContext() as unknown as AudioContext; + const sampler = makeSampler(context); + const fetch = vi.fn(async () => { + return { + arrayBuffer: async () => new ArrayBuffer(8), + ok: true, + } as Response; + }); + vi.stubGlobal('fetch', fetch); + + await sampler.load(context); + sampler.play({ + durationSeconds: 0.2, + midi: 60, + pan: 0, + startTime: context.currentTime, + velocity: 0.5, + }); + + expect(fetch).toHaveBeenCalledTimes(pianoSampleDefinitions.length); + expect(context.decodeAudioData).toHaveBeenCalledTimes(pianoSampleDefinitions.length); + expect(calls.bufferSourcesStarted).toBe(1); + }); + + it('stays silent when no decoded sample is available', () => { + const context = new FakeAudioContext() as unknown as AudioContext; + const sampler = makeSampler(context); + + sampler.play({ + durationSeconds: 0.2, + midi: 60, + pan: 0, + startTime: context.currentTime, + velocity: 0.5, + }); + + expect(calls.bufferSourcesStarted).toBe(0); + }); +}); diff --git a/src/audio/piano-sampler.ts b/src/audio/piano-sampler.ts index fab0e13..3320b73 100644 --- a/src/audio/piano-sampler.ts +++ b/src/audio/piano-sampler.ts @@ -3,7 +3,7 @@ import { clamp, clamp01 } from '../utils/clamp'; import { GardenAudioConfig } from './garden-audio-config'; import { GardenAudioGraph } from './garden-audio-graph'; import { ActivePianoVoice, LoadedPianoSample, PianoNote } from './garden-audio-types'; -import { pianoSampleDefinitions } from './piano-samples'; +import { getLoadedPianoSamples, loadPianoSamples } from './piano-samples'; export class PianoSampler { private sampleLoadPromise: Promise | null = null; @@ -16,28 +16,20 @@ export class PianoSampler { private readonly graph: GardenAudioGraph ) {} - public async load(context: AudioContext): Promise { + public load(context: BaseAudioContext): Promise { + const loadedSamples = getLoadedPianoSamples(); + if (loadedSamples) { + this.setSamples(loadedSamples); + return Promise.resolve(); + } + if (this.sampleLoadPromise) { return this.sampleLoadPromise; } - this.sampleLoadPromise = Promise.all( - pianoSampleDefinitions.map(async (sample) => { - const response = await fetch(sample.url); - if (!response.ok) { - throw new Error(`Unable to load piano sample ${sample.url}`); - } - const audioData = await response.arrayBuffer(); - const buffer = await context.decodeAudioData(audioData); - return { midi: sample.midi, buffer }; - }) - ) - .then((samples) => { - this.samples = samples.sort((a, b) => a.midi - b.midi); - }) - .catch(() => { - this.samples = []; - }); + this.sampleLoadPromise = loadPianoSamples(context).then((samples) => { + this.setSamples(samples); + }); return this.sampleLoadPromise; } @@ -89,13 +81,9 @@ export class PianoSampler { this.trimActiveVoices(scheduledStart); while (this.activeVoices.length >= this.config.piano.maxVoices) { const oldest = this.activeVoices.shift(); - oldest?.gain.gain.cancelScheduledValues(scheduledStart); - oldest?.gain.gain.setTargetAtTime( - this.engineConfig.piano.minGain, - scheduledStart, - this.engineConfig.piano.voiceStealFadeSeconds - ); - oldest?.source.stop(scheduledStart + this.engineConfig.piano.voiceStealStopSeconds); + if (oldest) { + this.stopVoice(oldest, scheduledStart); + } } source.buffer = sample.buffer; @@ -162,6 +150,21 @@ export class PianoSampler { ); } + public stopAll(): void { + const context = this.graph.context; + if (!context) { + this.activeVoices = []; + return; + } + + const now = context.currentTime; + + this.activeVoices.forEach((voice) => { + this.stopVoice(voice, now); + }); + this.activeVoices = []; + } + public reset(): void { this.sampleLoadPromise = null; this.samples = []; @@ -181,4 +184,25 @@ export class PianoSampler { private trimActiveVoices(now: number): void { this.activeVoices = this.activeVoices.filter((voice) => voice.stopAt > now); } + + private stopVoice(voice: ActivePianoVoice, now: number): void { + const stopAt = now + this.engineConfig.piano.voiceStealStopSeconds; + + voice.gain.gain.cancelScheduledValues(now); + voice.gain.gain.setTargetAtTime( + this.engineConfig.piano.minGain, + now, + this.engineConfig.piano.voiceStealFadeSeconds + ); + voice.stopAt = stopAt; + try { + voice.source.stop(stopAt); + } catch { + // The voice may already have ended; either way it is no longer part of the mix. + } + } + + private setSamples(samples: Array): void { + this.samples = samples.slice().sort((a, b) => a.midi - b.midi); + } } diff --git a/src/audio/piano-samples.ts b/src/audio/piano-samples.ts index 097feab..fb88bf5 100644 --- a/src/audio/piano-samples.ts +++ b/src/audio/piano-samples.ts @@ -1,8 +1,16 @@ -interface PianoSampleDefinition { +import type { LoadedPianoSample } from './garden-audio-types'; + +export interface PianoSampleDefinition { midi: number; url: string; } +export interface PianoSampleLoadProgress { + loadedCount: number; + totalCount: number; + sample?: PianoSampleDefinition; +} + const sampleBaseUrl = `${import.meta.env.BASE_URL}audio/piano/`; const sampleFiles: Array<[fileName: string, midi: number]> = [ @@ -44,3 +52,94 @@ export const pianoSampleDefinitions: Array = sampleFiles url: `${sampleBaseUrl}${fileName}`, })) .sort((a, b) => a.midi - b.midi); + +let loadedPianoSamples: Array | null = null; +let pianoSampleLoadPromise: Promise> | null = null; + +export const preloadPianoSamples = ( + onProgress?: (progress: PianoSampleLoadProgress) => void +): Promise> => { + const OfflineAudioContextConstructor = + globalThis.OfflineAudioContext ?? globalThis.webkitOfflineAudioContext; + + if (!OfflineAudioContextConstructor) { + return Promise.reject( + new Error('OfflineAudioContext is required to preload piano samples.') + ); + } + + const decodeContext = new OfflineAudioContextConstructor(1, 1, 44_100); + return loadPianoSamples(decodeContext, onProgress); +}; + +export const loadPianoSamples = ( + decodeContext: BaseAudioContext, + onProgress?: (progress: PianoSampleLoadProgress) => void +): Promise> => { + if (loadedPianoSamples) { + onProgress?.({ + loadedCount: loadedPianoSamples.length, + totalCount: pianoSampleDefinitions.length, + }); + return Promise.resolve([...loadedPianoSamples]); + } + + if (pianoSampleLoadPromise) { + return pianoSampleLoadPromise; + } + + let loadedCount = 0; + const totalCount = pianoSampleDefinitions.length; + onProgress?.({ loadedCount, totalCount }); + + pianoSampleLoadPromise = Promise.all( + pianoSampleDefinitions.map(async (sample) => { + const loadedSample = await loadPianoSample(decodeContext, sample); + loadedCount += 1; + onProgress?.({ loadedCount, totalCount, sample }); + return loadedSample; + }) + ).then( + (samples) => { + loadedPianoSamples = samples.slice().sort((a, b) => a.midi - b.midi); + return [...loadedPianoSamples]; + }, + (error: unknown) => { + pianoSampleLoadPromise = null; + throw error; + } + ); + + return pianoSampleLoadPromise; +}; + +export const getLoadedPianoSamples = (): Array | null => + loadedPianoSamples ? [...loadedPianoSamples] : null; + +export const resetPianoSampleCacheForTest = (): void => { + loadedPianoSamples = null; + pianoSampleLoadPromise = null; +}; + +const loadPianoSample = async ( + decodeContext: BaseAudioContext, + sample: PianoSampleDefinition +): Promise => { + const response = await fetch(sample.url); + if (!response.ok) { + throw new Error(`Unable to load piano sample ${sample.url}`); + } + + const audioData = await response.arrayBuffer(); + const buffer = await decodeAudioData(decodeContext, audioData); + return { midi: sample.midi, buffer }; +}; + +const decodeAudioData = ( + decodeContext: BaseAudioContext, + audioData: ArrayBuffer +): Promise => + new Promise((resolve, reject) => { + const decodePromise = decodeContext.decodeAudioData(audioData, resolve, reject); + decodePromise?.then(resolve, reject); + }); diff --git a/src/config.ts b/src/config.ts index a31e01a..feacf71 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,15 +3,10 @@ import type { GardenAppConfig } from './config/types'; import { audioVibes, defaultVibeId, vibePresets } from './config/vibe-presets'; export type { - AgentColorInteractionSettings, GardenAppConfig, GardenAudioEngineConfig, GardenRuntimeSettings, - GardenSimulationConfig, - GardenStorageConfig, - GardenVibeSettings, NumberControlConfig, - RuntimeSettingControlConfig, VibePreset, } from './config/types'; @@ -21,14 +16,6 @@ export const appConfig = { fadeInSeconds: 0.45, updateRampSeconds: 0.08, highPassFrequencyHz: 45, - fallbackVibeId: defaultVibeId, - compressor: { - thresholdDb: -18, - kneeDb: 18, - ratio: 2.4, - attackSeconds: 0.006, - releaseSeconds: 0.18, - }, delay: { timeSeconds: 0.46, feedback: 0.12, @@ -42,9 +29,6 @@ export const appConfig = { releaseSeconds: 0.24, lowpassHz: 7600, }, - input: { - pressureFallback: 0.48, - }, rhythm: { bpm: 74, stepsPerBeat: 4, @@ -120,7 +104,6 @@ export const appConfig = { distanceEnergyScale: 0.66, distanceForFullEnergyPixels: 140, fallbackFrameSeconds: 1 / 60, - penMinPressure: 0.56, strokeEnergyBase: 0.18, strokeEnergyPressureWeight: 0.22, strokeEnergySpeedWeight: 0.62, diff --git a/src/config/color-interactions.ts b/src/config/color-interactions.ts index cfb5ff8..671c2a4 100644 --- a/src/config/color-interactions.ts +++ b/src/config/color-interactions.ts @@ -18,47 +18,6 @@ export const defaultColorInteractionSettings: AgentColorInteractionSettings = { color3ToColor3: 1, }; -const hashString = (value: string): number => { - let hash = 0x811c9dc5; - for (let i = 0; i < value.length; i++) { - hash ^= value.charCodeAt(i); - hash = Math.imul(hash, 0x01000193); - } - return hash >>> 0; -}; - -const createSeededRandom = (seed: number): (() => number) => { - let state = seed; - return () => { - let value = (state += 0x6d2b79f5); - value = Math.imul(value ^ (value >>> 15), value | 1); - value ^= value + Math.imul(value ^ (value >>> 7), value | 61); - return ((value ^ (value >>> 14)) >>> 0) / 4294967296; - }; -}; - -export const createColorInteractionSettings = ( - seedSource: string -): AgentColorInteractionSettings => { - const random = createSeededRandom(hashString(seedSource)); - const values = Object.values(agentInteractionOptions); - const randomInteraction = () => - values[Math.floor(random() * values.length)] ?? - defaultColorInteractionSettings.color1ToColor2; - - return { - color1ToColor1: 1, - color1ToColor2: randomInteraction(), - color1ToColor3: randomInteraction(), - color2ToColor1: randomInteraction(), - color2ToColor2: 1, - color2ToColor3: randomInteraction(), - color3ToColor1: randomInteraction(), - color3ToColor2: randomInteraction(), - color3ToColor3: 1, - }; -}; - export const colorInteractionControl = (label: string): NumberControlConfig => ({ folder: 'Color Reactions', label, diff --git a/src/config/runtime-settings.ts b/src/config/runtime-settings.ts index 2de328f..8e2c9b3 100644 --- a/src/config/runtime-settings.ts +++ b/src/config/runtime-settings.ts @@ -36,7 +36,6 @@ export const runtimeSettings: GardenAppConfig['runtimeSettings'] = { startColorHue: 200, - renderSpeed: 1, simulatedDelayMs: 0, }, controls: { @@ -145,13 +144,6 @@ export const runtimeSettings: GardenAppConfig['runtimeSettings'] = { max: 500, step: 1, }, - renderSpeed: { - folder: 'Runtime', - integer: true, - min: 1, - max: 10, - step: 1, - }, selectedColorIndex: { folder: 'Brush', integer: true, diff --git a/src/config/types.ts b/src/config/types.ts index 0bb8989..44d586b 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -27,7 +27,7 @@ export type AgentColorInteractionSettings = Pick< | 'color3ToColor3' >; -export type GardenVibeSettings = Partial< +type GardenVibeSettings = Partial< Pick< GardenRuntimeSettings, | 'agentBudgetMax' @@ -72,7 +72,7 @@ export interface NumberControlConfig { step?: number; } -export type RuntimeSettingControlConfig = { +type RuntimeSettingControlConfig = { [Key in keyof GardenRuntimeSettings]: NumberControlConfig; }; @@ -120,7 +120,6 @@ export interface GardenAppConfig { distanceEnergyScale: number; distanceForFullEnergyPixels: number; fallbackFrameSeconds: number; - penMinPressure: number; strokeEnergyBase: number; strokeEnergyPressureWeight: number; strokeEnergySpeedWeight: number; @@ -280,5 +279,3 @@ export interface GardenAppConfig { } export type GardenAudioEngineConfig = GardenAppConfig['audioEngine']; -export type GardenSimulationConfig = GardenAppConfig['simulation']; -export type GardenStorageConfig = GardenAppConfig['storage']; diff --git a/src/config/vibe-presets.ts b/src/config/vibe-presets.ts index d4fd90f..e14aadf 100644 --- a/src/config/vibe-presets.ts +++ b/src/config/vibe-presets.ts @@ -2,7 +2,7 @@ import type { GardenAudioChord, GardenAudioVibeProfile, } from '../audio/garden-audio-config'; -import { createColorInteractionSettings } from './color-interactions'; +import { defaultColorInteractionSettings } from './color-interactions'; import type { VibePreset } from './types'; const majorProgression: Array = [ @@ -42,7 +42,7 @@ export const vibePresets: Array = [ sensorOffsetDistance: 38, spawnPerPixel: 0.22, turnSpeed: 58, - ...createColorInteractionSettings('candy-rain'), + ...defaultColorInteractionSettings, }, audio: { rootMidi: 57, @@ -69,7 +69,7 @@ export const vibePresets: Array = [ sensorOffsetDistance: 46, spawnPerPixel: 0.18, turnSpeed: 44, - ...createColorInteractionSettings('sunlit-moss'), + ...defaultColorInteractionSettings, }, audio: { rootMidi: 53, @@ -101,7 +101,7 @@ export const vibePresets: Array = [ sensorOffsetDistance: 35, spawnPerPixel: 0.25, turnSpeed: 62, - ...createColorInteractionSettings('coral-tide'), + ...defaultColorInteractionSettings, }, audio: { rootMidi: 50, @@ -128,7 +128,7 @@ export const vibePresets: Array = [ sensorOffsetDistance: 42, spawnPerPixel: 0.2, turnSpeed: 52, - ...createColorInteractionSettings('moon-orchid'), + ...defaultColorInteractionSettings, }, audio: { rootMidi: 49, @@ -155,7 +155,7 @@ export const vibePresets: Array = [ sensorOffsetDistance: 32, spawnPerPixel: 0.24, turnSpeed: 70, - ...createColorInteractionSettings('peach-neon'), + ...defaultColorInteractionSettings, }, audio: { rootMidi: 56, @@ -182,7 +182,7 @@ export const vibePresets: Array = [ sensorOffsetDistance: 52, spawnPerPixel: 0.16, turnSpeed: 40, - ...createColorInteractionSettings('frost-bloom'), + ...defaultColorInteractionSettings, }, audio: { rootMidi: 62, diff --git a/src/game-loop/frame-performance.test.ts b/src/game-loop/frame-performance.test.ts index cd74739..35250f0 100644 --- a/src/game-loop/frame-performance.test.ts +++ b/src/game-loop/frame-performance.test.ts @@ -2,44 +2,61 @@ import { describe, expect, it } from 'vitest'; import { FramePerformance } from './frame-performance'; +function createScenario() { + const performance = new FramePerformance(); + let time = 0; + performance.update(time); + const advance = (fps: number): void => { + time += 1000 / fps; + performance.update(time); + }; + return { performance, advance }; +} + describe('FramePerformance refresh target', () => { it('uses 60 FPS as the fixed adaptive budget target', () => { - const performance = new FramePerformance(); + const { performance, advance } = createScenario(); - [123, 126, 130, 121, 60, 30].forEach((fps) => performance.update(1 / fps)); + [123, 126, 130, 121, 60, 30].forEach(advance); expect(performance.refreshTargetFps).toBe(60); }); it('keeps latest and smoothed FPS separate from the fixed target', () => { - const performance = new FramePerformance(); + const { performance, advance } = createScenario(); - performance.update(1 / 120); + advance(120); expect(performance.latestFps).toBe(120); expect(performance.smoothedFps).toBeGreaterThan(60); expect(performance.refreshTargetFps).toBe(60); }); - it('snaps the display refresh estimate to a stable screen frequency', () => { - const performance = new FramePerformance(); + it('reports true FPS even when the simulation delta would clamp', () => { + const { performance, advance } = createScenario(); - [123, 126, 130, 121, 124, 127, 125, 122].forEach((fps) => - performance.update(1 / fps) - ); + [5, 5, 5, 5, 5].forEach(advance); + + expect(performance.latestFps).toBeCloseTo(5, 5); + }); + + it('snaps the display refresh estimate to a stable screen frequency', () => { + const { performance, advance } = createScenario(); + + [123, 126, 130, 121, 124, 127, 125, 122].forEach(advance); expect(performance.refreshTargetFps).toBe(60); expect(performance.displayRefreshFps).toBe(120); }); it('ignores a single startup spike before settling the display refresh estimate', () => { - const performance = new FramePerformance(); + const { performance, advance } = createScenario(); - performance.update(1 / 240); + advance(240); expect(performance.displayRefreshFps).toBe(60); - Array.from({ length: 8 }).forEach(() => performance.update(1 / 120)); + Array.from({ length: 8 }).forEach(() => advance(120)); expect(performance.refreshTargetFps).toBe(60); expect(performance.displayRefreshFps).toBe(120); diff --git a/src/game-loop/frame-performance.ts b/src/game-loop/frame-performance.ts index ea82d71..0127abe 100644 --- a/src/game-loop/frame-performance.ts +++ b/src/game-loop/frame-performance.ts @@ -7,7 +7,6 @@ interface TelemetrySnapshot { agentBudgetMax: number; canvas: HTMLCanvasElement; devicePixelRatio: number; - renderSpeed: number; } const COMMON_DISPLAY_REFRESH_RATES = [ @@ -15,6 +14,7 @@ const COMMON_DISPLAY_REFRESH_RATES = [ ] as const; const DISPLAY_REFRESH_CONFIRMATION_FRAMES = 8; const DISPLAY_REFRESH_SNAP_TOLERANCE = 0.15; +const FRAME_GAP_RESET_SECONDS = 1; export class FramePerformance { public latestFps = 60; @@ -23,6 +23,7 @@ export class FramePerformance { public readonly refreshTargetFps = 60; private lastTelemetryAt = 0; + private previousFrameTime: DOMHighResTimeStamp | null = null; private hasConfirmedDisplayRefreshFps = false; private pendingDisplayRefreshFps = 0; private pendingDisplayRefreshFrameCount = 0; @@ -35,8 +36,19 @@ export class FramePerformance { return appConfig.telemetry.enabled ? performance.now() - startedAt : 0; } - public update(deltaTime: number): void { - const fps = 1 / Math.max(deltaTime, appConfig.deltaTime.minDeltaTimeSeconds); + public update(time: DOMHighResTimeStamp): void { + const previous = this.previousFrameTime; + this.previousFrameTime = time; + if (previous === null) { + return; + } + + const deltaSeconds = (time - previous) / 1000; + if (deltaSeconds <= 0 || deltaSeconds > FRAME_GAP_RESET_SECONDS) { + return; + } + + const fps = 1 / deltaSeconds; this.latestFps = fps; this.updateDisplayRefreshEstimate(fps); this.smoothedFps = @@ -51,7 +63,6 @@ export class FramePerformance { agentBudgetMax, canvas, devicePixelRatio, - renderSpeed, }: TelemetrySnapshot): void { if (!appConfig.telemetry.enabled) { return; @@ -73,7 +84,6 @@ export class FramePerformance { canvasWidth: canvas.width, canvasHeight: canvas.height, dpr: devicePixelRatio, - renderSpeed, frameCpuMs: now - frameCpuStartedAt, encodeCpuMs, }); diff --git a/src/game-loop/game-loop-ping-pong.test.ts b/src/game-loop/game-loop-ping-pong.test.ts index 921c8e4..04bbd69 100644 --- a/src/game-loop/game-loop-ping-pong.test.ts +++ b/src/game-loop/game-loop-ping-pong.test.ts @@ -10,31 +10,45 @@ const simulationTexturesSource = readFileSync( join(process.cwd(), 'src/game-loop/simulation-textures.ts'), 'utf8' ); +const resizableTextureSource = readFileSync( + join(process.cwd(), 'src/utils/graphics/resizable-texture.ts'), + 'utf8' +); const getRenderStepSource = () => { - const start = simulationFrameSource.indexOf('for (let i = 0; i < renderSpeed; i++)'); + const start = simulationFrameSource.indexOf( + 'const commandEncoder = this.device.createCommandEncoder();' + ); const end = simulationFrameSource.indexOf(' public clearSwipes', start); if (start < 0 || end < 0) { - throw new Error('Could not find the render-speed simulation loop'); + throw new Error('Could not find the simulation frame execution body'); } return simulationFrameSource.slice(start, end); }; describe('GameLoop ping-pong texture flow', () => { - it('copies only the trail map and swaps source/influence references after diffusion', () => { + it('copies only the trail map with a GPU texture copy and swaps source/influence references after diffusion', () => { const renderStepSource = getRenderStepSource(); - expect(renderStepSource.match(/copyPipeline\.execute/g)).toHaveLength(1); - expect(renderStepSource).toMatch( - /this\.pipelines\.copyPipeline\.execute\([\s\S]*this\.textures\.trailMapA\.getTextureView\(\)[\s\S]*this\.textures\.trailMapB\.getTextureView\(\)[\s\S]*\);/ + expect(renderStepSource).not.toContain('copyPipeline.execute'); + expect(renderStepSource).toContain('this.textures.copyTrailMapAToB(commandEncoder);'); + expect(simulationTexturesSource).toMatch( + /commandEncoder\.copyTextureToTexture\([\s\S]*this\.trailMapA\.getTexture\(\)[\s\S]*this\.trailMapB\.getTexture\(\)[\s\S]*width: size\[0\][\s\S]*height: size\[1\][\s\S]*\);/ ); expect(renderStepSource).toMatch( /this\.pipelines\.diffusionPipeline\.execute\([\s\S]*this\.textures\.sourceMapA\.getTextureView\(\)[\s\S]*this\.textures\.sourceMapB\.getTextureView\(\)[\s\S]*\);[\s\S]*this\.pipelines\.brushEffectDiffusionPipeline\.execute\([\s\S]*this\.textures\.influenceMapA\.getTextureView\(\)[\s\S]*this\.textures\.influenceMapB\.getTextureView\(\)[\s\S]*\);[\s\S]*this\.device\.queue\.submit\(\[commandEncoder\.finish\(\)\]\);[\s\S]*this\.textures\.swapSourceMaps\(\);[\s\S]*this\.textures\.swapInfluenceMaps\(\);/ ); }); + it('keeps resizable textures usable for render, shader, and GPU copy paths', () => { + expect(resizableTextureSource).toContain('public getTexture(): GPUTexture'); + expect(resizableTextureSource).toContain('GPUTextureUsage.COPY_SRC'); + expect(resizableTextureSource).toContain('GPUTextureUsage.COPY_DST'); + expect(resizableTextureSource).toContain('this.copyPipeline.execute('); + }); + it('keeps ping-pong texture references mutable and swaps A/B identities', () => { expect(simulationTexturesSource).toContain('public sourceMapA: ResizableTexture;'); expect(simulationTexturesSource).toContain('public sourceMapB: ResizableTexture;'); diff --git a/src/game-loop/game-loop-resources.ts b/src/game-loop/game-loop-resources.ts index f1f9d0a..acf65e6 100644 --- a/src/game-loop/game-loop-resources.ts +++ b/src/game-loop/game-loop-resources.ts @@ -5,7 +5,6 @@ import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/ag import { AgentPipeline } from '../pipelines/agents/agent-pipeline'; import { BrushPipeline } from '../pipelines/brush/brush-pipeline'; import { CommonState } from '../pipelines/common-state/common-state'; -import { CopyPipeline } from '../pipelines/copy/copy-pipeline'; import { DiffusionPipeline } from '../pipelines/diffusion/diffusion-pipeline'; import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline'; import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline'; @@ -13,7 +12,7 @@ import { RenderPipeline } from '../pipelines/render/render-pipeline'; import { settings } from '../settings'; import { initializeContext } from '../utils/graphics/initialize-context'; import { GLOBAL_AGENT_CAP } from './agent-population'; -import { RenderInputs } from './game-loop-types'; +import { CanvasReadbackRequest, RenderInputs } from './game-loop-types'; import { SimulationFrameRenderer } from './simulation-frame'; import { SimulationTextures } from './simulation-textures'; @@ -33,7 +32,6 @@ interface FrameParameters extends RenderInputs { export class GameLoopResources { public readonly textures: SimulationTextures; public readonly commonState: CommonState; - public readonly copyPipeline: CopyPipeline; public readonly agentGenerationPipeline: AgentGenerationPipeline; public readonly agentPipeline: AgentPipeline; public readonly brushPipeline: BrushPipeline; @@ -53,7 +51,6 @@ export class GameLoopResources { const context = initializeContext({ device, canvas }); this.textures = new SimulationTextures(this.device, canvasSize); - this.copyPipeline = new CopyPipeline(this.device); this.commonState = new CommonState(this.device); this.commonState.setParameters({ @@ -88,7 +85,6 @@ export class GameLoopResources { this.renderPipeline = new RenderPipeline(context, this.device, this.commonState); this.frameRenderer = new SimulationFrameRenderer(this.device, this.textures, { - copyPipeline: this.copyPipeline, agentPipeline: this.agentPipeline, brushPipeline: this.brushPipeline, eraserAgentPipeline: this.eraserAgentPipeline, @@ -157,8 +153,11 @@ export class GameLoopResources { this.setBrushEffectDiffusionParameters(); } - public executeFrame(renderSpeed: number, isErasing: boolean): void { - this.frameRenderer.execute(renderSpeed, isErasing); + public executeFrame( + isErasing: boolean, + canvasReadbackRequest?: CanvasReadbackRequest | null + ): void { + this.frameRenderer.execute(isErasing, canvasReadbackRequest); } public clearSwipes(): void { @@ -166,7 +165,6 @@ export class GameLoopResources { } public destroy(): void { - this.copyPipeline.destroy(); this.agentGenerationPipeline.destroy(); this.agentPipeline.destroy(); this.brushPipeline.destroy(); diff --git a/src/game-loop/game-loop-settings.ts b/src/game-loop/game-loop-settings.ts index bd3a756..58ffd55 100644 --- a/src/game-loop/game-loop-settings.ts +++ b/src/game-loop/game-loop-settings.ts @@ -1,7 +1,6 @@ export interface GameLoopSettings { agentBudgetMax: number; agentCount: number; - renderSpeed: number; simulatedDelayMs: number; selectedColorIndex: number; spawnPerPixel: number; diff --git a/src/game-loop/game-loop-types.ts b/src/game-loop/game-loop-types.ts index 9ecb9cf..1b5182b 100644 --- a/src/game-loop/game-loop-types.ts +++ b/src/game-loop/game-loop-types.ts @@ -4,6 +4,7 @@ export interface GardenUi { prompt: HTMLElement; eraserPreview: HTMLElement; exportStatus: HTMLElement; + toolbar: HTMLElement; } export interface RenderInputs { @@ -15,3 +16,8 @@ export interface StrokeSegment { from: vec2; to: vec2; } + +export interface CanvasReadbackRequest { + encode(commandEncoder: GPUCommandEncoder, texture: GPUTexture): void; + afterSubmit(): void; +} diff --git a/src/game-loop/game-loop.ts b/src/game-loop/game-loop.ts index c919112..4b51f10 100644 --- a/src/game-loop/game-loop.ts +++ b/src/game-loop/game-loop.ts @@ -15,6 +15,7 @@ import { GardenUi } from './game-loop-types'; import { IntroPrompt } from './intro-prompt'; import { GardenPointerInput } from './pointer-input'; import { RenderInputCache } from './render-input-cache'; +import { ToolbarContrastMonitor } from './toolbar-contrast-monitor'; export default class GameLoop { private static readonly MAX_MIRROR_SEGMENT_COUNT = @@ -34,6 +35,7 @@ export default class GameLoop { private readonly agentPopulation: AgentPopulation; private readonly export4KRenderer: Export4KRenderer; private readonly framePerformance = new FramePerformance(); + private readonly toolbarContrastMonitor: ToolbarContrastMonitor; private readonly devStatsElement: HTMLDivElement | null; private readonly seed = Math.floor(Math.random() * 0xffffffff).toString(16); private readonly resizeListener = this.resize.bind(this); @@ -56,6 +58,7 @@ export default class GameLoop { this.resources = new GameLoopResources(canvas, device, this.canvasSize); this.introPrompt = new IntroPrompt(ui.prompt); this.eraserPreview = new EraserPreview(canvas, ui.eraserPreview); + this.toolbarContrastMonitor = new ToolbarContrastMonitor(canvas, ui.toolbar, device); this.agentPopulation = new AgentPopulation(this.resources.agentGenerationPipeline); this.agentPopulation.initializeIntroAgents(this.canvasSize); this.pointerInput = new GardenPointerInput({ @@ -85,8 +88,8 @@ export default class GameLoop { getSourceTextureView: () => this.resources.textures.sourceMapA.getTextureView(), getVibeId: () => activeVibe.id, }); - this.keydownListener = (event: KeyboardEvent) => { - this.audio.start(activeVibe, { userGesture: event.isTrusted }); + this.keydownListener = () => { + this.audio.start(activeVibe, { userGesture: true }); this.introPrompt.complete(); }; @@ -151,6 +154,7 @@ export default class GameLoop { window.removeEventListener('resize', this.resizeListener); window.removeEventListener('keydown', this.keydownListener); this.pointerInput.detach(); + this.toolbarContrastMonitor.destroy(); this.devStatsElement?.remove(); this.introPrompt.destroy(); this.resources.destroy(); @@ -165,7 +169,7 @@ export default class GameLoop { const frameCpuStartedAt = this.framePerformance.markCpuStart(); const deltaTime = this.deltaTimeCalculator.calculateDeltaTimeInSeconds(time); - this.framePerformance.update(deltaTime); + this.framePerformance.update(time); this.agentPopulation.growBudget( deltaTime, this.framePerformance.smoothedFps, @@ -175,7 +179,6 @@ export default class GameLoop { this.resize(); this.resizeSimulationToCanvas(); - const scaledTime = time * settings.renderSpeed; const { channelColors, backgroundColor } = this.renderInputs.get(); const introProgress = this.introPrompt.progress; const cameraZoom = 1; @@ -195,7 +198,7 @@ export default class GameLoop { }); this.resources.setFrameParameters({ - time: scaledTime, + time, deltaTime, canvasSize: this.canvasSize, activeAgentCount: this.agentPopulation.activeAgentCount, @@ -210,7 +213,10 @@ export default class GameLoop { }); const encodeCpuStartedAt = this.framePerformance.markCpuStart(); - this.resources.executeFrame(settings.renderSpeed, isErasing); + this.resources.executeFrame( + isErasing, + this.toolbarContrastMonitor.takeReadbackRequest(time) + ); const encodeCpuMs = this.framePerformance.measureSince(encodeCpuStartedAt); this.pointerInput.clearSwipesIfIdle(); @@ -223,7 +229,6 @@ export default class GameLoop { agentBudgetMax: settings.agentBudgetMax, canvas: this.canvas, devicePixelRatio: this.devicePixelRatio, - renderSpeed: settings.renderSpeed, }); this.updateDevStats(time); diff --git a/src/game-loop/intro-title-agents.ts b/src/game-loop/intro-title-agents.ts index 735393c..a22fc0f 100644 --- a/src/game-loop/intro-title-agents.ts +++ b/src/game-loop/intro-title-agents.ts @@ -154,7 +154,7 @@ const createIntroTitlePoints = ( const fontSize = getIntroTitleFontSize(context, width, height); context.clearRect(0, 0, width, height); - context.font = `${fontSize}px Comfortaa, "Open Sans", sans-serif`; + context.font = `${fontSize}px "Open Sans", sans-serif`; context.textAlign = 'center'; context.textBaseline = 'middle'; context.fillStyle = '#fff'; @@ -302,7 +302,7 @@ const getIntroTitleFontSize = ( ); while (fontSize > appConfig.simulation.intro.minFontSizePx) { - context.font = `${fontSize}px Comfortaa, "Open Sans", sans-serif`; + context.font = `${fontSize}px "Open Sans", sans-serif`; const metrics = context.measureText(INTRO_TITLE); const measuredHeight = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent || fontSize; diff --git a/src/game-loop/pointer-input.ts b/src/game-loop/pointer-input.ts index f7c873c..8cee375 100644 --- a/src/game-loop/pointer-input.ts +++ b/src/game-loop/pointer-input.ts @@ -1,7 +1,6 @@ import { vec2 } from 'gl-matrix'; import { GardenAudio } from '../audio/garden-audio'; -import { gardenAudioConfig } from '../audio/garden-audio-config'; import { appConfig } from '../config'; import { BrushPipeline } from '../pipelines/brush/brush-pipeline'; import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline'; @@ -111,7 +110,9 @@ export class GardenPointerInput { } const position = this.getCanvasPointerPosition(event); - this.options.audio.start(activeVibe, { userGesture: event.isTrusted }); + if (event.pointerType !== 'touch') { + this.options.audio.start(activeVibe, { userGesture: true }); + } this.options.audio.beginGesture(); this.options.audio.touchDown({ vibe: activeVibe, @@ -120,7 +121,6 @@ export class GardenPointerInput { canvasSize: this.options.getCanvasSize(), mirrorSegmentCount: this.options.getMirrorSegmentCount(), pressure: this.getPointerPressure(event), - pointerType: event.pointerType, }); this.options.onStartDrawing(); this.activePointerId = event.pointerId; @@ -149,6 +149,7 @@ export class GardenPointerInput { if (event.pointerId !== this.activePointerId) { return; } + this.options.audio.start(activeVibe, { userGesture: true }); this.addSwipeAt(event, { emitAudio: false }); this.finishSmoothedStroke(); this.options.audio.endGesture(); @@ -221,7 +222,6 @@ export class GardenPointerInput { elapsedSeconds, eraserSizePixels: settings.eraserSize * devicePixelRatio, mirrorSegmentCount: this.options.getMirrorSegmentCount(), - pointerType: event.pointerType, }); } this.lastPointerPosition = position; @@ -369,9 +369,7 @@ export class GardenPointerInput { return Math.min(1, Math.max(0, event.pressure)); } - return event.buttons > 0 || event.type === 'pointerdown' - ? gardenAudioConfig.input.pressureFallback - : 0; + return 0; } } diff --git a/src/game-loop/simulation-frame.ts b/src/game-loop/simulation-frame.ts index 43ad661..25e97c4 100644 --- a/src/game-loop/simulation-frame.ts +++ b/src/game-loop/simulation-frame.ts @@ -1,14 +1,13 @@ import { AgentPipeline } from '../pipelines/agents/agent-pipeline'; import { BrushPipeline } from '../pipelines/brush/brush-pipeline'; -import { CopyPipeline } from '../pipelines/copy/copy-pipeline'; import { DiffusionPipeline } from '../pipelines/diffusion/diffusion-pipeline'; import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline'; import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline'; import { RenderPipeline } from '../pipelines/render/render-pipeline'; +import { CanvasReadbackRequest } from './game-loop-types'; import { SimulationTextures } from './simulation-textures'; interface SimulationFramePipelines { - copyPipeline: CopyPipeline; agentPipeline: AgentPipeline; brushPipeline: BrushPipeline; eraserAgentPipeline: EraserAgentPipeline; @@ -25,70 +24,64 @@ export class SimulationFrameRenderer { private readonly pipelines: SimulationFramePipelines ) {} - public execute(renderSpeed: number, isErasing: boolean): void { - for (let i = 0; i < renderSpeed; i++) { - const commandEncoder = this.device.createCommandEncoder(); + public execute( + isErasing: boolean, + canvasReadbackRequest?: CanvasReadbackRequest | null + ): void { + const commandEncoder = this.device.createCommandEncoder(); - this.pipelines.copyPipeline.execute( - commandEncoder, - this.textures.trailMapA.getTextureView(), - this.textures.trailMapB.getTextureView() - ); - if (isErasing) { - this.pipelines.eraserTexturePipeline.execute( - commandEncoder, - this.textures.sourceMapA.getTextureView() - ); - this.pipelines.eraserTexturePipeline.execute( - commandEncoder, - this.textures.influenceMapA.getTextureView() - ); - this.pipelines.eraserTexturePipeline.execute( + this.textures.copyTrailMapAToB(commandEncoder); + if (isErasing) { + if (this.pipelines.eraserAgentPipeline.hasActiveMask()) { + const eraserMask = this.textures.clearEraserMask(commandEncoder); + this.pipelines.eraserTexturePipeline.execute(commandEncoder, eraserMask); + this.pipelines.eraserTexturePipeline.executeMultiTarget( commandEncoder, + this.textures.sourceMapA.getTextureView(), + this.textures.influenceMapA.getTextureView(), this.textures.trailMapB.getTextureView() ); - this.pipelines.eraserAgentPipeline.execute(commandEncoder); - } else { - this.pipelines.brushPipeline.execute( - commandEncoder, - this.textures.sourceMapA.getTextureView() - ); - this.pipelines.brushPipeline.execute( - commandEncoder, - this.textures.influenceMapA.getTextureView() - ); + this.pipelines.eraserAgentPipeline.execute(commandEncoder, eraserMask); } - this.pipelines.agentPipeline.execute( - commandEncoder, - this.textures.trailMapA.getTextureView(), - this.textures.trailMapB.getTextureView(), - this.textures.influenceMapA.getTextureView() - ); - this.pipelines.diffusionPipeline.execute( - commandEncoder, - this.textures.trailMapB.getTextureView(), - this.textures.trailMapA.getTextureView() - ); - this.pipelines.renderPipeline.execute( - commandEncoder, - this.textures.trailMapA.getTextureView(), - this.textures.sourceMapA.getTextureView() - ); - this.pipelines.diffusionPipeline.execute( + } else { + this.pipelines.brushPipeline.executeMultiTarget( commandEncoder, this.textures.sourceMapA.getTextureView(), - this.textures.sourceMapB.getTextureView() + this.textures.influenceMapA.getTextureView() ); - this.pipelines.brushEffectDiffusionPipeline.execute( - commandEncoder, - this.textures.influenceMapA.getTextureView(), - this.textures.influenceMapB.getTextureView() - ); - - this.device.queue.submit([commandEncoder.finish()]); - this.textures.swapSourceMaps(); - this.textures.swapInfluenceMaps(); } + this.pipelines.agentPipeline.execute( + commandEncoder, + this.textures.trailMapA.getTextureView(), + this.textures.trailMapB.getTextureView(), + this.textures.influenceMapA.getTextureView() + ); + this.pipelines.diffusionPipeline.execute( + commandEncoder, + this.textures.trailMapB.getTextureView(), + this.textures.trailMapA.getTextureView() + ); + const canvasTexture = this.pipelines.renderPipeline.execute( + commandEncoder, + this.textures.trailMapA.getTextureView(), + this.textures.sourceMapA.getTextureView() + ); + canvasReadbackRequest?.encode(commandEncoder, canvasTexture); + this.pipelines.diffusionPipeline.execute( + commandEncoder, + this.textures.sourceMapA.getTextureView(), + this.textures.sourceMapB.getTextureView() + ); + this.pipelines.brushEffectDiffusionPipeline.execute( + commandEncoder, + this.textures.influenceMapA.getTextureView(), + this.textures.influenceMapB.getTextureView() + ); + + this.device.queue.submit([commandEncoder.finish()]); + canvasReadbackRequest?.afterSubmit(); + this.textures.swapSourceMaps(); + this.textures.swapInfluenceMaps(); } public clearSwipes(): void { diff --git a/src/game-loop/simulation-textures.ts b/src/game-loop/simulation-textures.ts index 9309411..1301858 100644 --- a/src/game-loop/simulation-textures.ts +++ b/src/game-loop/simulation-textures.ts @@ -9,6 +9,7 @@ export class SimulationTextures { public sourceMapB: ResizableTexture; public influenceMapA: ResizableTexture; public influenceMapB: ResizableTexture; + public eraserMask: ResizableTexture; public constructor( private readonly device: GPUDevice, @@ -20,6 +21,7 @@ export class SimulationTextures { this.sourceMapB = new ResizableTexture(this.device, canvasSize); this.influenceMapA = new ResizableTexture(this.device, canvasSize); this.influenceMapB = new ResizableTexture(this.device, canvasSize); + this.eraserMask = new ResizableTexture(this.device, canvasSize); } public resizeTo(nextSize: vec2): vec2 | null { @@ -35,10 +37,38 @@ export class SimulationTextures { this.sourceMapB.resize(nextSize); this.influenceMapA.resize(nextSize); this.influenceMapB.resize(nextSize); + this.eraserMask.resize(nextSize); return scale; } + public clearEraserMask(commandEncoder: GPUCommandEncoder): GPUTextureView { + const eraserMaskView = this.eraserMask.getTextureView(); + const passEncoder = commandEncoder.beginRenderPass({ + colorAttachments: [ + { + view: eraserMaskView, + clearValue: { r: 1, g: 1, b: 1, a: 1 }, + loadOp: 'clear', + storeOp: 'store', + }, + ], + }); + passEncoder.end(); + + return eraserMaskView; + } + + public copyTrailMapAToB(commandEncoder: GPUCommandEncoder): void { + const size = this.trailMapA.getSize(); + + commandEncoder.copyTextureToTexture( + { texture: this.trailMapA.getTexture() }, + { texture: this.trailMapB.getTexture() }, + { width: size[0], height: size[1] } + ); + } + public swapSourceMaps(): void { [this.sourceMapA, this.sourceMapB] = [this.sourceMapB, this.sourceMapA]; } @@ -54,5 +84,6 @@ export class SimulationTextures { this.sourceMapB.destroy(); this.influenceMapA.destroy(); this.influenceMapB.destroy(); + this.eraserMask.destroy(); } } diff --git a/src/game-loop/toolbar-contrast-monitor.test.ts b/src/game-loop/toolbar-contrast-monitor.test.ts new file mode 100644 index 0000000..25b13d8 --- /dev/null +++ b/src/game-loop/toolbar-contrast-monitor.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; + +import { + getToolbarContrastMetrics, + shouldDimToolbarBackground, +} from './toolbar-contrast-monitor'; + +const makePixels = ( + samples: ReadonlyArray +): Uint8Array => { + const pixels = new Uint8Array(samples.length * 4); + samples.forEach(([red, green, blue], index) => { + const offset = index * 4; + pixels[offset] = red; + pixels[offset + 1] = green; + pixels[offset + 2] = blue; + pixels[offset + 3] = 255; + }); + return pixels; +}; + +describe('toolbar contrast monitoring', () => { + it('leaves the toolbar transparent over dark canvas samples', () => { + const metrics = getToolbarContrastMetrics( + makePixels(Array.from({ length: 91 }, () => [8, 12, 18])), + 91, + false + ); + + expect(metrics.dimmingStrength).toBe(0); + expect(metrics.lowContrastRatio).toBe(0); + expect(shouldDimToolbarBackground(metrics, false)).toBe(false); + }); + + it('dims the toolbar when enough samples have poor contrast with white controls', () => { + const darkSamples = Array.from({ length: 82 }, () => [8, 12, 18] as const); + const brightSamples = Array.from({ length: 9 }, () => [245, 240, 218] as const); + const metrics = getToolbarContrastMetrics( + makePixels([...darkSamples, ...brightSamples]), + 91, + false + ); + + expect(metrics.lowContrastRatio).toBeGreaterThanOrEqual(0.08); + expect(shouldDimToolbarBackground(metrics, false)).toBe(true); + }); + + it('keeps the dimmed state until contrast has clearly recovered', () => { + const metrics = getToolbarContrastMetrics( + makePixels([ + ...Array.from({ length: 86 }, () => [8, 12, 18] as const), + ...Array.from({ length: 5 }, () => [245, 240, 218] as const), + ]), + 91, + false + ); + + expect(shouldDimToolbarBackground(metrics, false)).toBe(false); + expect(shouldDimToolbarBackground(metrics, true)).toBe(true); + }); + + it('reads bgra canvas samples in the correct channel order', () => { + const bgraPixels = new Uint8Array([0, 0, 255, 255]); + const metrics = getToolbarContrastMetrics(bgraPixels, 1, true); + + expect(metrics.averageLuminance).toBeCloseTo(0.2126); + }); +}); diff --git a/src/game-loop/toolbar-contrast-monitor.ts b/src/game-loop/toolbar-contrast-monitor.ts new file mode 100644 index 0000000..974a0f5 --- /dev/null +++ b/src/game-loop/toolbar-contrast-monitor.ts @@ -0,0 +1,284 @@ +import type { CanvasReadbackRequest } from './game-loop-types'; + +interface CanvasSamplePoint { + x: number; + y: number; +} + +interface ToolbarContrastMetrics { + averageLuminance: number; + brightRatio: number; + dimmingStrength: number; + lowContrastRatio: number; +} + +const BYTES_PER_SAMPLE = 4; +const SAMPLE_COLUMNS = 13; +const SAMPLE_ROWS = 7; +const SAMPLE_INTERVAL_MS = 300; +const LOW_CONTRAST_RATIO_TO_DIM = 0.08; +const LOW_CONTRAST_RATIO_TO_CLEAR = 0.04; +const DIMMING_STRENGTH_TO_DIM = 0.18; +const DIMMING_STRENGTH_TO_CLEAR = 0.1; + +const clamp01 = (value: number): number => Math.min(1, Math.max(0, value)); + +const getLinearChannel = (channel: number): number => { + const normalized = channel / 255; + return normalized <= 0.03928 + ? normalized / 12.92 + : ((normalized + 0.055) / 1.055) ** 2.4; +}; + +const getRelativeLuminance = (red: number, green: number, blue: number): number => + 0.2126 * getLinearChannel(red) + + 0.7152 * getLinearChannel(green) + + 0.0722 * getLinearChannel(blue); + +export const getToolbarContrastMetrics = ( + pixels: Uint8Array, + sampleCount: number, + isBgra: boolean +): ToolbarContrastMetrics => { + const count = Math.max(0, Math.min(sampleCount, Math.floor(pixels.length / 4))); + if (count === 0) { + return { + averageLuminance: 0, + brightRatio: 0, + dimmingStrength: 0, + lowContrastRatio: 0, + }; + } + + let luminanceTotal = 0; + let brightCount = 0; + let lowContrastCount = 0; + + for (let i = 0; i < count; i++) { + const offset = i * BYTES_PER_SAMPLE; + const red = pixels[offset + (isBgra ? 2 : 0)]; + const green = pixels[offset + 1]; + const blue = pixels[offset + (isBgra ? 0 : 2)]; + const luminance = getRelativeLuminance(red, green, blue); + const contrastWithWhite = 1.05 / (luminance + 0.05); + + luminanceTotal += luminance; + if (luminance > 0.32) { + brightCount++; + } + if (contrastWithWhite < 3) { + lowContrastCount++; + } + } + + const averageLuminance = luminanceTotal / count; + const brightRatio = brightCount / count; + const lowContrastRatio = lowContrastCount / count; + const dimmingStrength = clamp01( + Math.max(0, averageLuminance - 0.11) / 0.28 + + brightRatio * 0.65 + + lowContrastRatio * 1.8 + ); + + return { + averageLuminance, + brightRatio, + dimmingStrength, + lowContrastRatio, + }; +}; + +export const shouldDimToolbarBackground = ( + metrics: ToolbarContrastMetrics, + wasDimmed: boolean +): boolean => + wasDimmed + ? metrics.dimmingStrength > DIMMING_STRENGTH_TO_CLEAR || + metrics.lowContrastRatio > LOW_CONTRAST_RATIO_TO_CLEAR + : metrics.dimmingStrength > DIMMING_STRENGTH_TO_DIM || + metrics.lowContrastRatio >= LOW_CONTRAST_RATIO_TO_DIM; + +export class ToolbarContrastMonitor { + private readonly isBgra: boolean; + private isDestroyed = false; + private isDimmed = false; + private isReadbackPending = false; + private lastSampleAt = Number.NEGATIVE_INFINITY; + + public constructor( + private readonly canvas: HTMLCanvasElement, + private readonly toolbar: HTMLElement, + private readonly device: GPUDevice + ) { + this.isBgra = navigator.gpu?.getPreferredCanvasFormat() === 'bgra8unorm'; + } + + public takeReadbackRequest(time: DOMHighResTimeStamp): CanvasReadbackRequest | null { + if ( + this.isDestroyed || + this.isReadbackPending || + time - this.lastSampleAt < SAMPLE_INTERVAL_MS + ) { + return null; + } + + const samplePoints = this.getSamplePoints(); + if (samplePoints.length === 0) { + return null; + } + + let buffer: GPUBuffer; + try { + buffer = this.device.createBuffer({ + size: samplePoints.length * BYTES_PER_SAMPLE, + usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ, + }); + } catch { + return null; + } + + this.isReadbackPending = true; + this.lastSampleAt = time; + + let isBufferDestroyed = false; + let isCancelled = false; + let isEncoded = false; + const destroyBuffer = () => { + if (isBufferDestroyed) { + return; + } + + isBufferDestroyed = true; + buffer.destroy(); + }; + const cancel = (destroyNow = true) => { + if (isCancelled) { + return; + } + + isCancelled = true; + this.isReadbackPending = false; + if (destroyNow) { + destroyBuffer(); + } + }; + + return { + encode: (commandEncoder, texture) => { + if (isCancelled) { + return; + } + + try { + samplePoints.forEach((point, index) => { + commandEncoder.copyTextureToBuffer( + { + origin: point, + texture, + }, + { + buffer, + offset: index * BYTES_PER_SAMPLE, + }, + { + depthOrArrayLayers: 1, + height: 1, + width: 1, + } + ); + }); + isEncoded = true; + } catch { + cancel(false); + } + }, + afterSubmit: () => { + if (isCancelled) { + destroyBuffer(); + return; + } + + if (!isEncoded) { + cancel(); + return; + } + + void this.readBuffer(buffer, samplePoints.length); + }, + }; + } + + public destroy(): void { + this.isDestroyed = true; + this.toolbar.classList.remove('needs-contrast-background'); + } + + private getSamplePoints(): Array { + const canvasRect = this.canvas.getBoundingClientRect(); + const toolbarRect = this.toolbar.getBoundingClientRect(); + if ( + canvasRect.width <= 0 || + canvasRect.height <= 0 || + toolbarRect.width <= 0 || + toolbarRect.height <= 0 + ) { + return []; + } + + const left = Math.max(canvasRect.left, toolbarRect.left); + const right = Math.min(canvasRect.right, toolbarRect.right); + const top = Math.max(canvasRect.top, toolbarRect.top); + const bottom = Math.min(canvasRect.bottom, toolbarRect.bottom); + if (left >= right || top >= bottom) { + return []; + } + + const xScale = this.canvas.width / canvasRect.width; + const yScale = this.canvas.height / canvasRect.height; + const width = right - left; + const height = bottom - top; + const points = new Map(); + + for (let row = 0; row < SAMPLE_ROWS; row++) { + const cssY = top + ((row + 0.5) / SAMPLE_ROWS) * height; + const y = Math.min( + this.canvas.height - 1, + Math.max(0, Math.floor((cssY - canvasRect.top) * yScale)) + ); + + for (let column = 0; column < SAMPLE_COLUMNS; column++) { + const cssX = left + ((column + 0.5) / SAMPLE_COLUMNS) * width; + const x = Math.min( + this.canvas.width - 1, + Math.max(0, Math.floor((cssX - canvasRect.left) * xScale)) + ); + points.set(`${x}:${y}`, { x, y }); + } + } + + return [...points.values()]; + } + + private async readBuffer(buffer: GPUBuffer, sampleCount: number): Promise { + let isMapped = false; + try { + await buffer.mapAsync(GPUMapMode.READ); + isMapped = true; + + if (!this.isDestroyed) { + const pixels = new Uint8Array(buffer.getMappedRange()); + const metrics = getToolbarContrastMetrics(pixels, sampleCount, this.isBgra); + this.isDimmed = shouldDimToolbarBackground(metrics, this.isDimmed); + this.toolbar.classList.toggle('needs-contrast-background', this.isDimmed); + } + } catch { + // Readback is an enhancement; leave rendering alone if the GPU rejects it. + } finally { + if (isMapped) { + buffer.unmap(); + } + buffer.destroy(); + this.isReadbackPending = false; + } + } +} diff --git a/src/index.ts b/src/index.ts index c191b60..f1b5164 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,13 @@ import GameLoop from './game-loop/game-loop'; import './index.scss'; +import { + initAnalytics, + trackExport, + trackSettingsOpen, + trackVibeChange, +} from './analytics'; +import { preloadPianoSamples } from './audio/piano-samples'; import { appConfig } from './config'; import { CollapsiblePanelAnimator } from './page/collapsible-panel-animator'; import { ConfigPane } from './page/config-pane'; @@ -11,7 +18,12 @@ import { activeVibe, applyVibeSettings, resetSettings, settings } from './settin import { readBrowserStorage, writeBrowserStorage } from './utils/browser-storage'; import { DeltaTimeCalculator } from './utils/delta-time-calculator'; import { queryRequiredElement, queryRequiredElements } from './utils/dom'; -import { ErrorHandler, Severity } from './utils/error-handler'; +import { + ErrorHandler, + getErrorMessage, + RuntimeError, + Severity, +} from './utils/error-handler'; import { initializeGpu } from './utils/graphics/initialize-gpu'; import { VIBE_PRESETS } from './vibes'; @@ -47,10 +59,11 @@ const formatMirrorSegmentCount = (count: number): string => ? 'Mirror off' : `${count} ${mirrorSegmentNames[count] ?? 'slices'}`; -const renderRuntimeMessage = ( - container: HTMLElement, - error: Parameters[0]>[0] -) => { +type RuntimeUiError = Parameters< + Parameters[0] +>[0]; + +const renderRuntimeMessage = (container: HTMLElement, error: RuntimeUiError) => { const message = document.createElement('pre'); message.className = error.severity; message.textContent = error.code ? `${error.message}\n${error.code}` : error.message; @@ -67,8 +80,31 @@ const renderRuntimeMessage = ( } }; -const elements = { +const getRuntimeUiError = (exception: unknown): RuntimeUiError => ({ + severity: Severity.ERROR, + message: getErrorMessage(exception), + ...(exception instanceof RuntimeError ? { code: exception.code } : {}), +}); + +const renderStartupException = (exception: unknown) => { + const existingContainer = document.querySelector('.errors-container'); + const container = + existingContainer instanceof HTMLElement + ? existingContainer + : document.createElement('div'); + + if (!(existingContainer instanceof HTMLElement)) { + container.className = 'errors-container'; + document.body.append(container); + } + + container.setAttribute('aria-live', 'assertive'); + renderRuntimeMessage(container, getRuntimeUiError(exception)); +}; + +const queryAppElements = () => ({ aside: queryRequiredElement('aside', HTMLElement), + toolbarRow: queryRequiredElement('.toolbar-row', HTMLElement), infoButton: queryRequiredElement('button.info', HTMLButtonElement), infoElement: queryRequiredElement('.info-page', HTMLElement), minimizeFullScreenButton: queryRequiredElement( @@ -98,7 +134,11 @@ const elements = { loadingIndicator: queryRequiredElement('.loading-indicator', HTMLDivElement), loadingStatus: queryRequiredElement('.loading-status', HTMLDivElement), loadingProgress: queryRequiredElement('.loading-progress', HTMLDivElement), -}; +}); + +type AppElements = ReturnType; + +let elements: AppElements; const setLoadingStage = (label: string, ratio: number) => { const percent = Math.round(Math.max(0, Math.min(1, ratio)) * 100); @@ -188,10 +228,15 @@ const renderMirrorSegmentUi = () => { }; const main = async () => { + let hasRuntimeErrorListener = false; try { + initAnalytics(); + let shouldStop = false; let game: GameLoop | null = null; + let wasConfigPaneOpen = false; + elements = queryAppElements(); elements.errorContainer.setAttribute('aria-live', 'assertive'); ErrorHandler.addOnErrorListener((error, _metadata) => { renderRuntimeMessage(elements.errorContainer, error); @@ -201,6 +246,7 @@ const main = async () => { shouldStop = true; } }); + hasRuntimeErrorListener = true; const syncRuntimeUi = () => { renderEraserSizeUi(game); @@ -216,7 +262,13 @@ const main = async () => { const configPane = new ConfigPane({ settingsButton: elements.settingsButton, onConfigChange: syncRuntimeUi, - onOpenChange: (isOpen) => game?.setStatsOverlayPinned(isOpen), + onOpenChange: (isOpen) => { + game?.setStatsOverlayPinned(isOpen); + if (isOpen && !wasConfigPaneOpen) { + trackSettingsOpen(); + } + wasConfigPaneOpen = isOpen; + }, onRuntimeChange: syncRuntimeUi, onRuntimeReset: () => { resetSettings(); @@ -224,7 +276,12 @@ const main = async () => { }, onRestart: () => game?.destroy(), onVibeChange: (vibeId) => { - applyVibeSettings(vibeId); + const vibe = applyVibeSettings(vibeId); + trackVibeChange({ + vibeId: vibe.id, + vibeName: vibe.name, + source: 'settings', + }); syncRuntimeUi(); game?.playVibeChangeAudio(false); }, @@ -244,33 +301,65 @@ const main = async () => { document.body ); + const startAudioFromUserGesture = (event: Event) => { + if ( + isAudioMuted || + (event.target instanceof Node && elements.soundButton.contains(event.target)) + ) { + return; + } + + game?.startAudio(true); + }; + + window.addEventListener('touchend', startAudioFromUserGesture, { + capture: true, + passive: true, + }); + window.addEventListener('pointerup', startAudioFromUserGesture, { + capture: true, + passive: true, + }); + window.addEventListener('click', startAudioFromUserGesture, { capture: true }); + window.addEventListener('keydown', startAudioFromUserGesture, { capture: true }); + elements.restartButton.addEventListener('click', () => game?.destroy()); - elements.soundButton.addEventListener('click', (event) => { + elements.soundButton.addEventListener('click', () => { isAudioMuted = !isAudioMuted; writeBrowserStorage(appConfig.storage.audioMutedKey, isAudioMuted ? '1' : '0'); renderAudioUi(game); if (!isAudioMuted) { - game?.startAudio(event.isTrusted); + game?.startAudio(true); } }); - elements.previousVibe.addEventListener('click', (event) => { + elements.previousVibe.addEventListener('click', () => { const current = VIBE_PRESETS.findIndex((vibe) => vibe.id === activeVibe.id); const vibe = VIBE_PRESETS[(current + VIBE_PRESETS.length - 1) % VIBE_PRESETS.length]; - applyVibeSettings(vibe.id); + const activePreset = applyVibeSettings(vibe.id); + trackVibeChange({ + vibeId: activePreset.id, + vibeName: activePreset.name, + source: 'previous-button', + }); configPane.refresh(); syncRuntimeUi(); - game?.playVibeChangeAudio(event.isTrusted); + game?.playVibeChangeAudio(true); }); - elements.nextVibe.addEventListener('click', (event) => { + elements.nextVibe.addEventListener('click', () => { const current = VIBE_PRESETS.findIndex((vibe) => vibe.id === activeVibe.id); const vibe = VIBE_PRESETS[(current + 1) % VIBE_PRESETS.length]; - applyVibeSettings(vibe.id); + const activePreset = applyVibeSettings(vibe.id); + trackVibeChange({ + vibeId: activePreset.id, + vibeName: activePreset.name, + source: 'next-button', + }); configPane.refresh(); syncRuntimeUi(); - game?.playVibeChangeAudio(event.isTrusted); + game?.playVibeChangeAudio(true); }); elements.swatches.forEach((swatch, index) => { @@ -318,6 +407,7 @@ const main = async () => { elements.export4k.disabled = true; try { await game.export4K(); + trackExport({ vibeId: activeVibe.id }); } catch (error) { ErrorHandler.addException(error, { severity: Severity.WARNING }); } finally { @@ -330,18 +420,32 @@ const main = async () => { renderMirrorSegmentUi(); renderAudioUi(game); - const fontsReady = document.fonts.ready.catch(() => undefined); + const fontsReady = document.fonts.ready.catch((error) => { + ErrorHandler.addException(error, { + fallbackMessage: 'Could not load fonts.', + severity: Severity.WARNING, + }); + }); setLoadingStage('Connecting to GPU…', 0.1); const gpu = await initializeGpu(); - setLoadingStage('Loading fonts…', 0.4); + setLoadingStage('Loading fonts…', 0.3); await fontsReady; - setLoadingStage('Compiling shaders…', 0.7); + setLoadingStage('Loading piano samples…', 0.45); + await preloadPianoSamples(({ loadedCount, totalCount }) => { + const sampleRatio = totalCount > 0 ? loadedCount / totalCount : 1; + setLoadingStage( + `Loading piano samples ${loadedCount}/${totalCount}…`, + 0.45 + sampleRatio * 0.3 + ); + }); + setLoadingStage('Compiling shaders…', 0.8); const deltaTimeCalculator = new DeltaTimeCalculator(); let isFirstStart = true; while (!shouldStop) { game = new GameLoop(elements.canvas, gpu, deltaTimeCalculator, { + toolbar: elements.toolbarRow, prompt: elements.prompt, eraserPreview: elements.eraserPreview, exportStatus: elements.exportStatus, @@ -364,7 +468,12 @@ const main = async () => { } } catch (e) { document.body.classList.remove('is-loading'); - ErrorHandler.addException(e); + if (hasRuntimeErrorListener) { + ErrorHandler.addException(e); + } else { + renderStartupException(e); + ErrorHandler.addException(e); + } console.error(e); } }; diff --git a/src/pipelines/agents/agent-generation/agent-compaction.wgsl b/src/pipelines/agents/agent-generation/agent-compaction.wgsl index 6be9e0e..19a73bf 100644 --- a/src/pipelines/agents/agent-generation/agent-compaction.wgsl +++ b/src/pipelines/agents/agent-generation/agent-compaction.wgsl @@ -15,22 +15,70 @@ struct Counters { @group(1) @binding(2) var counters: Counters; @group(1) @binding(3) var compactedAgents: array; +var workgroupAliveCount: atomic; +var workgroupCompactedOffset: u32; +var workgroupCopyCount: u32; + @compute @workgroup_size(64) fn main( @builtin(global_invocation_id) global_id: vec3, + @builtin(local_invocation_id) local_id: vec3, @builtin(num_workgroups) workgroup_count: vec3 ) { let id = get_id(global_id, workgroup_count); - if id >= settings.agentCount { - return; + if local_id.x == 0u { + atomicStore(&workgroupAliveCount, 0u); } - let agent = agents[id]; - if agent.colorIndex < 0.0 { - return; + workgroupBarrier(); + + var localCompactedIndex = 0u; + if id < settings.agentCount { + let agent = agents[id]; + if agent.colorIndex >= 0.0 { + localCompactedIndex = atomicAdd(&workgroupAliveCount, 1u); + } } - let compactedIndex = atomicAdd(&counters.aliveAgentCount, 1); - compactedAgents[compactedIndex] = agent; + workgroupBarrier(); + + if local_id.x == 0u { + let groupAliveCount = atomicLoad(&workgroupAliveCount); + if groupAliveCount > 0u { + workgroupCompactedOffset = atomicAdd(&counters.aliveAgentCount, groupAliveCount); + } else { + workgroupCompactedOffset = 0u; + } + } + + workgroupBarrier(); + + if id < settings.agentCount { + let agent = agents[id]; + if agent.colorIndex >= 0.0 { + compactedAgents[workgroupCompactedOffset + localCompactedIndex] = agent; + } + } +} + +@compute @workgroup_size(64) +fn copyCompactedAgents( + @builtin(global_invocation_id) global_id: vec3, + @builtin(local_invocation_id) local_id: vec3, + @builtin(num_workgroups) workgroup_count: vec3 +) { + let id = get_id(global_id, workgroup_count); + + if local_id.x == 0u { + workgroupCopyCount = atomicLoad(&counters.aliveAgentCount); + } + + workgroupBarrier(); + + if id >= workgroupCopyCount { + return; + } + + agents[id] = compactedAgents[id]; } diff --git a/src/pipelines/agents/agent-generation/agent-generation-pipeline.test.ts b/src/pipelines/agents/agent-generation/agent-generation-pipeline.test.ts new file mode 100644 index 0000000..2fde4c8 --- /dev/null +++ b/src/pipelines/agents/agent-generation/agent-generation-pipeline.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { CommonState } from '../../common-state/common-state'; +import { AGENT_SIZE_IN_BYTES } from './agent'; +import { AgentGenerationPipeline } from './agent-generation-pipeline'; + +const installGpuConstants = () => { + Object.defineProperties(globalThis, { + GPUBufferUsage: { + configurable: true, + value: { + MAP_READ: 1, + COPY_DST: 2, + COPY_SRC: 4, + STORAGE: 8, + UNIFORM: 16, + }, + }, + GPUMapMode: { + configurable: true, + value: { + READ: 1, + }, + }, + GPUShaderStage: { + configurable: true, + value: { + COMPUTE: 1, + }, + }, + }); +}; + +type CopyCall = { + source: GPUBuffer; + sourceOffset: number; + destination: GPUBuffer; + destinationOffset: number; + size: number; +}; + +type DispatchCall = { + entryPoint: string; + workgroups: [number, number, number]; +}; + +type FakePipeline = { + entryPoint: string; +}; + +class FakeBuffer { + private readonly mappedRange: ArrayBuffer; + + public readonly destroy = vi.fn(); + public readonly mapAsync = vi.fn(async (_mode: number) => undefined); + public readonly getMappedRange = vi.fn(() => this.mappedRange); + public readonly unmap = vi.fn(); + + public constructor( + public readonly label: string, + size: number, + mappedValue = 0 + ) { + this.mappedRange = new ArrayBuffer(Math.max(size, Uint32Array.BYTES_PER_ELEMENT)); + new Uint32Array(this.mappedRange)[0] = mappedValue; + } +} + +class FakeComputePass { + private pipeline: FakePipeline | null = null; + + public readonly setPipeline = vi.fn((pipeline: GPUComputePipeline) => { + this.pipeline = pipeline as unknown as FakePipeline; + }); + public readonly setBindGroup = vi.fn( + (_index: number, _bindGroup: GPUBindGroup) => undefined + ); + public readonly dispatchWorkgroups = vi.fn((x: number, y = 1, z = 1) => { + this.device.dispatchCalls.push({ + entryPoint: this.pipeline?.entryPoint ?? 'unset', + workgroups: [x, y, z], + }); + }); + public readonly end = vi.fn(); + + public constructor(private readonly device: FakeDevice) {} +} + +class FakeCommandEncoder { + public readonly beginComputePass = vi.fn(() => new FakeComputePass(this.device)); + public readonly copyBufferToBuffer = vi.fn( + ( + source: GPUBuffer, + sourceOffset: number, + destination: GPUBuffer, + destinationOffset: number, + size: number + ) => { + this.device.copyCalls.push({ + source, + sourceOffset, + destination, + destinationOffset, + size, + }); + } + ); + public readonly finish = vi.fn(() => ({}) as GPUCommandBuffer); + + public constructor(private readonly device: FakeDevice) {} +} + +class FakeQueue { + public readonly writeBuffer = vi.fn( + (_buffer: GPUBuffer, _offset: number, _data: BufferSource) => undefined + ); + public readonly submit = vi.fn( + (_commandBuffers: Iterable) => undefined + ); +} + +class FakeShaderModule { + public readonly getCompilationInfo = vi.fn(async () => ({ + messages: [], + })); +} + +class FakeDevice { + public readonly copyCalls: Array = []; + public readonly dispatchCalls: Array = []; + public readonly createdComputeEntryPoints: Array = []; + public readonly limits = { + maxBufferSize: 1024 * 1024 * 1024, + maxComputeWorkgroupsPerDimension: 65_535, + }; + public readonly queue = new FakeQueue(); + + private bufferIndex = 0; + + public readonly createBindGroupLayout = vi.fn( + (_descriptor: GPUBindGroupLayoutDescriptor) => ({}) as GPUBindGroupLayout + ); + public readonly createBuffer = vi.fn((descriptor: GPUBufferDescriptor) => { + const label = + ['agents', 'compactedAgents', 'counters', 'countersStaging', 'uniforms'][ + this.bufferIndex + ] ?? `buffer${this.bufferIndex}`; + this.bufferIndex += 1; + + const isMappedReadBuffer = (Number(descriptor.usage) & GPUBufferUsage.MAP_READ) !== 0; + + return new FakeBuffer( + label, + Number(descriptor.size), + isMappedReadBuffer ? this.compactedCount : 0 + ) as unknown as GPUBuffer; + }); + public readonly createBindGroup = vi.fn( + (_descriptor: GPUBindGroupDescriptor) => ({}) as GPUBindGroup + ); + public readonly createPipelineLayout = vi.fn( + (_descriptor: GPUPipelineLayoutDescriptor) => ({}) as GPUPipelineLayout + ); + public readonly createShaderModule = vi.fn( + (_descriptor: GPUShaderModuleDescriptor) => + new FakeShaderModule() as unknown as GPUShaderModule + ); + public readonly createComputePipeline = vi.fn( + (descriptor: GPUComputePipelineDescriptor) => { + const pipeline = { + entryPoint: descriptor.compute.entryPoint ?? 'main', + }; + this.createdComputeEntryPoints.push(pipeline.entryPoint); + return pipeline as unknown as GPUComputePipeline; + } + ); + public readonly createCommandEncoder = vi.fn(() => new FakeCommandEncoder(this)); + + public constructor(private readonly compactedCount: number) {} +} + +const createPipeline = (compactedCount: number) => { + installGpuConstants(); + + const device = new FakeDevice(compactedCount); + const commonState = { + bindGroupLayout: {} as GPUBindGroupLayout, + execute: vi.fn(), + } as unknown as CommonState; + + return { + device, + pipeline: new AgentGenerationPipeline( + device as unknown as GPUDevice, + commonState, + 1024 + ), + }; +}; + +describe('AgentGenerationPipeline compaction', () => { + it('copies compacted agents back with compute instead of a full agent buffer copy', async () => { + const agentCount = 10; + const { device, pipeline } = createPipeline(3); + + await expect(pipeline.compactAgents(agentCount)).resolves.toBe(3); + + expect(device.createdComputeEntryPoints).toContain('copyCompactedAgents'); + expect(device.dispatchCalls.map((call) => call.entryPoint)).toEqual([ + 'main', + 'copyCompactedAgents', + ]); + expect(device.copyCalls.map((call) => call.size)).toEqual([ + Uint32Array.BYTES_PER_ELEMENT, + ]); + expect( + device.copyCalls.some((call) => call.size === agentCount * AGENT_SIZE_IN_BYTES) + ).toBe(false); + expect(device.queue.submit).toHaveBeenCalledTimes(1); + + pipeline.destroy(); + }); + + it('does not encode work for empty compaction requests', async () => { + const { device, pipeline } = createPipeline(0); + + await expect(pipeline.compactAgents(0)).resolves.toBe(0); + + expect(device.dispatchCalls).toEqual([]); + expect(device.copyCalls).toEqual([]); + expect(device.queue.submit).not.toHaveBeenCalled(); + + pipeline.destroy(); + }); +}); diff --git a/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts b/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts index 3626449..de524a7 100644 --- a/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts +++ b/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts @@ -26,6 +26,7 @@ export class AgentGenerationPipeline { private readonly countingPipeline: GPUComputePipeline; private readonly resizePipeline: GPUComputePipeline; private readonly compactionPipeline: GPUComputePipeline; + private readonly compactedAgentsCopyPipeline: GPUComputePipeline; public readonly agentsBuffer: GPUBuffer; private readonly compactedAgentsBuffer: GPUBuffer; @@ -109,7 +110,7 @@ export class AgentGenerationPipeline { this.compactedAgentsBuffer = this.device.createBuffer({ size: this.maxAgentCount * AGENT_SIZE_IN_BYTES, - usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC, + usage: GPUBufferUsage.STORAGE, }); this.countersBuffer = this.device.createBuffer({ @@ -216,20 +217,32 @@ export class AgentGenerationPipeline { }, }); + const compactionModule = smartCompile( + device, + CommonState.shaderCode, + agentSchema, + compactionShader + ); + this.compactionPipeline = device.createComputePipeline({ layout: device.createPipelineLayout({ bindGroupLayouts: [commonState.bindGroupLayout, this.compactionBindGroupLayout], }), compute: { - module: smartCompile( - device, - CommonState.shaderCode, - agentSchema, - compactionShader - ), + module: compactionModule, entryPoint: 'main', }, }); + + this.compactedAgentsCopyPipeline = device.createComputePipeline({ + layout: device.createPipelineLayout({ + bindGroupLayouts: [commonState.bindGroupLayout, this.compactionBindGroupLayout], + }), + compute: { + module: compactionModule, + entryPoint: 'copyCompactedAgents', + }, + }); } public get maxAgentCount(): number { @@ -364,13 +377,19 @@ export class AgentGenerationPipeline { ); passEncoder.end(); - commandEncoder.copyBufferToBuffer( - this.compactedAgentsBuffer, - 0, - this.agentsBuffer, - 0, - agentCount * AGENT_SIZE_IN_BYTES + const copyPassEncoder = commandEncoder.beginComputePass(); + copyPassEncoder.setPipeline(this.compactedAgentsCopyPipeline); + this.commonState.execute(copyPassEncoder); + copyPassEncoder.setBindGroup(1, this.compactionBindGroup); + copyPassEncoder.dispatchWorkgroups( + ...getWorkgroupCounts( + this.device, + agentCount, + AgentGenerationPipeline.WORKGROUP_SIZE + ) ); + copyPassEncoder.end(); + commandEncoder.copyBufferToBuffer( this.countersBuffer, 0, diff --git a/src/pipelines/agents/agent-generation/agent-schema.test.ts b/src/pipelines/agents/agent-generation/agent-schema.test.ts index 96a419e..2fc6b38 100644 --- a/src/pipelines/agents/agent-generation/agent-schema.test.ts +++ b/src/pipelines/agents/agent-generation/agent-schema.test.ts @@ -69,6 +69,27 @@ describe('Agent TS/WGSL contract', () => { expect(agentSchema).toContain('workgroup_count.x * 64'); expect(agentSchema).toContain('workgroup_count.x * workgroup_count.y * 64'); expect(compactionShader).toContain('let id = get_id(global_id, workgroup_count);'); - expect(compactionShader).toContain('if id >= settings.agentCount'); + expect(compactionShader).toContain('if id < settings.agentCount'); + }); + + it('keeps compaction copy-back bounded by the compacted count', () => { + expect(compactionShader).toContain('fn copyCompactedAgents'); + expect(compactionShader).toContain( + 'workgroupCopyCount = atomicLoad(&counters.aliveAgentCount);' + ); + expect(compactionShader).toContain('if id >= workgroupCopyCount'); + expect(compactionShader).toContain('agents[id] = compactedAgents[id];'); + }); + + it('uses workgroup-local counting before allocating global compacted ranges', () => { + expect(compactionShader).toContain( + 'var workgroupAliveCount: atomic;' + ); + expect(compactionShader).toContain( + 'localCompactedIndex = atomicAdd(&workgroupAliveCount, 1u);' + ); + expect( + compactionShader.match(/atomicAdd\(&counters\.aliveAgentCount/g) ?? [] + ).toHaveLength(1); }); }); diff --git a/src/pipelines/agents/agent.wgsl b/src/pipelines/agents/agent.wgsl index 1516f7a..66726f4 100644 --- a/src/pipelines/agents/agent.wgsl +++ b/src/pipelines/agents/agent.wgsl @@ -35,7 +35,7 @@ fn main( } var agent = agents[id]; - if agent.colorIndex < 0.0 { + if agent.colorIndex < 0.0 || agent.colorIndex >= 2.5 { return; } @@ -48,12 +48,7 @@ fn main( return; } - let random = textureSampleLevel( - noise, - noiseSampler, - fract(vec2(f32(id) * 0.7548777, state.time * 0.00017 + f32(id) * 0.5698403)), - 0 - ); + let random = random_vec4(id, state.time); let forwardSensor = sensor_position(agent.position, agent.angle, settings.sensorOffset, 0); let leftSensor = sensor_position(agent.position, agent.angle, settings.sensorOffset, settings.sensorAngle); @@ -154,7 +149,10 @@ fn get_channel_mask(colorIndex: f32) -> vec3 { if colorIndex < 1.5 { return vec3(0, 1, 0); } - return vec3(0, 0, 1); + if colorIndex < 2.5 { + return vec3(0, 0, 1); + } + return vec3(0.0, 0.0, 0.0); } fn get_reaction_mask(colorIndex: f32) -> vec3 { @@ -172,13 +170,37 @@ fn get_reaction_mask(colorIndex: f32) -> vec3 { settings.color2ToColor3 ); } - return vec3( - settings.color3ToColor1, - settings.color3ToColor2, - settings.color3ToColor3 - ); + if colorIndex < 2.5 { + return vec3( + settings.color3ToColor1, + settings.color3ToColor2, + settings.color3ToColor3 + ); + } + return vec3(0.0, 0.0, 0.0); } fn angle_delta(sourceAngle: f32, targetAngle: f32) -> f32 { return atan2(sin(targetAngle - sourceAngle), cos(targetAngle - sourceAngle)); } + +fn random_vec4(id: u32, time: f32) -> vec4 { + let timeSeed = u32(time * 0.34816); + let seed = id * 747796405u + timeSeed * 2891336453u; + return vec4( + random_float(seed), + random_float(seed + 1013904223u), + random_float(seed + 1664525u), + random_float(seed + 22695477u) + ); +} + +fn random_float(seed: u32) -> f32 { + return f32(hash_u32(seed) >> 8u) * (1.0 / 16777216.0); +} + +fn hash_u32(seed: u32) -> u32 { + let value = seed * 747796405u + 2891336453u; + let word = ((value >> ((value >> 28u) + 4u)) ^ value) * 277803737u; + return (word >> 22u) ^ word; +} diff --git a/src/pipelines/brush/brush-pipeline.ts b/src/pipelines/brush/brush-pipeline.ts index 6c4987c..865043a 100644 --- a/src/pipelines/brush/brush-pipeline.ts +++ b/src/pipelines/brush/brush-pipeline.ts @@ -26,6 +26,7 @@ export class BrushPipeline { private readonly bindGroupLayout: GPUBindGroupLayout; private readonly bindGroup: GPUBindGroup; private readonly pipeline: GPURenderPipeline; + private readonly multiTargetPipeline: GPURenderPipeline; private readonly uniforms: GPUBuffer; private readonly uniformValues = new Float32Array(BrushPipeline.UNIFORM_COUNT); private readonly uniformCache = createCachedFloat32BufferWrite( @@ -57,68 +58,16 @@ export class BrushPipeline { usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, }); - this.pipeline = device.createRenderPipeline({ - layout: device.createPipelineLayout({ - bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout], - }), - vertex: { - module: smartCompile(device, CommonState.shaderCode, shader), - entryPoint: 'vertex', - buffers: [ - { - arrayStride: Float32Array.BYTES_PER_ELEMENT * 6, - attributes: [ - { - shaderLocation: 0, - format: 'float32x2', - offset: 0, - }, - { - shaderLocation: 1, - format: 'float32x2', - offset: Float32Array.BYTES_PER_ELEMENT * 2, - }, - { - shaderLocation: 2, - format: 'float32x2', - offset: Float32Array.BYTES_PER_ELEMENT * 4, - }, - ], - }, - ], - }, - fragment: { - module: smartCompile(device, CommonState.shaderCode, shader), - entryPoint: 'fragment', - targets: [ - { - format: 'rgba16float', - blend: { - color: { - operation: 'max', - srcFactor: 'one', - dstFactor: 'one', - }, - alpha: { - operation: 'max', - srcFactor: 'one', - dstFactor: 'one', - }, - }, - }, - ], - }, - primitive: { - topology: 'triangle-list', - }, - }); + const shaderModule = smartCompile(device, CommonState.shaderCode, shader); + this.pipeline = this.createPipeline(shaderModule, 'fragment', 1); + this.multiTargetPipeline = this.createPipeline(shaderModule, 'fragmentMrt', 2); this.uniforms = this.device.createBuffer({ size: BrushPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, }); - this.bindGroup = this.bindGroup = this.device.createBindGroup({ + this.bindGroup = this.device.createBindGroup({ layout: this.bindGroupLayout, entries: [ { @@ -315,23 +264,40 @@ export class BrushPipeline { return offset; } - public execute(commandEncoder: GPUCommandEncoder, trailMapOut: GPUTextureView) { + public execute(commandEncoder: GPUCommandEncoder, trailMapOut: GPUTextureView): void { + this.executeWithPipeline(commandEncoder, this.pipeline, [trailMapOut]); + } + + public executeMultiTarget( + commandEncoder: GPUCommandEncoder, + sourceMapOut: GPUTextureView, + influenceMapOut: GPUTextureView + ): void { + this.executeWithPipeline(commandEncoder, this.multiTargetPipeline, [ + sourceMapOut, + influenceMapOut, + ]); + } + + private executeWithPipeline( + commandEncoder: GPUCommandEncoder, + pipeline: GPURenderPipeline, + textureViews: Array + ): void { if (this.lineCount === 0) { return; } const renderPassDescriptor: GPURenderPassDescriptor = { - colorAttachments: [ - { - view: trailMapOut, - loadOp: 'load', - storeOp: 'store', - }, - ], + colorAttachments: textureViews.map((view) => ({ + view, + loadOp: 'load', + storeOp: 'store', + })), }; const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); - passEncoder.setPipeline(this.pipeline); + passEncoder.setPipeline(pipeline); this.commonState.execute(passEncoder); passEncoder.setBindGroup(1, this.bindGroup); passEncoder.setVertexBuffer(0, this.vertexBuffer); @@ -344,6 +310,73 @@ export class BrushPipeline { this.uniforms.destroy(); } + private createPipeline( + shaderModule: GPUShaderModule, + fragmentEntryPoint: string, + colorTargetCount: number + ): GPURenderPipeline { + return this.device.createRenderPipeline({ + layout: this.device.createPipelineLayout({ + bindGroupLayouts: [this.commonState.bindGroupLayout, this.bindGroupLayout], + }), + vertex: { + module: shaderModule, + entryPoint: 'vertex', + buffers: [ + { + arrayStride: Float32Array.BYTES_PER_ELEMENT * 6, + attributes: [ + { + shaderLocation: 0, + format: 'float32x2', + offset: 0, + }, + { + shaderLocation: 1, + format: 'float32x2', + offset: Float32Array.BYTES_PER_ELEMENT * 2, + }, + { + shaderLocation: 2, + format: 'float32x2', + offset: Float32Array.BYTES_PER_ELEMENT * 4, + }, + ], + }, + ], + }, + fragment: { + module: shaderModule, + entryPoint: fragmentEntryPoint, + targets: Array.from( + { length: colorTargetCount }, + () => BrushPipeline.colorTarget + ), + }, + primitive: { + topology: 'triangle-list', + }, + }); + } + + private static get colorTarget(): GPUColorTargetState { + return { + format: 'rgba16float', + blend: { + color: { + operation: 'max', + srcFactor: 'one', + dstFactor: 'one', + }, + alpha: { + operation: 'max', + srcFactor: 'one', + dstFactor: 'one', + }, + }, + }; + } + private static get bindGroupLayout(): GPUBindGroupLayoutDescriptor { return { entries: [ diff --git a/src/pipelines/brush/brush.wgsl b/src/pipelines/brush/brush.wgsl index 831927f..056579a 100644 --- a/src/pipelines/brush/brush.wgsl +++ b/src/pipelines/brush/brush.wgsl @@ -15,6 +15,11 @@ struct VertexOutput { @location(2) end: vec2 } +struct BrushTargets { + @location(0) source: vec4, + @location(1) influence: vec4, +} + @vertex fn vertex( @location(0) screenPosition: vec2, @@ -32,23 +37,47 @@ fn fragment( @location(1) start: vec2, @location(2) end: vec2 ) -> @location(0) vec4 { - let distance = distanceFromLine(screenPosition, start, end); - let coarseNoise = textureSample(noise, noiseSampler, fract(screenPosition / 160.0)).r; - let grainNoise = textureSample( - noise, - noiseSampler, - fract(screenPosition / 22.0 + vec2(0.31, 0.67)) - ).r; - let radius = settings.brushSize + (coarseNoise - 0.5) * settings.brushSizeVariation * 2.0; - let feather = max(1.0, settings.brushSize * 0.22); - let edge = 1.0 - smoothstep(radius - feather, radius + feather, distance); - let strength = edge * mix(0.45, 1.0, grainNoise); + let strength = brushStrength(screenPosition, start, end); - if(strength < 0.02) { - discard; - } + if(strength < 0.02) { + discard; + } - return vec4(settings.brushValue.rgb * strength, settings.brushValue.a * strength); + return brushOutput(strength); +} + +@fragment +fn fragmentMrt( + @location(0) screenPosition: vec2, + @location(1) start: vec2, + @location(2) end: vec2 +) -> BrushTargets { + let strength = brushStrength(screenPosition, start, end); + + if(strength < 0.02) { + discard; + } + + let color = brushOutput(strength); + return BrushTargets(color, color); +} + +fn brushStrength(screenPosition: vec2, start: vec2, end: vec2) -> f32 { + let distance = distanceFromLine(screenPosition, start, end); + let coarseNoise = textureSample(noise, noiseSampler, fract(screenPosition / 160.0)).r; + let grainNoise = textureSample( + noise, + noiseSampler, + fract(screenPosition / 22.0 + vec2(0.31, 0.67)) + ).r; + let radius = settings.brushSize + (coarseNoise - 0.5) * settings.brushSizeVariation * 2.0; + let feather = max(1.0, settings.brushSize * 0.22); + let edge = 1.0 - smoothstep(radius - feather, radius + feather, distance); + return edge * mix(0.45, 1.0, grainNoise); +} + +fn brushOutput(strength: f32) -> vec4 { + return vec4(settings.brushValue.rgb * strength, settings.brushValue.a * strength); } fn distanceFromLine(position: vec2, start: vec2, end: vec2) -> f32 { diff --git a/src/pipelines/diffusion/diffuse.wgsl b/src/pipelines/diffusion/diffuse.wgsl index 8ae9dee..64cdea6 100644 --- a/src/pipelines/diffusion/diffuse.wgsl +++ b/src/pipelines/diffusion/diffuse.wgsl @@ -7,24 +7,28 @@ struct Settings { @group(1) @binding(0) var settings: Settings; -@group(1) @binding(1) var Sampler: sampler; @group(1) @binding(2) var trailMap: texture_2d; @fragment -fn fragment(@location(0) uv: vec2) -> @location(0) vec4 { - var current = textureSample(trailMap, Sampler, uv); +fn fragment(@builtin(position) position: vec4) -> @location(0) vec4 { + let textureSize = vec2(textureDimensions(trailMap, 0)); + let pixel = clamp(vec2(position.xy), vec2(0), textureSize - vec2(1)); + var current = textureLoad(trailMap, pixel, 0); + let random = random_from_pixel(pixel); + let trailWeight = diffusion_weight(random, settings.inverseDiffusionRateTrails); + let brushWeight = diffusion_weight(random, settings.inverseDiffusionRateBrush); current += ( - propagate(uv, vec2(-1.0, -1.0), current) - + propagate(uv, vec2(-1.0, 1.0), current) - + propagate(uv, vec2(1.0, -1.0), current) - + propagate(uv, vec2(1.0, 1.0), current) + propagate(pixel, textureSize, vec2(-1, -1), current, trailWeight, brushWeight) + + propagate(pixel, textureSize, vec2(-1, 1), current, trailWeight, brushWeight) + + propagate(pixel, textureSize, vec2(1, -1), current, trailWeight, brushWeight) + + propagate(pixel, textureSize, vec2(1, 1), current, trailWeight, brushWeight) - + propagate(uv, vec2(-1.0, 0.0), current) - + propagate(uv, vec2(0.0, -1.0), current) - + propagate(uv, vec2(1.0, 0.0), current) - + propagate(uv, vec2(0.0, 1.0), current) + + propagate(pixel, textureSize, vec2(-1, 0), current, trailWeight, brushWeight) + + propagate(pixel, textureSize, vec2(0, -1), current, trailWeight, brushWeight) + + propagate(pixel, textureSize, vec2(1, 0), current, trailWeight, brushWeight) + + propagate(pixel, textureSize, vec2(0, 1), current, trailWeight, brushWeight) ) / 8; let decayed = clamp(vec4( @@ -36,13 +40,64 @@ fn fragment(@location(0) uv: vec2) -> @location(0) vec4 { } -fn propagate(uv: vec2, offset: vec2, currentColor: vec4) -> vec4 { - let neighbour = textureSample(trailMap, Sampler, uv + offset / state.size); - var random = textureSample(noise, noiseSampler, uv + offset / state.size * 0.5).r; +fn propagate( + pixel: vec2, + textureSize: vec2, + offset: vec2, + currentColor: vec4, + trailWeight: f32, + brushWeight: f32 +) -> vec4 { + let neighbour = textureLoad( + trailMap, + clamp(pixel + offset, vec2(0), textureSize - vec2(1)), + 0 + ); let difference = clamp(neighbour - currentColor, vec4(0), vec4(1)); return vec4( - vec3(length(neighbour.rgb) * pow(random, settings.inverseDiffusionRateTrails)), - length(neighbour.a) * pow(random, settings.inverseDiffusionRateBrush) + vec3(length(neighbour.rgb) * trailWeight), + neighbour.a * brushWeight ) * difference; } + +fn random_from_pixel(pixel: vec2) -> f32 { + let p = vec2(pixel); + var hash = p.x * 1664525u + p.y * 1013904223u + 374761393u; + hash = (hash ^ (hash >> 16u)) * 2246822519u; + hash = (hash ^ (hash >> 13u)) * 3266489917u; + hash = hash ^ (hash >> 16u); + return f32(hash) * 2.3283064365386963e-10; +} + +fn diffusion_weight(random: f32, inverseRate: f32) -> f32 { + let r = clamp(random, 0.0, 1.0); + let r2 = r * r; + let r4 = r2 * r2; + let r8 = r4 * r4; + + if inverseRate < 1.0 { + let rootApproximation = r / max(0.5 + r * 0.5, 0.0001); + return mix( + rootApproximation, + r, + clamp((inverseRate - 0.5) * 2.0, 0.0, 1.0) + ); + } + + if inverseRate < 2.0 { + return mix(r, r2, inverseRate - 1.0); + } + + if inverseRate < 4.0 { + return mix(r2, r4, (inverseRate - 2.0) * 0.5); + } + + if inverseRate < 8.0 { + return mix(r4, r8, (inverseRate - 4.0) * 0.25); + } + + let r16 = r8 * r8; + return mix(r8, r16, clamp((inverseRate - 8.0) * 0.125, 0.0, 1.0)) + * min(1.0, 16.0 / inverseRate); +} diff --git a/src/pipelines/diffusion/diffusion-pipeline.test.ts b/src/pipelines/diffusion/diffusion-pipeline.test.ts index 87c4e13..db00651 100644 --- a/src/pipelines/diffusion/diffusion-pipeline.test.ts +++ b/src/pipelines/diffusion/diffusion-pipeline.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; +import shader from './diffuse.wgsl?raw'; import { getSafeInverseDiffusionRate, setDiffusionUniformValues, @@ -26,4 +27,11 @@ describe('diffusion pipeline parameters', () => { expect(getSafeInverseDiffusionRate(2)).toBe(0.5); expect(getSafeInverseDiffusionRate(0.25)).toBe(4); }); + + it('keeps the diffusion shader on the low-cost trail sampling path', () => { + expect(shader).toContain('textureLoad'); + expect(shader).not.toContain('textureSample'); + expect(shader).not.toContain('pow('); + expect(shader).not.toContain('noise'); + }); }); diff --git a/src/pipelines/diffusion/diffusion-pipeline.ts b/src/pipelines/diffusion/diffusion-pipeline.ts index 69875c4..2e35789 100644 --- a/src/pipelines/diffusion/diffusion-pipeline.ts +++ b/src/pipelines/diffusion/diffusion-pipeline.ts @@ -47,7 +47,6 @@ export class DiffusionPipeline { private readonly uniformCache = createCachedFloat32BufferWrite( DiffusionPipeline.UNIFORM_COUNT ); - private readonly sampler: GPUSampler; private readonly vertexBuffer: GPUBuffer; private readonly bindGroupsByInput = new WeakMap(); @@ -86,11 +85,6 @@ export class DiffusionPipeline { size: DiffusionPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, }); - - this.sampler = this.device.createSampler({ - magFilter: 'linear', - minFilter: 'linear', - }); } public setParameters({ @@ -155,10 +149,6 @@ export class DiffusionPipeline { buffer: this.uniforms, }, }, - { - binding: 1, - resource: this.sampler, - }, { binding: 2, resource: trailMapIn, @@ -185,13 +175,6 @@ export class DiffusionPipeline { type: 'uniform', }, }, - { - binding: 1, - visibility: GPUShaderStage.FRAGMENT, - sampler: { - type: 'filtering', - }, - }, { binding: 2, visibility: GPUShaderStage.FRAGMENT, diff --git a/src/pipelines/eraser/eraser-agent-pipeline.ts b/src/pipelines/eraser/eraser-agent-pipeline.ts index 9e2493f..1629db6 100644 --- a/src/pipelines/eraser/eraser-agent-pipeline.ts +++ b/src/pipelines/eraser/eraser-agent-pipeline.ts @@ -11,40 +11,22 @@ import agentSchema from '../agents/agent-generation/agent-schema.wgsl?raw'; import { CommonState } from '../common-state/common-state'; import shader from './eraser-agent.wgsl?raw'; -interface LineSegment { - from: vec2; - to: vec2; -} - -const shaderWithConfig = shader.replace( - 'const MAX_SEGMENT_COUNT = 384u;', - `const MAX_SEGMENT_COUNT = ${Math.round(appConfig.pipelines.eraser.maxSegmentCount)}u;` -); - export class EraserAgentPipeline { private static readonly WORKGROUP_SIZE = appConfig.pipelines.eraser.workgroupSize; private static readonly UNIFORM_COUNT = 4; - private static readonly MAX_SEGMENT_COUNT = appConfig.pipelines.eraser.maxSegmentCount; - private static readonly SEGMENT_FLOAT_COUNT = - appConfig.pipelines.eraser.segmentFloatCount; private readonly bindGroupLayout: GPUBindGroupLayout; - private readonly bindGroup: GPUBindGroup; private readonly pipeline: GPUComputePipeline; private readonly uniforms: GPUBuffer; private readonly uniformValues = new Float32Array(EraserAgentPipeline.UNIFORM_COUNT); private readonly uniformCache = createCachedFloat32BufferWrite( EraserAgentPipeline.UNIFORM_COUNT ); - private readonly segmentsBuffer: GPUBuffer; - private readonly segmentUploadData = new Float32Array( - EraserAgentPipeline.MAX_SEGMENT_COUNT * EraserAgentPipeline.SEGMENT_FLOAT_COUNT - ); + private readonly bindGroupsByMask = new WeakMap(); private linePoints: Array = []; - private lineSegments: Array = []; - private actualSegments: Array = []; - private segmentCount = 0; + private pendingSegmentCount = 0; + private activeSegmentCount = 0; private agentCount = 0; public constructor( @@ -71,8 +53,8 @@ export class EraserAgentPipeline { { binding: 2, visibility: GPUShaderStage.COMPUTE, - buffer: { - type: 'read-only-storage', + texture: { + sampleType: 'float', }, }, ], @@ -83,15 +65,93 @@ export class EraserAgentPipeline { usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, }); - this.segmentsBuffer = this.device.createBuffer({ - size: - EraserAgentPipeline.MAX_SEGMENT_COUNT * - EraserAgentPipeline.SEGMENT_FLOAT_COUNT * - Float32Array.BYTES_PER_ELEMENT, - usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, + this.pipeline = device.createComputePipeline({ + layout: device.createPipelineLayout({ + bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout], + }), + compute: { + module: smartCompile(device, CommonState.shaderCode, agentSchema, shader), + entryPoint: 'main', + }, }); + } - this.bindGroup = this.device.createBindGroup({ + public addSwipe(position: vec2): void { + const previousPosition = this.linePoints[this.linePoints.length - 1] ?? position; + this.addSwipeSegment(previousPosition, position); + this.linePoints.push(vec2.clone(position)); + } + + public addSwipeSegment(from: vec2, to: vec2): void { + void from; + void to; + this.pendingSegmentCount += 1; + } + + public clearSwipes(): void { + this.linePoints.length = 0; + this.pendingSegmentCount = 0; + this.activeSegmentCount = 0; + } + + public setParameters({ + agentCount, + eraserSize: _eraserSize, + }: { + agentCount: number; + eraserSize: number; + }): void { + void _eraserSize; + this.agentCount = agentCount; + this.activeSegmentCount = this.pendingSegmentCount; + this.pendingSegmentCount = 0; + + this.uniformValues[0] = agentCount; + this.uniformValues[1] = 0; + this.uniformValues[2] = 0; + this.uniformValues[3] = 0; + writeFloat32BufferIfChanged( + this.device, + this.uniforms, + this.uniformValues, + this.uniformCache + ); + } + + public hasActiveMask(): boolean { + return this.activeSegmentCount > 0; + } + + public execute(commandEncoder: GPUCommandEncoder, eraserMask: GPUTextureView): void { + if (!this.hasActiveMask() || this.agentCount === 0) { + return; + } + + const passEncoder = commandEncoder.beginComputePass(); + passEncoder.setPipeline(this.pipeline); + this.commonState.execute(passEncoder); + passEncoder.setBindGroup(1, this.getBindGroup(eraserMask)); + passEncoder.dispatchWorkgroups( + ...getWorkgroupCounts( + this.device, + this.agentCount, + EraserAgentPipeline.WORKGROUP_SIZE + ) + ); + passEncoder.end(); + } + + public destroy(): void { + this.uniforms.destroy(); + } + + private getBindGroup(eraserMask: GPUTextureView): GPUBindGroup { + const cached = this.bindGroupsByMask.get(eraserMask); + if (cached) { + return cached; + } + + const bindGroup = this.device.createBindGroup({ layout: this.bindGroupLayout, entries: [ { @@ -108,137 +168,12 @@ export class EraserAgentPipeline { }, { binding: 2, - resource: { - buffer: this.segmentsBuffer, - }, + resource: eraserMask, }, ], }); - this.pipeline = device.createComputePipeline({ - layout: device.createPipelineLayout({ - bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout], - }), - compute: { - module: smartCompile( - device, - CommonState.shaderCode, - agentSchema, - shaderWithConfig - ), - entryPoint: 'main', - }, - }); - } - - public addSwipe(position: vec2): void { - const previousPosition = this.linePoints[this.linePoints.length - 1] ?? position; - this.addSwipeSegment(previousPosition, position); - this.linePoints.push(vec2.clone(position)); - } - - public addSwipeSegment(from: vec2, to: vec2): void { - this.lineSegments.push({ - from: vec2.clone(from), - to: vec2.clone(to), - }); - } - - public clearSwipes(): void { - this.linePoints.length = 0; - this.lineSegments.length = 0; - this.actualSegments.length = 0; - this.segmentCount = 0; - } - - public setParameters({ - agentCount, - eraserSize, - }: { - agentCount: number; - eraserSize: number; - }): void { - this.agentCount = agentCount; - this.actualSegments = this.lineSegments.slice(); - this.lineSegments.length = 0; - - if (this.actualSegments.length > EraserAgentPipeline.MAX_SEGMENT_COUNT) { - this.actualSegments = EraserAgentPipeline.subsampleSegments(this.actualSegments); - } - - this.segmentCount = Math.max(0, this.actualSegments.length); - - const eraserRadius = eraserSize / 2; - this.uniformValues[0] = eraserRadius; - this.uniformValues[1] = this.segmentCount; - this.uniformValues[2] = agentCount; - this.uniformValues[3] = eraserRadius * eraserRadius; - writeFloat32BufferIfChanged( - this.device, - this.uniforms, - this.uniformValues, - this.uniformCache - ); - - if (this.segmentCount === 0) { - return; - } - - for (let i = 0; i < this.segmentCount; i++) { - const { from, to } = this.actualSegments[i]; - const offset = i * EraserAgentPipeline.SEGMENT_FLOAT_COUNT; - this.segmentUploadData[offset] = from[0]; - this.segmentUploadData[offset + 1] = from[1]; - this.segmentUploadData[offset + 2] = to[0]; - this.segmentUploadData[offset + 3] = to[1]; - } - - this.device.queue.writeBuffer( - this.segmentsBuffer, - 0, - this.segmentUploadData, - 0, - this.segmentCount * EraserAgentPipeline.SEGMENT_FLOAT_COUNT - ); - } - - public execute(commandEncoder: GPUCommandEncoder): void { - if (this.segmentCount === 0 || this.agentCount === 0) { - return; - } - - const passEncoder = commandEncoder.beginComputePass(); - passEncoder.setPipeline(this.pipeline); - this.commonState.execute(passEncoder); - passEncoder.setBindGroup(1, this.bindGroup); - passEncoder.dispatchWorkgroups( - ...getWorkgroupCounts( - this.device, - this.agentCount, - EraserAgentPipeline.WORKGROUP_SIZE - ) - ); - passEncoder.end(); - } - - public destroy(): void { - this.uniforms.destroy(); - this.segmentsBuffer.destroy(); - } - - private static subsampleSegments(segments: Array): Array { - if (segments.length <= EraserAgentPipeline.MAX_SEGMENT_COUNT) { - return segments; - } - - const result: Array = []; - for (let i = 0; i < EraserAgentPipeline.MAX_SEGMENT_COUNT; i++) { - const index = Math.round( - (i * (segments.length - 1)) / (EraserAgentPipeline.MAX_SEGMENT_COUNT - 1) - ); - result.push(segments[index]); - } - - return result; + this.bindGroupsByMask.set(eraserMask, bindGroup); + return bindGroup; } } diff --git a/src/pipelines/eraser/eraser-agent.wgsl b/src/pipelines/eraser/eraser-agent.wgsl index 12048be..b928866 100644 --- a/src/pipelines/eraser/eraser-agent.wgsl +++ b/src/pipelines/eraser/eraser-agent.wgsl @@ -1,14 +1,12 @@ struct Settings { - eraserRadius: f32, - segmentCount: f32, agentCount: f32, - eraserRadiusSquared: f32, + padding0: f32, + padding1: f32, + padding2: f32, }; -const MAX_SEGMENT_COUNT = 384u; - @group(1) @binding(0) var settings: Settings; -@group(1) @binding(2) var segments: array>; +@group(1) @binding(2) var eraserMask: texture_2d; @compute @workgroup_size(64) fn main( @@ -26,38 +24,16 @@ fn main( return; } - for (var i = 0u; i < MAX_SEGMENT_COUNT; i++) { - if i >= u32(settings.segmentCount) { - break; - } + let maskSize = vec2(textureDimensions(eraserMask)); + let maskPosition = clamp( + vec2(agent.position), + vec2(0, 0), + maskSize - vec2(1, 1) + ); + let maskSample = textureLoad(eraserMask, maskPosition, 0); - let segment = segments[i]; - let distanceSquared = distanceSquaredFromLine( - agent.position, - segment.xy, - segment.zw - ); - - if distanceSquared <= settings.eraserRadiusSquared { - agent.position = vec2(-1.0, -1.0); - agent.targetPosition = vec2(-1.0, -1.0); - agent.colorIndex = -1.0; - agents[id] = agent; - return; - } + if maskSample.a < 0.5 { + agent.colorIndex = -1.0; + agents[id] = agent; } } - -fn distanceSquaredFromLine(position: vec2, start: vec2, end: vec2) -> f32 { - let pa = position - start; - let direction = end - start; - let denominator = dot(direction, direction); - - if denominator <= 0.0001 { - return dot(pa, pa); - } - - let q = clamp(dot(pa, direction) / denominator, 0.0, 1.0); - let nearestOffset = pa - direction * q; - return dot(nearestOffset, nearestOffset); -} diff --git a/src/pipelines/eraser/eraser-texture-pipeline.ts b/src/pipelines/eraser/eraser-texture-pipeline.ts index c2db414..3d3ff66 100644 --- a/src/pipelines/eraser/eraser-texture-pipeline.ts +++ b/src/pipelines/eraser/eraser-texture-pipeline.ts @@ -24,6 +24,7 @@ export class EraserTexturePipeline { private readonly bindGroupLayout: GPUBindGroupLayout; private readonly bindGroup: GPUBindGroup; private readonly pipeline: GPURenderPipeline; + private readonly multiTargetPipeline: GPURenderPipeline; private readonly uniforms: GPUBuffer; private readonly uniformValues = new Float32Array(EraserTexturePipeline.UNIFORM_COUNT); private readonly uniformCache = createCachedFloat32BufferWrite( @@ -65,49 +66,9 @@ export class EraserTexturePipeline { usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, }); - this.pipeline = device.createRenderPipeline({ - layout: device.createPipelineLayout({ - bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout], - }), - vertex: { - module: smartCompile(device, CommonState.shaderCode, shader), - entryPoint: 'vertex', - buffers: [ - { - arrayStride: Float32Array.BYTES_PER_ELEMENT * 6, - attributes: [ - { - shaderLocation: 0, - format: 'float32x2', - offset: 0, - }, - { - shaderLocation: 1, - format: 'float32x2', - offset: Float32Array.BYTES_PER_ELEMENT * 2, - }, - { - shaderLocation: 2, - format: 'float32x2', - offset: Float32Array.BYTES_PER_ELEMENT * 4, - }, - ], - }, - ], - }, - fragment: { - module: smartCompile(device, CommonState.shaderCode, shader), - entryPoint: 'fragment', - targets: [ - { - format: 'rgba16float', - }, - ], - }, - primitive: { - topology: 'triangle-list', - }, - }); + const shaderModule = smartCompile(device, CommonState.shaderCode, shader); + this.pipeline = this.createPipeline(shaderModule, 'fragment', 1); + this.multiTargetPipeline = this.createPipeline(shaderModule, 'fragmentMrt', 3); this.uniforms = this.device.createBuffer({ size: EraserTexturePipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT, @@ -194,22 +155,41 @@ export class EraserTexturePipeline { } public execute(commandEncoder: GPUCommandEncoder, textureOut: GPUTextureView): void { + this.executeWithPipeline(commandEncoder, this.pipeline, [textureOut]); + } + + public executeMultiTarget( + commandEncoder: GPUCommandEncoder, + sourceMapOut: GPUTextureView, + influenceMapOut: GPUTextureView, + trailMapOut: GPUTextureView + ): void { + this.executeWithPipeline(commandEncoder, this.multiTargetPipeline, [ + sourceMapOut, + influenceMapOut, + trailMapOut, + ]); + } + + private executeWithPipeline( + commandEncoder: GPUCommandEncoder, + pipeline: GPURenderPipeline, + textureViews: Array + ): void { if (this.lineCount === 0) { return; } const renderPassDescriptor: GPURenderPassDescriptor = { - colorAttachments: [ - { - view: textureOut, - loadOp: 'load', - storeOp: 'store', - }, - ], + colorAttachments: textureViews.map((view) => ({ + view, + loadOp: 'load', + storeOp: 'store', + })), }; const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); - passEncoder.setPipeline(this.pipeline); + passEncoder.setPipeline(pipeline); this.commonState.execute(passEncoder); passEncoder.setBindGroup(1, this.bindGroup); passEncoder.setVertexBuffer(0, this.vertexBuffer); @@ -222,6 +202,54 @@ export class EraserTexturePipeline { this.uniforms.destroy(); } + private createPipeline( + shaderModule: GPUShaderModule, + fragmentEntryPoint: string, + colorTargetCount: number + ): GPURenderPipeline { + return this.device.createRenderPipeline({ + layout: this.device.createPipelineLayout({ + bindGroupLayouts: [this.commonState.bindGroupLayout, this.bindGroupLayout], + }), + vertex: { + module: shaderModule, + entryPoint: 'vertex', + buffers: [ + { + arrayStride: Float32Array.BYTES_PER_ELEMENT * 6, + attributes: [ + { + shaderLocation: 0, + format: 'float32x2', + offset: 0, + }, + { + shaderLocation: 1, + format: 'float32x2', + offset: Float32Array.BYTES_PER_ELEMENT * 2, + }, + { + shaderLocation: 2, + format: 'float32x2', + offset: Float32Array.BYTES_PER_ELEMENT * 4, + }, + ], + }, + ], + }, + fragment: { + module: shaderModule, + entryPoint: fragmentEntryPoint, + targets: Array.from({ length: colorTargetCount }, () => ({ + format: 'rgba16float' as const, + })), + }, + primitive: { + topology: 'triangle-list', + }, + }); + } + private static subsampleSegments(segments: Array): Array { if (segments.length <= EraserTexturePipeline.MAX_LINE_COUNT) { return segments; diff --git a/src/pipelines/eraser/eraser-texture.wgsl b/src/pipelines/eraser/eraser-texture.wgsl index c1bfe28..28297f2 100644 --- a/src/pipelines/eraser/eraser-texture.wgsl +++ b/src/pipelines/eraser/eraser-texture.wgsl @@ -14,6 +14,12 @@ struct VertexOutput { @location(2) end: vec2 } +struct EraserTextureTargets { + @location(0) source: vec4, + @location(1) influence: vec4, + @location(2) trail: vec4, +} + @vertex fn vertex( @location(0) screenPosition: vec2, @@ -31,13 +37,35 @@ fn fragment( @location(1) start: vec2, @location(2) end: vec2 ) -> @location(0) vec4 { - if distanceSquaredFromLine(screenPosition, start, end) > settings.eraserRadiusSquared { + if shouldDiscardEraserFragment(screenPosition, start, end) { discard; } return vec4(0.0, 0.0, 0.0, 0.0); } +@fragment +fn fragmentMrt( + @location(0) screenPosition: vec2, + @location(1) start: vec2, + @location(2) end: vec2 +) -> EraserTextureTargets { + if shouldDiscardEraserFragment(screenPosition, start, end) { + discard; + } + + let cleared = vec4(0.0, 0.0, 0.0, 0.0); + return EraserTextureTargets(cleared, cleared, cleared); +} + +fn shouldDiscardEraserFragment( + screenPosition: vec2, + start: vec2, + end: vec2 +) -> bool { + return distanceSquaredFromLine(screenPosition, start, end) > settings.eraserRadiusSquared; +} + fn distanceSquaredFromLine(position: vec2, start: vec2, end: vec2) -> f32 { let pa = position - start; let direction = end - start; diff --git a/src/pipelines/render/render-pipeline.ts b/src/pipelines/render/render-pipeline.ts index 21a4de4..328debc 100644 --- a/src/pipelines/render/render-pipeline.ts +++ b/src/pipelines/render/render-pipeline.ts @@ -121,13 +121,14 @@ export class RenderPipeline { commandEncoder: GPUCommandEncoder, colorTexture: GPUTextureView, sourceTexture: GPUTextureView - ) { + ): GPUTexture { const bindGroup = this.getBindGroup(colorTexture, sourceTexture); + const canvasTexture = this.context.getCurrentTexture(); const renderPassDescriptor: GPURenderPassDescriptor = { colorAttachments: [ { - view: this.context.getCurrentTexture().createView(), + view: canvasTexture.createView(), clearValue: { r: 0, g: 0, b: 0, a: 1 }, loadOp: 'clear', storeOp: 'store', @@ -141,6 +142,8 @@ export class RenderPipeline { passEncoder.setBindGroup(1, bindGroup); passEncoder.draw(4, 1); passEncoder.end(); + + return canvasTexture; } public executeToView( diff --git a/src/pipelines/wgsl-uniform-layout.test.ts b/src/pipelines/wgsl-uniform-layout.test.ts index e611f17..4566ba6 100644 --- a/src/pipelines/wgsl-uniform-layout.test.ts +++ b/src/pipelines/wgsl-uniform-layout.test.ts @@ -169,7 +169,7 @@ describe('WGSL uniform layout contracts', () => { pipeline: EraserAgentPipeline, source: eraserAgentShader, structName: 'Settings', - fieldNames: ['eraserRadius', 'segmentCount', 'agentCount', 'eraserRadiusSquared'], + fieldNames: ['agentCount', 'padding0', 'padding1', 'padding2'], }); expectStructUniformLayout({ pipeline: EraserTexturePipeline, @@ -199,4 +199,10 @@ describe('WGSL uniform layout contracts', () => { getUniformCount(AgentGenerationPipeline) ); }); + + it('guards invalid high agent color indexes instead of treating them as color 3', () => { + expect(agentShader).toContain('agent.colorIndex < 0.0 || agent.colorIndex >= 2.5'); + expect(agentShader).toContain('if colorIndex < 2.5'); + expect(agentShader).toContain('return vec3(0.0, 0.0, 0.0);'); + }); }); diff --git a/src/utils/error-handler.ts b/src/utils/error-handler.ts index d969a2e..9bc83dc 100644 --- a/src/utils/error-handler.ts +++ b/src/utils/error-handler.ts @@ -5,7 +5,6 @@ export enum Severity { } export enum ErrorCode { - UNKNOWN = 'unknown', WEBGPU_INSECURE_CONTEXT = 'webgpu-insecure-context', WEBGPU_UNSUPPORTED = 'webgpu-unsupported', WEBGPU_ADAPTER_UNAVAILABLE = 'webgpu-adapter-unavailable', diff --git a/src/utils/graphics/initialize-context.ts b/src/utils/graphics/initialize-context.ts index 2a50c9e..fd78952 100644 --- a/src/utils/graphics/initialize-context.ts +++ b/src/utils/graphics/initialize-context.ts @@ -34,6 +34,7 @@ export const initializeContext = ({ context.configure({ device: device, format: gpu.getPreferredCanvasFormat(), + usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC, alphaMode: 'premultiplied', }); } catch (error) { diff --git a/src/utils/graphics/resizable-texture.ts b/src/utils/graphics/resizable-texture.ts index 44770e7..dabd343 100644 --- a/src/utils/graphics/resizable-texture.ts +++ b/src/utils/graphics/resizable-texture.ts @@ -49,6 +49,10 @@ export class ResizableTexture { return this.textureView; } + public getTexture(): GPUTexture { + return this.texture; + } + public destroy(): void { this.texture.destroy(); this.copyPipeline.destroy(); @@ -61,7 +65,9 @@ export class ResizableTexture { usage: GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING | - GPUTextureUsage.RENDER_ATTACHMENT, + GPUTextureUsage.RENDER_ATTACHMENT | + GPUTextureUsage.COPY_SRC | + GPUTextureUsage.COPY_DST, }); } } diff --git a/src/vibes.test.ts b/src/vibes.test.ts index dd8dd9f..17fd809 100644 --- a/src/vibes.test.ts +++ b/src/vibes.test.ts @@ -60,7 +60,6 @@ describe('vibe and audio config contract', () => { expect(new Set(vibeIds).size).toBe(vibeIds.length); expect(vibeIds.every((id) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id))).toBe(true); expect(audioIds.slice().sort()).toEqual(vibeIds.slice().sort()); - expect(vibeIds).toContain(gardenAudioConfig.fallbackVibeId); }); it('keeps each vibe palette and audio profile complete', () => { @@ -97,6 +96,11 @@ describe('vibe and audio config contract', () => { }); }); + it('falls back to finite RGB channels for malformed hex colors', () => { + expect(hexToRgb('not-a-color')).toEqual([0, 0, 0]); + expect(hexToRgb('#abcdzz')).toEqual([0, 0, 0]); + }); + it('uses discrete color interaction matrices for every vibe', () => { VIBE_PRESETS.forEach((vibe) => { colorInteractionKeys.forEach((key) => { diff --git a/src/vibes.ts b/src/vibes.ts index 085f10d..5b6bd47 100644 --- a/src/vibes.ts +++ b/src/vibes.ts @@ -1,17 +1,21 @@ import { appConfig, type VibePreset } from './config'; import { readBrowserStorage } from './utils/browser-storage'; -export type { GardenVibeSettings, VibePreset } from './config'; +export type { VibePreset } from './config'; export const VIBE_PRESETS: Array = appConfig.vibes.presets; +const HEX_COLOR_PATTERN = + /^#?(?[0-9a-f]{2})(?[0-9a-f]{2})(?[0-9a-f]{2})$/i; + export const hexToRgb = (hex: string): [number, number, number] => { - const value = hex.replace('#', ''); - return [ - parseInt(value.slice(0, 2), 16) / 255, - parseInt(value.slice(2, 4), 16) / 255, - parseInt(value.slice(4, 6), 16) / 255, - ]; + const match = HEX_COLOR_PATTERN.exec(hex); + if (!match?.groups) { + return [0, 0, 0]; + } + + const { red, green, blue } = match.groups; + return [parseInt(red, 16) / 255, parseInt(green, 16) / 255, parseInt(blue, 16) / 255]; }; export const getInitialVibe = (): VibePreset => { From ce383ce34c011c781a18d9cbe2e81f949b632062 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 16 May 2026 15:41:36 +0100 Subject: [PATCH 06/20] More css clean up --- src/style/_app-shell.scss | 22 +++++++--------------- src/style/_config-pane.scss | 7 ++++--- src/style/_garden-prompt.scss | 11 ----------- src/style/_motion.scss | 15 --------------- src/style/_toolbar.scss | 21 +-------------------- src/style/common.scss | 8 +------- 6 files changed, 13 insertions(+), 71 deletions(-) diff --git a/src/style/_app-shell.scss b/src/style/_app-shell.scss index 1a6fa85..fc816f2 100644 --- a/src/style/_app-shell.scss +++ b/src/style/_app-shell.scss @@ -1,7 +1,5 @@ -html>body { +html > body { width: 100%; - min-height: 100vh; - min-height: 100dvh; height: 100vh; height: 100dvh; overflow: hidden; @@ -9,22 +7,20 @@ html>body { position: relative; background: var(--garden-background, #10151f); - >.canvas-container { - min-height: 100vh; - min-height: 100dvh; + > .canvas-container { height: 100%; width: 100%; display: flex; position: relative; overflow: hidden; - >canvas { + > canvas { height: 100%; width: 100%; touch-action: none; } - >.eraser-preview { + > .eraser-preview { position: absolute; top: 0; left: 0; @@ -51,7 +47,7 @@ html>body { } } - >.dev-stats-overlay { + > .dev-stats-overlay { position: absolute; top: max(10px, env(safe-area-inset-top, 0px)); left: max(10px, env(safe-area-inset-left, 0px)); @@ -70,13 +66,9 @@ html>body { pointer-events: none; user-select: none; white-space: pre; - - &[hidden] { - display: none; - } } - >.errors-container { + > .errors-container { position: absolute; top: 0; left: 0; @@ -89,4 +81,4 @@ html>body { } } } -} \ No newline at end of file +} diff --git a/src/style/_config-pane.scss b/src/style/_config-pane.scss index 6fafa4b..cdd9781 100644 --- a/src/style/_config-pane.scss +++ b/src/style/_config-pane.scss @@ -18,12 +18,15 @@ min-height: 28px; align-items: center; justify-content: center; - gap: 5px; color: rgb(255 255 255 / 76%); font-size: 11px; line-height: 1; } +.color-reaction-matrix__header { + gap: 5px; +} + .color-reaction-matrix__corner { justify-content: flex-start; padding-left: 2px; @@ -40,7 +43,6 @@ } .color-reaction-matrix__cell { - display: block; min-width: 0; } @@ -51,7 +53,6 @@ border: 1px solid rgb(255 255 255 / 16%); border-radius: 4px; padding: 0 4px; - appearance: auto; background: rgb(255 255 255 / 8%); color: white; font: inherit; diff --git a/src/style/_garden-prompt.scss b/src/style/_garden-prompt.scss index 1ba7945..b7cb7d2 100644 --- a/src/style/_garden-prompt.scss +++ b/src/style/_garden-prompt.scss @@ -3,14 +3,8 @@ html > body > .canvas-container > .garden-prompt { position: absolute; left: 50%; - bottom: calc(7.25rem + env(safe-area-inset-bottom)); transform: translateX(-50%); - max-width: min(92vw, 780px); - color: white; text-align: center; - font-size: 46px; - line-height: 1.15; - text-shadow: 0 2px 18px rgb(0 0 0 / 60%); pointer-events: none; z-index: 2; @@ -22,7 +16,6 @@ html > body > .canvas-container > .garden-prompt { display: flex; align-items: center; top: calc(1.25rem + env(safe-area-inset-top)); - bottom: auto; gap: 16px; width: max-content; min-height: 78px; @@ -84,12 +77,8 @@ html > body > .canvas-container > .garden-prompt { } @include on-small-screen { - bottom: calc(10rem + env(safe-area-inset-bottom)); - font-size: 24px; - &.draw-hint { top: calc(0.75rem + env(safe-area-inset-top)); - bottom: auto; gap: 10px; min-height: 58px; max-width: min(92vw, 340px); diff --git a/src/style/_motion.scss b/src/style/_motion.scss index b12cc37..3211dd0 100644 --- a/src/style/_motion.scss +++ b/src/style/_motion.scss @@ -1,22 +1,7 @@ @media (prefers-reduced-motion: reduce) { html > body { - > .canvas-container > .garden-prompt { - .draw-hint-stroke { - stroke-dashoffset: 0; - } - - .draw-hint-end { - opacity: 1; - transform: none; - } - } - > aside.control-dock { - transform: translateY(0); - > .toolbar-row { - button:hover, - button:active, > .toolbar-shell > .garden-controls > .swatches > .eraser-size-control:hover, > .toolbar-shell > .garden-controls > .swatches > .mirror-segment-control:hover { transform: none; diff --git a/src/style/_toolbar.scss b/src/style/_toolbar.scss index 1d4f7b8..2c0a8be 100644 --- a/src/style/_toolbar.scss +++ b/src/style/_toolbar.scss @@ -127,13 +127,7 @@ html > body > aside.control-dock > .toolbar-row { } &:hover { - background: transparent; color: color-mix(in srgb, var(--accent-color) 70%, white); - box-shadow: none; - } - - &:active { - transform: translateX(0); } &.previous-vibe:hover { @@ -190,8 +184,7 @@ html > body > aside.control-dock > .toolbar-row { transform var(--transition-time); } - &:hover, - &.active { + &:hover { border-color: rgb(255 255 255 / 10%); background: rgb(255 255 255 / 9%); } @@ -257,7 +250,6 @@ html > body > aside.control-dock > .toolbar-row { flex-wrap: wrap; align-items: center; justify-content: center; - gap: 12px; min-width: 0; padding: 0 4px; @@ -295,14 +287,9 @@ html > body > aside.control-dock > .toolbar-row { > .eraser-size-control, > .mirror-segment-control { - --control-progress: 0%; - --control-rgb: 255 255 255; - --thumb-background: rgb(var(--control-rgb)); - --thumb-height: 28px; --thumb-hover-transform: scale(1.03); --thumb-radius: 50%; --thumb-transform: none; - --thumb-width: 28px; position: relative; display: grid; @@ -445,9 +432,7 @@ html > body > aside.control-dock > .toolbar-row { } > .toolbar-shell { - min-width: 0; flex: 1 1 auto; - gap: 8px; padding: 4px 8px; > nav.buttons { @@ -455,7 +440,6 @@ html > body > aside.control-dock > .toolbar-row { padding-top: 3px; > button { - width: 44px; height: 38px; min-height: 38px; @@ -473,13 +457,11 @@ html > body > aside.control-dock > .toolbar-row { } > .garden-controls { - gap: 8px; padding: 2px 4px; > .swatches { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); - align-items: center; justify-items: center; justify-content: stretch; width: 100%; @@ -500,7 +482,6 @@ html > body > aside.control-dock > .toolbar-row { width: 100%; min-width: 0; height: 42px; - flex-basis: auto; padding: 0 8px; } diff --git a/src/style/common.scss b/src/style/common.scss index f048023..6275681 100644 --- a/src/style/common.scss +++ b/src/style/common.scss @@ -14,14 +14,8 @@ } } -h1, -h2, -h3, -h4, -h5, -h6 { +h1 { font-family: 'Open Sans', sans-serif; - margin-bottom: var(--small-margin); } p { From d2da0d1617c4d802ee8ee8023c08b35201348d3e Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 16 May 2026 16:15:54 +0100 Subject: [PATCH 07/20] lgtm --- src/audio/garden-audio-config.ts | 105 +++++++- src/audio/garden-audio-energy.test.ts | 8 +- src/audio/garden-audio-energy.ts | 19 +- src/audio/garden-audio-gesture-state.ts | 322 +++--------------------- src/audio/garden-audio-graph.ts | 47 ++-- src/audio/garden-audio-input.ts | 61 +---- src/audio/garden-audio-types.ts | 11 - src/audio/garden-audio.test.ts | 58 ++--- src/audio/garden-audio.ts | 146 +++-------- src/audio/generative-piano.test.ts | 11 +- src/audio/generative-piano.ts | 238 +++++++----------- src/audio/noise-burst-player.ts | 21 +- src/audio/piano-sampler.test.ts | 3 +- src/audio/piano-sampler.ts | 46 ++-- src/config.ts | 216 ++++++++++------ src/config/runtime-settings.ts | 11 +- src/config/types.ts | 81 +----- src/game-loop/agent-population.test.ts | 41 ++- src/game-loop/agent-population.ts | 7 +- src/game-loop/frame-performance.ts | 51 ---- src/game-loop/game-loop-settings.ts | 2 - src/game-loop/game-loop.ts | 19 +- src/game-loop/pointer-input.test.ts | 3 - src/game-loop/pointer-input.ts | 28 --- src/pipelines/render/render.wgsl | 12 +- 25 files changed, 531 insertions(+), 1036 deletions(-) diff --git a/src/audio/garden-audio-config.ts b/src/audio/garden-audio-config.ts index 4751bf4..ca87857 100644 --- a/src/audio/garden-audio-config.ts +++ b/src/audio/garden-audio-config.ts @@ -13,6 +13,51 @@ interface GardenAudioColorVoice { panOffset: number; } +export interface GardenAudioRegister { + midiMin: number; + midiMax: number; + preferredMidi: number; + pan: number; +} + +export interface GardenAudioColorPool extends GardenAudioRegister { + scaleDegrees: Array; +} + +interface GardenAudioGenerativePianoConfig { + colorPools: [GardenAudioColorPool, GardenAudioColorPool, GardenAudioColorPool]; + padRegisters: [GardenAudioRegister, GardenAudioRegister, GardenAudioRegister]; + chordBars: number; + supportBarSpacing: number; + supportBarOffset: number; + idleTextureBarSpacing: number; + mediumTextureBarSpacing: number; + textureBeat: number; + highActivityExtraBeat: number; + highActivityExtraThreshold: number; + noteScorePreferenceWeight: number; + noteScoreRegisterWeight: number; + noteScoreRepeatPenalty: number; + gestureAccentSpacingSeconds: number; + gestureAccentMinIntervalSeconds: number; + strokeAccentMinIntervalSeconds: number; + strokeAccentThreshold: number; + stingerSpacingSeconds: number; + stingerDurationSeconds: number; + maxBrushPhraseLayers: number; + brushLayerBaseSeconds: number; + brushLayerEnergySeconds: number; + brushLayerMirrorSeconds: number; + brushLayerMinIntensity: number; + brushStreamIdleIntervalBeats: number; + brushStreamActiveIntervalBeats: number; + brushStreamIntenseIntervalBeats: number; + brushStreamManicIntervalBeats: number; + brushMotifMaxSteps: number; + brushMotifCanonDelaySeconds: number; + padDurationBarScale: number; +} + export interface GardenAudioVibeProfile { rootMidi: number; scale: Array; @@ -30,6 +75,13 @@ export interface GardenAudioConfig { timeSeconds: number; feedback: number; wetGain: number; + erasingActivity: number; + activityFeedbackWeight: number; + feedbackMax: number; + feedbackMin: number; + outputActivityWeight: number; + outputBase: number; + timeRampSeconds: number; }; piano: { maxVoices: number; @@ -38,13 +90,26 @@ export interface GardenAudioConfig { sustainLevel: number; releaseSeconds: number; lowpassHz: number; + filterQ: number; + gainAttackSeconds: number; + lowpassMaxHz: number; + lowpassMinHz: number; + minDurationSeconds: number; + minFadeSeconds: number; + minGain: number; + pitchSemitonesPerOctave: number; + scheduleAheadSeconds: number; + sustainBase: number; + sustainVelocityRange: number; + tailStopExtraSeconds: number; + voiceStealFadeSeconds: number; + voiceStealStopSeconds: number; }; rhythm: { bpm: number; stepsPerBeat: number; stepsPerBar: number; lookaheadSeconds: number; - speedForFullEnergyPixelsPerSecond: number; sparseActivity: number; }; eraser: { @@ -52,7 +117,45 @@ export interface GardenAudioConfig { noiseGain: number; filterMinHz: number; filterMaxHz: number; + durationSeconds: number; }; + energy: { + attackSeconds: number; + decaySeconds: number; + immediateActivityScale: number; + releaseSeconds: number; + strokeDecaySeconds: number; + }; + graph: { + closeGain: number; + closeRampSeconds: number; + delayMaxSeconds: number; + eventBusGain: number; + noiseMax: number; + noiseMin: number; + unlockTickFrequencyHz: number; + unlockTickSeconds: number; + }; + input: { + activeActivityThreshold: number; + distanceWindowForFullActivityPixels: number; + distanceWindowSeconds: number; + fallbackFrameSeconds: number; + manicActivityThreshold: number; + manicModeThreshold: number; + }; + muteGain: number; + muteRampSeconds: number; + noiseBurst: { + attackSeconds: number; + filterQ: number; + offsetRandomSeconds: number; + scheduleAheadSeconds: number; + silentGain: number; + }; + startDelaySeconds: number; + vibeChangeStingerMinIntervalSeconds: number; + generativePiano: GardenAudioGenerativePianoConfig; colorVoices: [GardenAudioColorVoice, GardenAudioColorVoice, GardenAudioColorVoice]; vibes: Record; } diff --git a/src/audio/garden-audio-energy.test.ts b/src/audio/garden-audio-energy.test.ts index 39f8051..ac6a6be 100644 --- a/src/audio/garden-audio-energy.test.ts +++ b/src/audio/garden-audio-energy.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { appConfig } from '../config'; +import { gardenAudioConfig } from './garden-audio-config'; import { GardenAudioEnergy } from './garden-audio-energy'; describe('GardenAudioEnergy', () => { it('suspends activity but keeps a fading level when the gesture ends', () => { - const energy = new GardenAudioEnergy(appConfig.audioEngine); + const energy = new GardenAudioEnergy(gardenAudioConfig); energy.beginGesture(0); energy.recordStroke(0.8, 0.1); @@ -25,7 +25,7 @@ describe('GardenAudioEnergy', () => { }); it('uses recent stroke intensity rather than gesture duration alone', () => { - const energy = new GardenAudioEnergy(appConfig.audioEngine); + const energy = new GardenAudioEnergy(gardenAudioConfig); energy.beginGesture(0); energy.recordStroke(1, 0.1); @@ -39,7 +39,7 @@ describe('GardenAudioEnergy', () => { }); it('raises activity immediately when a stroke is recorded', () => { - const energy = new GardenAudioEnergy(appConfig.audioEngine); + const energy = new GardenAudioEnergy(gardenAudioConfig); energy.beginGesture(0); energy.recordStroke(0.12, 0.05); diff --git a/src/audio/garden-audio-energy.ts b/src/audio/garden-audio-energy.ts index 99bbc0d..67640c5 100644 --- a/src/audio/garden-audio-energy.ts +++ b/src/audio/garden-audio-energy.ts @@ -1,7 +1,5 @@ -import type { GardenAudioEngineConfig } from '../config'; import { clamp01 } from '../utils/clamp'; - -const STROKE_IMMEDIATE_ACTIVITY_SCALE = 0.85; +import type { GardenAudioConfig } from './garden-audio-config'; export class GardenAudioEnergy { private isGestureActive = false; @@ -9,7 +7,7 @@ export class GardenAudioEnergy { private targetEnergy = 0; private lastEnergyUpdateAt = 0; - public constructor(private readonly engineConfig: GardenAudioEngineConfig) {} + public constructor(private readonly config: GardenAudioConfig) {} public beginGesture(now: number): void { this.isGestureActive = true; @@ -25,7 +23,10 @@ export class GardenAudioEnergy { const energy = clamp01(strokeEnergy); this.targetEnergy = Math.max(this.targetEnergy, energy); if (this.isGestureActive) { - this.energy = Math.max(this.energy, energy * STROKE_IMMEDIATE_ACTIVITY_SCALE); + this.energy = Math.max( + this.energy, + energy * this.config.energy.immediateActivityScale + ); } this.lastEnergyUpdateAt ||= now; } @@ -48,15 +49,15 @@ export class GardenAudioEnergy { const elapsedSeconds = Math.max(0, now - this.lastEnergyUpdateAt); this.lastEnergyUpdateAt = now; this.targetEnergy *= Math.exp( - -elapsedSeconds / this.engineConfig.energy.strokeDecaySeconds + -elapsedSeconds / this.config.energy.strokeDecaySeconds ); const target = this.isGestureActive ? this.targetEnergy : 0; - let timeConstant = this.engineConfig.energy.decaySeconds; + let timeConstant = this.config.energy.decaySeconds; if (!this.isGestureActive) { - timeConstant = this.engineConfig.energy.releaseSeconds; + timeConstant = this.config.energy.releaseSeconds; } else if (target > this.energy) { - timeConstant = this.engineConfig.energy.attackSeconds; + timeConstant = this.config.energy.attackSeconds; } const amount = 1 - Math.exp(-elapsedSeconds / timeConstant); this.energy += (target - this.energy) * amount; diff --git a/src/audio/garden-audio-gesture-state.ts b/src/audio/garden-audio-gesture-state.ts index ce5aa4f..e86b29f 100644 --- a/src/audio/garden-audio-gesture-state.ts +++ b/src/audio/garden-audio-gesture-state.ts @@ -1,10 +1,5 @@ -import type { GardenAudioEngineConfig } from '../config'; -import { clamp, clamp01 } from '../utils/clamp'; -import type { - GardenAudioColorIndex, - GardenAudioStroke, - GardenAudioTouchDown, -} from './garden-audio-types'; +import { clamp01 } from '../utils/clamp'; +import type { GardenAudioConfig } from './garden-audio-config'; import type { GardenAudioStrokeMetrics } from './garden-audio-input'; type GardenAudioGestureMode = 'calm' | 'active' | 'manic' | 'afterglow'; @@ -13,216 +8,83 @@ interface GardenAudioGestureFrame { mode: GardenAudioGestureMode; activity: number; maniaAmount: number; - panBias: number; - registerBias: number; - brightnessBias: number; - contour: number; - pressure: number; - pressureDelta: number; - mirrorAmount: number; - speedAmount: number; } -interface GestureSample { +interface GestureDistanceSample { at: number; - speed: number; - acceleration: number; distancePixels: number; - turned: boolean; } -const WINDOW_SECONDS = 0.75; -const BIN_SECONDS = 0.05; -const MIN_TURN_DEGREES = 55; -const MIN_TURN_DISTANCE_PIXELS = 6; - const DEFAULT_FRAME: GardenAudioGestureFrame = { mode: 'calm', activity: 0, maniaAmount: 0, - panBias: 0, - registerBias: 0, - brightnessBias: 0, - contour: 0, - pressure: 0, - pressureDelta: 0, - mirrorAmount: 0, - speedAmount: 0, }; export class GardenAudioGestureState { - private readonly samples: Array = []; + private readonly samples: Array = []; private gestureClockSeconds = 0; - private isGestureActive = false; - private previousPressure = 0; - private previousVelocityPixelsPerSecond = 0; - private previousVector: [number, number] | null = null; - private maniaAmount = 0; private peakActivity = 0; private lastFrame: GardenAudioGestureFrame = DEFAULT_FRAME; - public constructor( - private readonly speedForFullEnergyPixelsPerSecond: number, - private readonly inputConfig: GardenAudioEngineConfig['input'] - ) {} + public constructor(private readonly inputConfig: GardenAudioConfig['input']) {} public beginGesture(): void { this.samples.length = 0; this.gestureClockSeconds = 0; - this.isGestureActive = true; - this.previousPressure = 0; - this.previousVelocityPixelsPerSecond = 0; - this.previousVector = null; - this.maniaAmount = 0; this.peakActivity = 0; this.lastFrame = DEFAULT_FRAME; } public endGesture(): GardenAudioGestureFrame { - this.isGestureActive = false; this.samples.length = 0; - this.previousVector = null; - this.previousVelocityPixelsPerSecond = 0; - this.maniaAmount = 0; + this.gestureClockSeconds = 0; this.lastFrame = { - ...this.lastFrame, - mode: this.peakActivity >= 0.42 ? 'afterglow' : 'calm', - activity: 0, - maniaAmount: 0, - speedAmount: 0, + ...DEFAULT_FRAME, + mode: + this.peakActivity >= this.inputConfig.activeActivityThreshold + ? 'afterglow' + : 'calm', }; + this.peakActivity = 0; return this.lastFrame; } - public recordTouchDown({ - touch, - colorIndex, - mirrorAmount, - pressure, - strength, - }: { - touch: GardenAudioTouchDown; - colorIndex: GardenAudioColorIndex; - mirrorAmount: number; - pressure: number; - strength: number; - }): GardenAudioGestureFrame { - const spatial = getSpatialBias(touch.position, touch.canvasSize); - const normalizedStrength = clamp01(strength); - - this.previousPressure = pressure; - this.peakActivity = Math.max(this.peakActivity, normalizedStrength); - this.lastFrame = { - mode: normalizedStrength >= 0.38 ? 'active' : 'calm', - activity: normalizedStrength, - maniaAmount: 0, - panBias: spatial.panBias, - registerBias: spatial.registerBias, - brightnessBias: spatial.brightnessBias, - contour: colorIndex === 2 ? 0.25 : colorIndex === 0 ? -0.15 : 0, - pressure, - pressureDelta: 0, - mirrorAmount, - speedAmount: 0, - }; - + public recordTouchDown(): GardenAudioGestureFrame { + this.lastFrame = DEFAULT_FRAME; return this.lastFrame; } public recordStroke({ - stroke, metrics, - mirrorAmount, }: { - stroke: GardenAudioStroke; metrics: GardenAudioStrokeMetrics; - mirrorAmount: number; }): GardenAudioGestureFrame { - const elapsedSeconds = this.getElapsedSeconds(stroke); - this.gestureClockSeconds += elapsedSeconds; + this.gestureClockSeconds += metrics.elapsedSeconds; - const dx = stroke.to[0] - stroke.from[0]; - const dy = stroke.to[1] - stroke.from[1]; - const distancePixels = metrics.distancePixels; - const speedRatio = - metrics.speedPixelsPerSecond / - Math.max(1, this.speedForFullEnergyPixelsPerSecond); - const speed = smoothstep(0.45, 1.2, speedRatio); - const acceleration = smoothstep( - 3, - 12, - Math.abs(metrics.speedPixelsPerSecond - this.previousVelocityPixelsPerSecond) / - (Math.max(1, this.speedForFullEnergyPixelsPerSecond) * elapsedSeconds) - ); - const currentVector: [number, number] = - distancePixels > 0.001 ? [dx / distancePixels, dy / distancePixels] : [0, 0]; - const turned = this.getTurned(currentVector, distancePixels, metrics.speedAmount); - const spatial = getSpatialBias(stroke.to, stroke.canvasSize); - const pressureDelta = clamp(metrics.pressure - this.previousPressure, -1, 1); - const contour = distancePixels > 0.001 ? clamp(-dy / distancePixels, -1, 1) : 0; - - if (distancePixels > 0.5) { + if (metrics.distancePixels > 0) { this.samples.push({ at: this.gestureClockSeconds, - speed, - acceleration, - distancePixels, - turned, + distancePixels: metrics.distancePixels, }); } this.trimSamples(); - const features = this.getWindowFeatures(); - const distanceFeature = smoothstep(10, 90, metrics.distancePixels); - const normalIntensity = clamp01( - 0.1 + - features.speed * 0.46 + - metrics.pressure * 0.2 + - distanceFeature * 0.16 + - mirrorAmount * 0.08 + const windowDistancePixels = this.samples.reduce( + (total, sample) => total + sample.distancePixels, + 0 ); - const hasKineticChange = features.acceleration > 0.35 || features.turns > 0.35; - const maniaGate = - !stroke.isErasing && - this.isGestureActive && - this.gestureClockSeconds > 0.2 && - features.pathPixels > 60 && - features.speed > 0.45 && - hasKineticChange; - const maniaEvidence = maniaGate - ? clamp01( - features.speed * 0.34 + - features.acceleration * 0.26 + - features.strokeFrequency * 0.2 + - features.turns * 0.2 - ) * - (1 + mirrorAmount * 0.22) - : 0; - const maniaTarget = smoothstep(0.55, 0.85, maniaEvidence); - const timeConstant = maniaTarget > this.maniaAmount ? 0.12 : 0.65; - const maniaMove = 1 - Math.exp(-elapsedSeconds / timeConstant); + const activity = clamp01( + windowDistancePixels / this.inputConfig.distanceWindowForFullActivityPixels + ); + const maniaAmount = smoothstep(this.inputConfig.manicActivityThreshold, 1, activity); - this.maniaAmount += (maniaTarget - this.maniaAmount) * maniaMove; - this.previousPressure = metrics.pressure; - this.previousVelocityPixelsPerSecond = metrics.speedPixelsPerSecond; - this.previousVector = currentVector; - - const activity = clamp01(normalIntensity + this.maniaAmount * 0.28); this.peakActivity = Math.max(this.peakActivity, activity); this.lastFrame = { - mode: this.getMode(activity, this.maniaAmount), + ...DEFAULT_FRAME, + mode: this.getMode(activity, maniaAmount), activity, - maniaAmount: clamp01(this.maniaAmount), - panBias: spatial.panBias, - registerBias: spatial.registerBias, - brightnessBias: clamp01( - spatial.brightnessBias * 0.65 + metrics.pressure * 0.2 + speed * 0.15 - ), - contour, - pressure: metrics.pressure, - pressureDelta, - mirrorAmount, - speedAmount: metrics.speedAmount, + maniaAmount, }; return this.lastFrame; @@ -235,150 +97,26 @@ export class GardenAudioGestureState { public reset(): void { this.samples.length = 0; this.gestureClockSeconds = 0; - this.isGestureActive = false; - this.previousPressure = 0; - this.previousVelocityPixelsPerSecond = 0; - this.previousVector = null; - this.maniaAmount = 0; this.peakActivity = 0; this.lastFrame = DEFAULT_FRAME; } - private getElapsedSeconds(stroke: GardenAudioStroke): number { - if ( - stroke.elapsedSeconds !== undefined && - Number.isFinite(stroke.elapsedSeconds) && - stroke.elapsedSeconds > 0 - ) { - return clamp(stroke.elapsedSeconds, 0.001, 0.15); - } - - return this.inputConfig.fallbackFrameSeconds; - } - - private getTurned( - currentVector: [number, number], - distancePixels: number, - speedAmount: number - ): boolean { - if ( - !this.previousVector || - distancePixels <= MIN_TURN_DISTANCE_PIXELS || - speedAmount <= 0.35 - ) { - return false; - } - - const dot = clamp( - this.previousVector[0] * currentVector[0] + - this.previousVector[1] * currentVector[1], - -1, - 1 - ); - const degrees = (Math.acos(dot) * 180) / Math.PI; - return degrees > MIN_TURN_DEGREES; - } - private trimSamples(): void { - const earliest = this.gestureClockSeconds - WINDOW_SECONDS; + const earliest = this.gestureClockSeconds - this.inputConfig.distanceWindowSeconds; while (this.samples.length > 0 && this.samples[0].at < earliest) { this.samples.shift(); } } - private getWindowFeatures(): { - speed: number; - acceleration: number; - strokeFrequency: number; - turns: number; - pathPixels: number; - } { - if (this.samples.length === 0) { - return { - speed: 0, - acceleration: 0, - strokeFrequency: 0, - turns: 0, - pathPixels: 0, - }; - } - - const first = this.samples[0]; - const last = this.samples[this.samples.length - 1]; - const spanSeconds = clamp(last.at - first.at, 0.2, WINDOW_SECONDS); - const bins = new Set(); - let pathPixels = 0; - let turnCount = 0; - - this.samples.forEach((sample) => { - if (sample.distancePixels > 1) { - bins.add(Math.floor(sample.at / BIN_SECONDS)); - } - if (sample.turned) { - turnCount += 1; - } - pathPixels += sample.distancePixels; - }); - - return { - speed: percentile(this.samples.map((sample) => sample.speed), 0.75), - acceleration: percentile( - this.samples.map((sample) => sample.acceleration), - 0.75 - ), - strokeFrequency: smoothstep(6, 14, bins.size / spanSeconds), - turns: smoothstep(2, 7, turnCount / spanSeconds), - pathPixels, - }; - } - private getMode(activity: number, maniaAmount: number): GardenAudioGestureMode { - if (maniaAmount >= 0.72) { + if (maniaAmount >= this.inputConfig.manicModeThreshold) { return 'manic'; } - return activity >= 0.38 ? 'active' : 'calm'; + return activity >= this.inputConfig.activeActivityThreshold ? 'active' : 'calm'; } } -const getSpatialBias = ( - position: ArrayLike | undefined, - canvasSize: ArrayLike | undefined -): { - panBias: number; - registerBias: number; - brightnessBias: number; -} => { - if (!position || !canvasSize) { - return { - panBias: 0, - registerBias: 0, - brightnessBias: 0.5, - }; - } - - const width = Math.max(1, canvasSize[0]); - const height = Math.max(1, canvasSize[1]); - const x = clamp01(position[0] / width); - const y = clamp01(position[1] / height); - - return { - panBias: clamp(x * 2 - 1, -1, 1), - registerBias: clamp(1 - y * 2, -1, 1), - brightnessBias: clamp01(1 - y * 0.72), - }; -}; - -const percentile = (values: Array, amount: number): number => { - if (values.length === 0) { - return 0; - } - - const sorted = [...values].sort((a, b) => a - b); - const index = clamp(Math.floor((sorted.length - 1) * amount), 0, sorted.length - 1); - return sorted[index]; -}; - const smoothstep = (edge0: number, edge1: number, value: number): number => { const amount = clamp01((value - edge0) / (edge1 - edge0)); return amount * amount * (3 - 2 * amount); diff --git a/src/audio/garden-audio-graph.ts b/src/audio/garden-audio-graph.ts index 61b9cdf..0364c21 100644 --- a/src/audio/garden-audio-graph.ts +++ b/src/audio/garden-audio-graph.ts @@ -1,10 +1,6 @@ -import type { GardenAudioEngineConfig } from '../config'; import { clamp } from '../utils/clamp'; import { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config'; -const UNLOCK_TICK_SECONDS = 0.035; -const UNLOCK_TICK_FREQUENCY_HZ = 440; - export class GardenAudioGraph { public context: AudioContext | null = null; public eventBus: GainNode | null = null; @@ -16,10 +12,7 @@ export class GardenAudioGraph { private delayFeedback: GainNode | null = null; private delayOutput: GainNode | null = null; - public constructor( - private readonly config: GardenAudioConfig, - private readonly engineConfig: GardenAudioEngineConfig - ) {} + public constructor(private readonly config: GardenAudioConfig) {} public ensureContext(canCreate: boolean): AudioContext | null { if (this.context) { @@ -73,16 +66,16 @@ export class GardenAudioGraph { const gain = this.context.createGain(); source.type = 'sine'; - source.frequency.setValueAtTime(UNLOCK_TICK_FREQUENCY_HZ, now); - gain.gain.setValueAtTime(this.engineConfig.piano.minGain, now); + source.frequency.setValueAtTime(this.config.graph.unlockTickFrequencyHz, now); + gain.gain.setValueAtTime(this.config.piano.minGain, now); gain.gain.exponentialRampToValueAtTime( - this.engineConfig.piano.minGain, - now + UNLOCK_TICK_SECONDS + this.config.piano.minGain, + now + this.config.graph.unlockTickSeconds ); source.connect(gain); gain.connect(this.context.destination); source.start(now); - source.stop(now + UNLOCK_TICK_SECONDS); + source.stop(now + this.config.graph.unlockTickSeconds); source.addEventListener( 'ended', () => { @@ -113,7 +106,7 @@ export class GardenAudioGraph { this.delayNode.delayTime.setTargetAtTime( this.config.delay.timeSeconds * profile.delayTimeMultiplier, this.context.currentTime, - this.engineConfig.graph.delayTimeRampSeconds + this.config.delay.timeRampSeconds ); } @@ -126,22 +119,21 @@ export class GardenAudioGraph { this.delayNode.delayTime.setTargetAtTime( this.config.delay.timeSeconds * profile.delayTimeMultiplier, now, - this.engineConfig.graph.delayTimeRampSeconds + this.config.delay.timeRampSeconds ); this.delayFeedback.gain.setTargetAtTime( clamp( - this.config.delay.feedback + - activity * this.engineConfig.graph.delayActivityFeedbackWeight, - this.engineConfig.graph.delayFeedbackMin, - this.engineConfig.graph.delayFeedbackMax + this.config.delay.feedback + activity * this.config.delay.activityFeedbackWeight, + this.config.delay.feedbackMin, + this.config.delay.feedbackMax ), now, this.config.updateRampSeconds ); this.delayOutput.gain.setTargetAtTime( this.config.delay.wetGain * - (this.engineConfig.graph.delayOutputBase + - activity * this.engineConfig.graph.delayOutputActivityWeight), + (this.config.delay.outputBase + + activity * this.config.delay.outputActivityWeight), now, this.config.updateRampSeconds ); @@ -155,9 +147,9 @@ export class GardenAudioGraph { if (this.masterGain && context.state !== 'closed') { this.masterGain.gain.setTargetAtTime( - this.engineConfig.graph.closeGain, + this.config.graph.closeGain, context.currentTime, - this.engineConfig.graph.closeRampSeconds + this.config.graph.closeRampSeconds ); } @@ -170,7 +162,7 @@ export class GardenAudioGraph { private createDelay(context: AudioContext, masterGain: GainNode): void { const delayInput = context.createGain(); - const delayNode = context.createDelay(2); + const delayNode = context.createDelay(this.config.graph.delayMaxSeconds); const delayFeedback = context.createGain(); const delayOutput = context.createGain(); @@ -192,7 +184,7 @@ export class GardenAudioGraph { private createBuses(context: AudioContext, masterGain: GainNode): void { this.eventBus = context.createGain(); - this.eventBus.gain.value = this.engineConfig.graph.eventBusGain; + this.eventBus.gain.value = this.config.graph.eventBusGain; this.eventBus.connect(masterGain); } @@ -202,9 +194,8 @@ export class GardenAudioGraph { for (let index = 0; index < data.length; index++) { data[index] = - this.engineConfig.graph.noiseMin + - Math.random() * - (this.engineConfig.graph.noiseMax - this.engineConfig.graph.noiseMin); + this.config.graph.noiseMin + + Math.random() * (this.config.graph.noiseMax - this.config.graph.noiseMin); } return buffer; diff --git a/src/audio/garden-audio-input.ts b/src/audio/garden-audio-input.ts index 6e9ab09..fb38f93 100644 --- a/src/audio/garden-audio-input.ts +++ b/src/audio/garden-audio-input.ts @@ -1,70 +1,35 @@ -import type { GardenAudioEngineConfig } from '../config'; -import { clamp01 } from '../utils/clamp'; -import { GardenAudioStroke } from './garden-audio-types'; +import type { GardenAudioConfig } from './garden-audio-config'; +import type { GardenAudioStroke } from './garden-audio-types'; export interface GardenAudioStrokeMetrics { distancePixels: number; - pressure: number; - speedPixelsPerSecond: number; - speedAmount: number; - effectiveEnergy: number; + elapsedSeconds: number; } export const getStrokeMetrics = ( stroke: GardenAudioStroke, - speedForFullEnergyPixelsPerSecond: number, - inputConfig: GardenAudioEngineConfig['input'] + inputConfig: GardenAudioConfig['input'] ): GardenAudioStrokeMetrics => { const dx = stroke.to[0] - stroke.from[0]; const dy = stroke.to[1] - stroke.from[1]; - const distancePixels = Math.hypot(dx, dy); - const speedPixelsPerSecond = getStrokeVelocity(stroke, distancePixels, inputConfig); - const pressure = getPressureAmount(stroke); - const speedAmount = clamp01(speedPixelsPerSecond / speedForFullEnergyPixelsPerSecond); - const strokeEnergy = clamp01( - inputConfig.strokeEnergyBase + - speedAmount * inputConfig.strokeEnergySpeedWeight + - pressure * inputConfig.strokeEnergyPressureWeight - ); - const effectiveEnergy = - strokeEnergy * - (inputConfig.distanceEnergyBase + - clamp01(distancePixels / inputConfig.distanceForFullEnergyPixels) * - inputConfig.distanceEnergyScale); return { - distancePixels, - pressure, - speedPixelsPerSecond, - speedAmount, - effectiveEnergy, + distancePixels: Math.hypot(dx, dy), + elapsedSeconds: getElapsedSeconds(stroke, inputConfig), }; }; -const getStrokeVelocity = ( +const getElapsedSeconds = ( stroke: GardenAudioStroke, - distancePixels: number, - inputConfig: GardenAudioEngineConfig['input'] + inputConfig: GardenAudioConfig['input'] ): number => { if ( - stroke.velocityPixelsPerSecond !== undefined && - Number.isFinite(stroke.velocityPixelsPerSecond) && - stroke.velocityPixelsPerSecond >= 0 + stroke.elapsedSeconds !== undefined && + Number.isFinite(stroke.elapsedSeconds) && + stroke.elapsedSeconds > 0 ) { - return stroke.velocityPixelsPerSecond; + return Math.max(0.001, stroke.elapsedSeconds); } - return distancePixels / inputConfig.fallbackFrameSeconds; -}; - -const getPressureAmount = (stroke: GardenAudioStroke): number => { - if ( - stroke.pressure !== undefined && - Number.isFinite(stroke.pressure) && - stroke.pressure > 0 - ) { - return clamp01(stroke.pressure); - } - - return 0; + return inputConfig.fallbackFrameSeconds; }; diff --git a/src/audio/garden-audio-types.ts b/src/audio/garden-audio-types.ts index 8f03c41..b3f2240 100644 --- a/src/audio/garden-audio-types.ts +++ b/src/audio/garden-audio-types.ts @@ -6,30 +6,19 @@ export interface GardenAudioSnapshot { vibe: VibePreset; selectedColorIndex: number; isErasing: boolean; - mirrorSegmentCount?: number; } export interface GardenAudioStroke { vibe: VibePreset; from: ArrayLike; to: ArrayLike; - canvasSize: ArrayLike; colorIndex: number; isErasing: boolean; - pressure?: number; - velocityPixelsPerSecond?: number; elapsedSeconds?: number; - eraserSizePixels?: number; - mirrorSegmentCount?: number; } export interface GardenAudioTouchDown { - vibe: VibePreset; colorIndex: number; - position?: ArrayLike; - canvasSize?: ArrayLike; - mirrorSegmentCount?: number; - pressure?: number; } export interface GardenAudioStartOptions { diff --git a/src/audio/garden-audio.test.ts b/src/audio/garden-audio.test.ts index 7b48e60..b3687ad 100644 --- a/src/audio/garden-audio.test.ts +++ b/src/audio/garden-audio.test.ts @@ -159,11 +159,7 @@ describe('GardenAudio startup policy', () => { }); it('does not create an AudioContext from passive audio paths', () => { - const audio = new GardenAudio( - makeConfig(), - appConfig.audioEngine, - appConfig.simulation.maxMirrorSegmentCount - ); + const audio = new GardenAudio(makeConfig()); const vibe = VIBE_PRESETS[0]; audio.start(vibe); @@ -171,7 +167,6 @@ describe('GardenAudio startup policy', () => { vibe, from: [0, 0], to: [12, 0], - canvasSize: [100, 100], colorIndex: 0, isErasing: false, }); @@ -180,11 +175,7 @@ describe('GardenAudio startup policy', () => { }); it('only resumes a suspended context from a user gesture start', () => { - const audio = new GardenAudio( - makeConfig(), - appConfig.audioEngine, - appConfig.simulation.maxMirrorSegmentCount - ); + const audio = new GardenAudio(makeConfig()); const vibe = VIBE_PRESETS[0]; audio.start(vibe, { userGesture: true }); @@ -201,11 +192,7 @@ describe('GardenAudio startup policy', () => { }); it('reports AudioContext resume failures as warnings', async () => { - const audio = new GardenAudio( - makeConfig(), - appConfig.audioEngine, - appConfig.simulation.maxMirrorSegmentCount - ); + const audio = new GardenAudio(makeConfig()); const vibe = VIBE_PRESETS[0]; resumeError = new Error('resume rejected'); const addException = vi.spyOn(ErrorHandler, 'addException'); @@ -221,11 +208,7 @@ describe('GardenAudio startup policy', () => { }); it('stays silent without piano samples while preserving eraser noise', () => { - const audio = new GardenAudio( - makeConfig(), - appConfig.audioEngine, - appConfig.simulation.maxMirrorSegmentCount - ); + const audio = new GardenAudio(makeConfig()); const vibe = VIBE_PRESETS[0]; audio.start(vibe, { userGesture: true }); @@ -233,21 +216,15 @@ describe('GardenAudio startup policy', () => { audio.beginGesture(); audio.touchDown({ - vibe, colorIndex: 1, - position: [30, 40], - canvasSize: [100, 100], - pressure: 0.7, }); audio.stroke({ vibe, from: [30, 40], to: [60, 60], - canvasSize: [100, 100], colorIndex: 1, isErasing: false, - pressure: 0.7, - velocityPixelsPerSecond: 1600, + elapsedSeconds: 0.05, }); expect(calls.sourcesStarted).toBe(1); @@ -256,12 +233,9 @@ describe('GardenAudio startup policy', () => { vibe, from: [60, 60], to: [75, 80], - canvasSize: [100, 100], colorIndex: 1, - eraserSizePixels: 30, isErasing: true, - pressure: 0.7, - velocityPixelsPerSecond: 1200, + elapsedSeconds: 0.05, }); expect(calls.sourcesStarted).toBe(2); @@ -277,21 +251,21 @@ describe('GardenAudio startup policy', () => { ); await loadPianoSamples(new FakeAudioContext() as unknown as AudioContext); - const audio = new GardenAudio( - makeConfig(), - appConfig.audioEngine, - appConfig.simulation.maxMirrorSegmentCount - ); + const audio = new GardenAudio(makeConfig()); const vibe = VIBE_PRESETS[0]; audio.start(vibe, { userGesture: true }); audio.beginGesture(); audio.touchDown({ - vibe, colorIndex: 1, - position: [30, 40], - canvasSize: [100, 100], - pressure: 0.7, + }); + audio.stroke({ + vibe, + from: [30, 40], + to: [90, 40], + colorIndex: 1, + elapsedSeconds: 0.05, + isErasing: false, }); const activePianoSources = calls.sources.filter( @@ -308,7 +282,7 @@ describe('GardenAudio startup policy', () => { expect(stoppedVoices.length).toBeGreaterThan(0); stoppedVoices.forEach((source) => { expect(source.stop.mock.calls.at(-1)?.[0]).toBeCloseTo( - 1 + appConfig.audioEngine.piano.voiceStealStopSeconds, + 1 + appConfig.audio.piano.voiceStealStopSeconds, 3 ); }); diff --git a/src/audio/garden-audio.ts b/src/audio/garden-audio.ts index ea54e63..abda120 100644 --- a/src/audio/garden-audio.ts +++ b/src/audio/garden-audio.ts @@ -1,5 +1,4 @@ -import type { GardenAudioEngineConfig } from '../config'; -import { clamp, clamp01 } from '../utils/clamp'; +import { clamp01 } from '../utils/clamp'; import { ErrorHandler, Severity } from '../utils/error-handler'; import { VibePreset } from '../vibes'; import { GardenAudioConfig } from './garden-audio-config'; @@ -44,22 +43,13 @@ export class GardenAudio { private lastEraserAt = Number.NEGATIVE_INFINITY; private lastVibeStingerAt = Number.NEGATIVE_INFINITY; - public constructor( - private readonly config: GardenAudioConfig, - private readonly engineConfig: GardenAudioEngineConfig, - private readonly maxMirrorSegmentCount: number - ) { - this.graph = new GardenAudioGraph(config, engineConfig); - this.piano = new PianoSampler(config, engineConfig, this.graph); - this.noise = new NoiseBurstPlayer(engineConfig, this.graph); - this.energy = new GardenAudioEnergy(engineConfig); - this.gestureState = new GardenAudioGestureState( - config.rhythm.speedForFullEnergyPixelsPerSecond, - engineConfig.input - ); - this.pianoEngine = new GenerativePianoEngine(config, engineConfig, (note) => - this.piano.play(note) - ); + public constructor(private readonly config: GardenAudioConfig) { + this.graph = new GardenAudioGraph(config); + this.piano = new PianoSampler(config, this.graph); + this.noise = new NoiseBurstPlayer(config, this.graph); + this.energy = new GardenAudioEnergy(config); + this.gestureState = new GardenAudioGestureState(config.input); + this.pianoEngine = new GenerativePianoEngine(config, (note) => this.piano.play(note)); } public start(vibe: VibePreset, options: GardenAudioStartOptions = {}): void { @@ -74,7 +64,7 @@ export class GardenAudio { const startupRampSeconds = options.userGesture === true - ? this.engineConfig.muteRampSeconds + ? this.config.muteRampSeconds : this.config.fadeInSeconds; const needsResume = context.state !== 'running' && context.state !== 'closed'; let resumePromise: Promise | null = null; @@ -148,8 +138,8 @@ export class GardenAudio { public setMuted(isMuted: boolean): void { this.isMuted = isMuted; this.graph.setMasterGain( - isMuted ? this.engineConfig.muteGain : this.config.masterVolume, - isMuted ? this.engineConfig.muteRampSeconds : this.config.fadeInSeconds + isMuted ? this.config.muteGain : this.config.masterVolume, + isMuted ? this.config.muteRampSeconds : this.config.fadeInSeconds ); } @@ -183,32 +173,7 @@ export class GardenAudio { } this.selectedColorIndex = normalizeColorIndex(touch.colorIndex); - const mirrorAmount = this.getMirrorAmount(touch.mirrorSegmentCount ?? 1); - const pressure = this.getPressure(touch.pressure); - const strength = clamp01(0.36 + pressure * 0.34 + mirrorAmount * 0.22); - const frame = this.gestureState.recordTouchDown({ - touch, - colorIndex: this.selectedColorIndex, - mirrorAmount, - pressure, - strength, - }); - - this.energy.recordStroke(strength, context.currentTime); - this.pianoEngine.recordTouchDown({ - vibe: touch.vibe, - now: context.currentTime, - strength, - selectedColorIndex: this.selectedColorIndex, - mirrorAmount, - panBias: frame.panBias, - registerBias: frame.registerBias, - brightnessBias: frame.brightnessBias, - contour: frame.contour, - pressureAmount: frame.pressure, - pressureDelta: frame.pressureDelta, - maniaAmount: frame.maniaAmount, - }); + this.gestureState.recordTouchDown(); } public update(snapshot: GardenAudioSnapshot): void { @@ -248,37 +213,25 @@ export class GardenAudio { return; } - const metrics = getStrokeMetrics( - stroke, - this.config.rhythm.speedForFullEnergyPixelsPerSecond, - this.engineConfig.input - ); + const metrics = getStrokeMetrics(stroke, this.config.input); const now = context.currentTime; this.selectedColorIndex = normalizeColorIndex(stroke.colorIndex); + const frame = this.gestureState.recordStroke({ metrics }); + const strokeEnergy = frame.activity; if (stroke.isErasing) { this.energy.recordEraserStroke(); - this.playEraser(stroke, metrics.speedAmount, metrics.pressure, now); + this.playEraser(strokeEnergy, now); return; } - const mirrorAmount = this.getMirrorAmount(stroke.mirrorSegmentCount ?? 1); - const frame = this.gestureState.recordStroke({ stroke, metrics, mirrorAmount }); - const strokeEnergy = frame.activity; this.energy.recordStroke(strokeEnergy, now); this.pianoEngine.recordStroke({ vibe: stroke.vibe, now, activity: strokeEnergy, selectedColorIndex: this.selectedColorIndex, - mirrorAmount, - panBias: frame.panBias, - registerBias: frame.registerBias, - brightnessBias: frame.brightnessBias, - contour: frame.contour, - pressureAmount: frame.pressure, - pressureDelta: frame.pressureDelta, maniaAmount: frame.maniaAmount, }); } @@ -307,10 +260,7 @@ export class GardenAudio { } const now = context.currentTime; - if ( - now - this.lastVibeStingerAt < - this.engineConfig.vibeChangeStingerMinIntervalSeconds - ) { + if (now - this.lastVibeStingerAt < this.config.vibeChangeStingerMinIntervalSeconds) { return; } @@ -318,46 +268,29 @@ export class GardenAudio { this.pianoEngine.playVibeChangeStinger(vibe, now); } - private playEraser( - stroke: GardenAudioStroke, - speedAmount: number, - pressure: number, - now: number - ): void { + private playEraser(activity: number, now: number): void { if (!this.graph.context) { return; } - const sizeAmount = clamp01( - (stroke.eraserSizePixels ?? this.engineConfig.eraser.defaultSizePixels) / - Math.max( - 1, - stroke.canvasSize[0] * this.engineConfig.eraser.canvasWidthRatioForFullSize - ) - ); - const x = clamp01(stroke.to[0] / Math.max(1, stroke.canvasSize[0])); + const distanceActivity = clamp01(activity); + if (distanceActivity <= 0) { + return; + } + const filterHz = this.config.eraser.filterMinHz + (this.config.eraser.filterMaxHz - this.config.eraser.filterMinHz) * - clamp01( - speedAmount * this.engineConfig.eraser.filterSpeedWeight + - pressure * this.engineConfig.eraser.filterPressureWeight + - sizeAmount * this.engineConfig.eraser.filterSizeWeight - ); + distanceActivity; if (now - this.lastEraserAt >= this.config.eraser.minIntervalSeconds) { this.lastEraserAt = now; this.noise.play({ startTime: now, - durationSeconds: this.engineConfig.eraser.durationSeconds, - gain: - this.config.eraser.noiseGain * - (this.engineConfig.eraser.gainBase + - speedAmount * this.engineConfig.eraser.gainSpeedWeight + - pressure * this.engineConfig.eraser.gainPressureWeight + - sizeAmount * this.engineConfig.eraser.gainSizeWeight), + durationSeconds: this.config.eraser.durationSeconds, + gain: this.config.eraser.noiseGain * distanceActivity, filterHz, - pan: clamp(x * 2 - 1, -1, 1), + pan: 0, }); } } @@ -370,7 +303,7 @@ export class GardenAudio { const profile = getVibeProfile(this.config, snapshot.vibe); const activity = snapshot.isErasing - ? this.engineConfig.delay.erasingActivity + ? this.config.delay.erasingActivity : this.energy.getLevel(); this.graph.updateDelay(profile, activity); } @@ -384,27 +317,4 @@ export class GardenAudio { this.graph.applyDelayProfile(getVibeProfile(this.config, vibe)); this.pianoEngine.cue(this.graph.context.currentTime); } - - private getMirrorAmount(mirrorSegmentCount: number): number { - const maxMirrorSegmentCount = Math.max(1, this.maxMirrorSegmentCount); - const segmentCount = clamp( - Number.isFinite(mirrorSegmentCount) ? mirrorSegmentCount : 1, - 1, - maxMirrorSegmentCount - ); - - if (maxMirrorSegmentCount <= 1) { - return 0; - } - - return clamp01((segmentCount - 1) / (maxMirrorSegmentCount - 1)); - } - - private getPressure(pressure: number | undefined): number { - if (pressure !== undefined && Number.isFinite(pressure) && pressure > 0) { - return clamp01(pressure); - } - - return 0; - } } diff --git a/src/audio/generative-piano.test.ts b/src/audio/generative-piano.test.ts index 90a65ad..b43c102 100644 --- a/src/audio/generative-piano.test.ts +++ b/src/audio/generative-piano.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { appConfig } from '../config'; import { VIBE_PRESETS } from '../vibes'; import { gardenAudioConfig } from './garden-audio-config'; import { PianoNote } from './garden-audio-types'; @@ -8,13 +7,9 @@ import { GenerativePianoEngine } from './generative-piano'; const makeEngine = () => { const notes: Array = []; - const engine = new GenerativePianoEngine( - gardenAudioConfig, - appConfig.audioEngine, - (note) => { - notes.push(note); - } - ); + const engine = new GenerativePianoEngine(gardenAudioConfig, (note) => { + notes.push(note); + }); return { engine, notes }; }; diff --git a/src/audio/generative-piano.ts b/src/audio/generative-piano.ts index 2e3ce1e..d3c512f 100644 --- a/src/audio/generative-piano.ts +++ b/src/audio/generative-piano.ts @@ -1,9 +1,10 @@ -import type { GardenAudioEngineConfig } from '../config'; import { clamp, clamp01 } from '../utils/clamp'; import { VibePreset } from '../vibes'; import { GardenAudioChord, + GardenAudioColorPool, GardenAudioConfig, + GardenAudioRegister, GardenAudioVibeProfile, } from './garden-audio-config'; import { @@ -51,17 +52,6 @@ interface TouchDownRequest { maniaAmount?: number; } -interface Register { - midiMin: number; - midiMax: number; - preferredMidi: number; - pan: number; -} - -interface ColorPool extends Register { - scaleDegrees: ReadonlyArray; -} - interface PitchCandidate { midi: number; preference: number; @@ -89,81 +79,6 @@ interface BrushPhraseLayer { maniaAmount: number; } -const COLOR_POOLS: [ColorPool, ColorPool, ColorPool] = [ - { - midiMin: 48, - midiMax: 67, - preferredMidi: 55, - pan: -0.18, - scaleDegrees: [0, 1, 2, 4], - }, - { - midiMin: 55, - midiMax: 74, - preferredMidi: 63, - pan: 0, - scaleDegrees: [1, 2, 3, 5], - }, - { - midiMin: 62, - midiMax: 81, - preferredMidi: 72, - pan: 0.18, - scaleDegrees: [2, 3, 4, 6], - }, -]; - -const PAD_REGISTERS: [Register, Register, Register] = [ - { - midiMin: 40, - midiMax: 55, - preferredMidi: 48, - pan: -0.12, - }, - { - midiMin: 48, - midiMax: 64, - preferredMidi: 55, - pan: 0.08, - }, - { - midiMin: 58, - midiMax: 76, - preferredMidi: 67, - pan: 0.2, - }, -]; - -const CHORD_BARS = 4; -const SUPPORT_BAR_SPACING = 2; -const SUPPORT_BAR_OFFSET = 1; -const IDLE_TEXTURE_BAR_SPACING = 2; -const MEDIUM_TEXTURE_BAR_SPACING = 1; -const TEXTURE_BEAT = 2; -const HIGH_ACTIVITY_EXTRA_BEAT = 3; -const HIGH_ACTIVITY_EXTRA_THRESHOLD = 0.45; -const NOTE_SCORE_PREFERENCE_WEIGHT = 1.8; -const NOTE_SCORE_REGISTER_WEIGHT = 0.28; -const NOTE_SCORE_REPEAT_PENALTY = 3.2; -const GESTURE_ACCENT_SPACING_SECONDS = 0.26; -const GESTURE_ACCENT_MIN_INTERVAL_SECONDS = 2.5; -const STROKE_ACCENT_MIN_INTERVAL_SECONDS = 3.2; -const STROKE_ACCENT_THRESHOLD = 0.58; -const STINGER_SPACING_SECONDS = 0.08; -const STINGER_DURATION_SECONDS = 1.1; -const MAX_BRUSH_PHRASE_LAYERS = 5; -const BRUSH_LAYER_BASE_SECONDS = 5.5; -const BRUSH_LAYER_ENERGY_SECONDS = 2.5; -const BRUSH_LAYER_MIRROR_SECONDS = 3; -const BRUSH_LAYER_MIN_INTENSITY = 0.08; -const BRUSH_STREAM_IDLE_INTERVAL_BEATS = 2; -const BRUSH_STREAM_ACTIVE_INTERVAL_BEATS = 1; -const BRUSH_STREAM_INTENSE_INTERVAL_BEATS = 0.5; -const BRUSH_STREAM_MANIC_INTERVAL_BEATS = 0.25; -const BRUSH_MOTIF_MAX_STEPS = 8; -const BRUSH_MOTIF_CANON_DELAY_SECONDS = 0.055; -const PAD_DURATION_BAR_SCALE = 0.46; - export class GenerativePianoEngine { private nextBeatAt: number | null = null; private timelineStartedAt: number | null = null; @@ -183,23 +98,26 @@ export class GenerativePianoEngine { public constructor( private readonly config: GardenAudioConfig, - private readonly engineConfig: GardenAudioEngineConfig, private readonly playNote: (note: PianoNote) => void ) {} + private get generation(): GardenAudioConfig['generativePiano'] { + return this.config.generativePiano; + } + public prime(now: number): void { if (this.nextBeatAt === null) { - this.nextBeatAt = now + this.engineConfig.startDelaySeconds; + this.nextBeatAt = now + this.config.startDelaySeconds; } this.timelineStartedAt ??= now; - this.nextBrushStreamAt ??= now + this.engineConfig.startDelaySeconds; + this.nextBrushStreamAt ??= now + this.config.startDelaySeconds; } public cue(now: number): void { - this.nextBeatAt = now + this.engineConfig.startDelaySeconds; + this.nextBeatAt = now + this.config.startDelaySeconds; this.timelineStartedAt = now; this.beatIndex = 0; - this.nextBrushStreamAt = now + this.engineConfig.startDelaySeconds; + this.nextBrushStreamAt = now + this.config.startDelaySeconds; this.brushStreamNoteIndex = 0; this.lastBrushStreamMidi = null; } @@ -288,7 +206,7 @@ export class GenerativePianoEngine { if ( this.isWaitingForGestureAccent && - now - this.lastGestureAccentAt >= GESTURE_ACCENT_MIN_INTERVAL_SECONDS + now - this.lastGestureAccentAt >= this.generation.gestureAccentMinIntervalSeconds ) { this.recordTouchDown({ vibe, @@ -310,8 +228,8 @@ export class GenerativePianoEngine { ...normalizedMotif, }); if ( - strength >= STROKE_ACCENT_THRESHOLD && - now - this.lastStrokeAccentAt >= STROKE_ACCENT_MIN_INTERVAL_SECONDS + strength >= this.generation.strokeAccentThreshold && + now - this.lastStrokeAccentAt >= this.generation.strokeAccentMinIntervalSeconds ) { this.lastStrokeAccentAt = now; this.playGestureAccent(vibe, now, selectedColorIndex, strength, 1); @@ -361,7 +279,10 @@ export class GenerativePianoEngine { const rootMidi = profile.rootMidi + chord.rootOffset; const notes = [ { - midi: this.chooseMidi({ baseMidi: rootMidi, offsets: [0] }, PAD_REGISTERS[0]), + midi: this.chooseMidi( + { baseMidi: rootMidi, offsets: [0] }, + this.generation.padRegisters[0] + ), velocity: 0.1, pan: -0.16, delaySend: 0.012, @@ -369,7 +290,7 @@ export class GenerativePianoEngine { { midi: this.chooseMidi( { baseMidi: rootMidi, offsets: [intervals[1], intervals[2]] }, - PAD_REGISTERS[1] + this.generation.padRegisters[1] ), velocity: 0.085, pan: 0, @@ -378,7 +299,7 @@ export class GenerativePianoEngine { { midi: this.chooseMidi( { baseMidi: rootMidi, offsets: [intervals[3], intervals[2]] }, - PAD_REGISTERS[2] + this.generation.padRegisters[2] ), velocity: 0.07, pan: 0.16, @@ -389,9 +310,9 @@ export class GenerativePianoEngine { notes.forEach((note, index) => { this.playNote({ ...note, - durationSeconds: STINGER_DURATION_SECONDS, + durationSeconds: this.generation.stingerDurationSeconds, lowpassHz: this.getLowpassHz(profile, note.midi, 0.35), - startTime: now + index * STINGER_SPACING_SECONDS, + startTime: now + index * this.generation.stingerSpacingSeconds, }); }); } @@ -429,7 +350,7 @@ export class GenerativePianoEngine { const beatInBar = beatIndex % beatsPerBar; const barIndex = Math.floor(beatIndex / beatsPerBar); - if (beatInBar === 0 && barIndex % CHORD_BARS === 0) { + if (beatInBar === 0 && barIndex % this.generation.chordBars === 0) { this.playPadChord(profile, barIndex, startTime, expression); } @@ -437,13 +358,16 @@ export class GenerativePianoEngine { this.playSupportNote(profile, barIndex, startTime, expression, selectedColorIndex); } - if (beatInBar === TEXTURE_BEAT && this.shouldPlayTexture(expression, barIndex)) { + if ( + beatInBar === this.generation.textureBeat && + this.shouldPlayTexture(expression, barIndex) + ) { this.playTextureNote(profile, barIndex, startTime, expression, selectedColorIndex); } if ( - beatInBar === HIGH_ACTIVITY_EXTRA_BEAT && - expression >= HIGH_ACTIVITY_EXTRA_THRESHOLD + beatInBar === this.generation.highActivityExtraBeat && + expression >= this.generation.highActivityExtraThreshold ) { this.playTextureNote( profile, @@ -464,21 +388,24 @@ export class GenerativePianoEngine { const chord = this.getChord(profile, barIndex); const intervals = getChordIntervals(chord, true); const rootMidi = profile.rootMidi + chord.rootOffset; - const durationSeconds = this.getBarDurationSeconds() * CHORD_BARS * PAD_DURATION_BAR_SCALE; + const durationSeconds = + this.getBarDurationSeconds() * + this.generation.chordBars * + this.generation.padDurationBarScale; const notes = [ { source: { baseMidi: rootMidi, offsets: [0] }, - register: PAD_REGISTERS[0], + register: this.generation.padRegisters[0], velocity: 0.052, }, { source: { baseMidi: rootMidi, offsets: [intervals[1]] }, - register: PAD_REGISTERS[1], + register: this.generation.padRegisters[1], velocity: 0.041, }, { source: { baseMidi: rootMidi, offsets: [intervals[3], intervals[2]] }, - register: PAD_REGISTERS[2], + register: this.generation.padRegisters[2], velocity: 0.033, }, ]; @@ -504,7 +431,7 @@ export class GenerativePianoEngine { expression: number, selectedColorIndex: GardenAudioColorIndex ): void { - const pool = COLOR_POOLS[selectedColorIndex]; + const pool = this.generation.colorPools[selectedColorIndex]; const chord = this.getChord(profile, barIndex); const chordIntervals = getChordIntervals(chord, false); const rootMidi = profile.rootMidi + chord.rootOffset; @@ -539,7 +466,7 @@ export class GenerativePianoEngine { expression: number, selectedColorIndex: GardenAudioColorIndex ): void { - const pool = COLOR_POOLS[selectedColorIndex]; + const pool = this.generation.colorPools[selectedColorIndex]; const degrees = this.rotate(pool.scaleDegrees, barIndex + selectedColorIndex); const midi = this.chooseMidi( { @@ -573,7 +500,7 @@ export class GenerativePianoEngine { noteCount: number ): void { const profile = getVibeProfile(this.config, vibe); - const pool = COLOR_POOLS[selectedColorIndex]; + const pool = this.generation.colorPools[selectedColorIndex]; const degrees = this.rotate(pool.scaleDegrees, Math.round(strength * 3)); for (let index = 0; index < noteCount; index += 1) { @@ -598,8 +525,8 @@ export class GenerativePianoEngine { this.config.colorVoices[selectedColorIndex].velocityMultiplier, startTime: now + - this.engineConfig.startDelaySeconds + - index * GESTURE_ACCENT_SPACING_SECONDS, + this.config.startDelaySeconds + + index * this.generation.gestureAccentSpacingSeconds, durationSeconds: 0.48 + strength * 0.22, pan: this.getColorPan(selectedColorIndex), delaySend: 0.012, @@ -626,7 +553,7 @@ export class GenerativePianoEngine { brightnessBias: number; }): void { const profile = getVibeProfile(this.config, vibe); - const pool = COLOR_POOLS[selectedColorIndex]; + const pool = this.generation.colorPools[selectedColorIndex]; const register = this.getBiasedRegister(pool, registerBias, 0); const chord = this.getChord(profile, this.getGlobalBarIndex(now)); const chordIntervals = getChordIntervals(chord, false); @@ -688,9 +615,9 @@ export class GenerativePianoEngine { maniaAmount: number; }): void { const lifetimeSeconds = - BRUSH_LAYER_BASE_SECONDS + - strength * BRUSH_LAYER_ENERGY_SECONDS + - mirrorAmount * BRUSH_LAYER_MIRROR_SECONDS; + this.generation.brushLayerBaseSeconds + + strength * this.generation.brushLayerEnergySeconds + + mirrorAmount * this.generation.brushLayerMirrorSeconds; this.brushPhraseLayers.push({ vibe, @@ -713,8 +640,10 @@ export class GenerativePianoEngine { maniaAmount, }); - if (this.brushPhraseLayers.length > MAX_BRUSH_PHRASE_LAYERS) { - this.brushPhraseLayers = this.brushPhraseLayers.slice(-MAX_BRUSH_PHRASE_LAYERS); + if (this.brushPhraseLayers.length > this.generation.maxBrushPhraseLayers) { + this.brushPhraseLayers = this.brushPhraseLayers.slice( + -this.generation.maxBrushPhraseLayers + ); } } @@ -762,8 +691,8 @@ export class GenerativePianoEngine { layer.motifOffsets.push( this.getMotifOffset({ registerBias, contour, pressureDelta, strength }) ); - if (layer.motifOffsets.length > BRUSH_MOTIF_MAX_STEPS) { - layer.motifOffsets = layer.motifOffsets.slice(-BRUSH_MOTIF_MAX_STEPS); + if (layer.motifOffsets.length > this.generation.brushMotifMaxSteps) { + layer.motifOffsets = layer.motifOffsets.slice(-this.generation.brushMotifMaxSteps); } } @@ -780,8 +709,8 @@ export class GenerativePianoEngine { activity: number; selectedColorIndex: GardenAudioColorIndex; }): void { - const earliestStart = now + this.engineConfig.piano.scheduleAheadSeconds; - this.nextBrushStreamAt ??= now + this.engineConfig.startDelaySeconds; + const earliestStart = now + this.config.piano.scheduleAheadSeconds; + this.nextBrushStreamAt ??= now + this.config.startDelaySeconds; this.brushPhraseLayers = this.brushPhraseLayers.filter( (layer) => layer.expiresAt > earliestStart @@ -795,7 +724,7 @@ export class GenerativePianoEngine { while (this.nextBrushStreamAt <= lookaheadEnd) { const frame = this.getBrushStreamFrame(this.nextBrushStreamAt, activity); - if (frame.intensity >= BRUSH_LAYER_MIN_INTENSITY) { + if (frame.intensity >= this.generation.brushLayerMinIntensity) { this.playBrushStreamNote({ vibe, startTime: this.nextBrushStreamAt, @@ -823,7 +752,7 @@ export class GenerativePianoEngine { layer: BrushPhraseLayer | null; }): void { const profile = getVibeProfile(this.config, vibe); - const pool = COLOR_POOLS[selectedColorIndex]; + const pool = this.generation.colorPools[selectedColorIndex]; const maniaAmount = layer?.maniaAmount ?? clamp01((intensity - 0.82) / 0.18); const register = this.getBiasedRegister( pool, @@ -860,7 +789,10 @@ export class GenerativePianoEngine { 0.62 ); const delaySend = clamp( - 0.012 + intensity * 0.011 + (layer?.mirrorAmount ?? 0) * 0.004 - maniaAmount * 0.006, + 0.012 + + intensity * 0.011 + + (layer?.mirrorAmount ?? 0) * 0.004 - + maniaAmount * 0.006, 0.006, 0.032 ); @@ -888,7 +820,10 @@ export class GenerativePianoEngine { ), }); - if (maniaAmount >= 0.62 && (this.brushStreamNoteIndex % 2 === 1 || intensity >= 0.9)) { + if ( + maniaAmount >= 0.62 && + (this.brushStreamNoteIndex % 2 === 1 || intensity >= 0.9) + ) { const echoMidi = midi + 12 <= 88 ? midi + 12 : midi - 12; this.playNote({ midi: echoMidi, @@ -897,7 +832,7 @@ export class GenerativePianoEngine { this.config.colorVoices[selectedColorIndex].velocityMultiplier, startTime: startTime + - BRUSH_MOTIF_CANON_DELAY_SECONDS + + this.generation.brushMotifCanonDelaySeconds + (layer?.mirrorAmount ?? 0) * 0.04, durationSeconds: Math.max(0.11, durationSeconds * 0.68), pan: clamp(-pan * 0.75, -1, 1), @@ -948,12 +883,12 @@ export class GenerativePianoEngine { private getBrushStreamIntervalSeconds(intensity: number): number { const intervalBeats = intensity >= 0.85 - ? BRUSH_STREAM_MANIC_INTERVAL_BEATS + ? this.generation.brushStreamManicIntervalBeats : intensity >= 0.62 - ? BRUSH_STREAM_INTENSE_INTERVAL_BEATS + ? this.generation.brushStreamIntenseIntervalBeats : intensity >= 0.34 - ? BRUSH_STREAM_ACTIVE_INTERVAL_BEATS - : BRUSH_STREAM_IDLE_INTERVAL_BEATS; + ? this.generation.brushStreamActiveIntervalBeats + : this.generation.brushStreamIdleIntervalBeats; return this.getBeatDurationSeconds() * intervalBeats; } @@ -1037,7 +972,7 @@ export class GenerativePianoEngine { selectedColorIndex, }: { layer: BrushPhraseLayer | null; - pool: ColorPool; + pool: GardenAudioColorPool; selectedColorIndex: GardenAudioColorIndex; }): Array { const colorOffset = this.config.colorVoices[selectedColorIndex].scaleDegreeOffset; @@ -1063,10 +998,10 @@ export class GenerativePianoEngine { } private getBiasedRegister( - register: Register, + register: GardenAudioRegister, registerBias: number, maniaAmount: number - ): Register { + ): GardenAudioRegister { const shift = Math.round(registerBias * 7 + maniaAmount * 4); const midiMin = clamp(register.midiMin + shift, 36, 86); const midiMax = clamp(register.midiMax + shift, midiMin + 4, 91); @@ -1101,7 +1036,7 @@ export class GenerativePianoEngine { private chooseMidi( pitchSource: PitchSource, - register: Register, + register: GardenAudioRegister, previousMidi: number | null = null, avoidRepeat = false ): number { @@ -1122,7 +1057,7 @@ export class GenerativePianoEngine { private getCandidates( pitchSource: PitchSource, - register: Register + register: GardenAudioRegister ): Array { const candidates: Array = []; @@ -1140,15 +1075,18 @@ export class GenerativePianoEngine { private scoreCandidate( candidate: PitchCandidate, - register: Register, + register: GardenAudioRegister, previousMidi: number, avoidRepeat: boolean ): number { return ( Math.abs(candidate.midi - previousMidi) + - Math.abs(candidate.midi - register.preferredMidi) * NOTE_SCORE_REGISTER_WEIGHT + - candidate.preference * NOTE_SCORE_PREFERENCE_WEIGHT + - (avoidRepeat && candidate.midi === previousMidi ? NOTE_SCORE_REPEAT_PENALTY : 0) + Math.abs(candidate.midi - register.preferredMidi) * + this.generation.noteScoreRegisterWeight + + candidate.preference * this.generation.noteScorePreferenceWeight + + (avoidRepeat && candidate.midi === previousMidi + ? this.generation.noteScoreRepeatPenalty + : 0) ); } @@ -1157,15 +1095,17 @@ export class GenerativePianoEngine { return true; } - return barIndex % SUPPORT_BAR_SPACING === SUPPORT_BAR_OFFSET; + return ( + barIndex % this.generation.supportBarSpacing === this.generation.supportBarOffset + ); } private shouldPlayTexture(expression: number, barIndex: number): boolean { const spacing = expression < 0.35 - ? IDLE_TEXTURE_BAR_SPACING + ? this.generation.idleTextureBarSpacing : expression < 0.7 - ? MEDIUM_TEXTURE_BAR_SPACING + ? this.generation.mediumTextureBarSpacing : 1; return barIndex % spacing === (spacing === 1 ? 0 : 1); @@ -1188,7 +1128,7 @@ export class GenerativePianoEngine { private getChord(profile: GardenAudioVibeProfile, barIndex: number): GardenAudioChord { const progressionIndex = - Math.floor(barIndex / CHORD_BARS) % profile.progression.length; + Math.floor(barIndex / this.generation.chordBars) % profile.progression.length; return profile.progression[progressionIndex]; } @@ -1199,7 +1139,7 @@ export class GenerativePianoEngine { } private getColorPan(selectedColorIndex: GardenAudioColorIndex): number { - const pool = COLOR_POOLS[selectedColorIndex]; + const pool = this.generation.colorPools[selectedColorIndex]; const colorVoice = this.config.colorVoices[selectedColorIndex]; return clamp(pool.pan + colorVoice.panOffset * 0.35, -1, 1); } @@ -1213,8 +1153,8 @@ export class GenerativePianoEngine { return clamp( this.config.piano.lowpassHz * profile.brightness * (0.58 + expression * 0.32) + midiLift, - this.engineConfig.piano.lowpassMinHz, - this.engineConfig.piano.lowpassMaxHz + this.config.piano.lowpassMinHz, + this.config.piano.lowpassMaxHz ); } @@ -1223,7 +1163,7 @@ export class GenerativePianoEngine { return; } - const earliestStart = now + this.engineConfig.piano.scheduleAheadSeconds; + const earliestStart = now + this.config.piano.scheduleAheadSeconds; if (this.nextBeatAt >= earliestStart) { return; } diff --git a/src/audio/noise-burst-player.ts b/src/audio/noise-burst-player.ts index 31b3045..9b1341e 100644 --- a/src/audio/noise-burst-player.ts +++ b/src/audio/noise-burst-player.ts @@ -1,10 +1,10 @@ -import type { GardenAudioEngineConfig } from '../config'; +import type { GardenAudioConfig } from './garden-audio-config'; import { GardenAudioGraph } from './garden-audio-graph'; import { NoiseBurst } from './garden-audio-types'; export class NoiseBurstPlayer { public constructor( - private readonly engineConfig: GardenAudioEngineConfig, + private readonly config: GardenAudioConfig, private readonly graph: GardenAudioGraph ) {} @@ -15,7 +15,7 @@ export class NoiseBurstPlayer { } const scheduledStart = Math.max( - context.currentTime + this.engineConfig.noiseBurst.scheduleAheadSeconds, + context.currentTime + this.config.noiseBurst.scheduleAheadSeconds, startTime ); const source = context.createBufferSource(); @@ -27,16 +27,13 @@ export class NoiseBurstPlayer { source.buffer = noiseBuffer; filter.type = 'bandpass'; filter.frequency.setValueAtTime(filterHz, scheduledStart); - filter.Q.value = this.engineConfig.noiseBurst.filterQ; - envelope.gain.setValueAtTime(this.engineConfig.noiseBurst.silentGain, scheduledStart); + filter.Q.value = this.config.noiseBurst.filterQ; + envelope.gain.setValueAtTime(this.config.noiseBurst.silentGain, scheduledStart); envelope.gain.exponentialRampToValueAtTime( - Math.max(this.engineConfig.noiseBurst.silentGain, gain), - scheduledStart + this.engineConfig.noiseBurst.attackSeconds - ); - envelope.gain.exponentialRampToValueAtTime( - this.engineConfig.noiseBurst.silentGain, - stopAt + Math.max(this.config.noiseBurst.silentGain, gain), + scheduledStart + this.config.noiseBurst.attackSeconds ); + envelope.gain.exponentialRampToValueAtTime(this.config.noiseBurst.silentGain, stopAt); panner.pan.setValueAtTime(pan, scheduledStart); source.connect(filter); @@ -45,7 +42,7 @@ export class NoiseBurstPlayer { panner.connect(eventBus); source.start( scheduledStart, - Math.random() * this.engineConfig.noiseBurst.offsetRandomSeconds + Math.random() * this.config.noiseBurst.offsetRandomSeconds ); source.stop(stopAt); source.addEventListener( diff --git a/src/audio/piano-sampler.test.ts b/src/audio/piano-sampler.test.ts index 24c71f9..66f9ade 100644 --- a/src/audio/piano-sampler.test.ts +++ b/src/audio/piano-sampler.test.ts @@ -1,6 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { appConfig } from '../config'; import { gardenAudioConfig } from './garden-audio-config'; import type { GardenAudioGraph } from './garden-audio-graph'; import { PianoSampler } from './piano-sampler'; @@ -70,7 +69,7 @@ const makeSampler = (context: AudioContext): PianoSampler => { eventBus, } as unknown as GardenAudioGraph; - return new PianoSampler(gardenAudioConfig, appConfig.audioEngine, graph); + return new PianoSampler(gardenAudioConfig, graph); }; describe('PianoSampler', () => { diff --git a/src/audio/piano-sampler.ts b/src/audio/piano-sampler.ts index 3320b73..3ec03d2 100644 --- a/src/audio/piano-sampler.ts +++ b/src/audio/piano-sampler.ts @@ -1,4 +1,3 @@ -import type { GardenAudioEngineConfig } from '../config'; import { clamp, clamp01 } from '../utils/clamp'; import { GardenAudioConfig } from './garden-audio-config'; import { GardenAudioGraph } from './garden-audio-graph'; @@ -12,7 +11,6 @@ export class PianoSampler { public constructor( private readonly config: GardenAudioConfig, - private readonly engineConfig: GardenAudioEngineConfig, private readonly graph: GardenAudioGraph ) {} @@ -54,21 +52,20 @@ export class PianoSampler { } const scheduledStart = Math.max( - context.currentTime + this.engineConfig.piano.scheduleAheadSeconds, + context.currentTime + this.config.piano.scheduleAheadSeconds, startTime ); const noteVelocity = clamp01(velocity); const noteGainValue = Math.max( - this.engineConfig.piano.minGain, + this.config.piano.minGain, this.config.piano.gain * noteVelocity ); const sustainSeconds = this.config.piano.sustainSeconds * - (this.engineConfig.piano.sustainBase + - noteVelocity * this.engineConfig.piano.sustainVelocityRange); + (this.config.piano.sustainBase + + noteVelocity * this.config.piano.sustainVelocityRange); const sustainAt = - scheduledStart + - Math.max(this.engineConfig.piano.minDurationSeconds, durationSeconds); + scheduledStart + Math.max(this.config.piano.minDurationSeconds, durationSeconds); const releaseAt = sustainAt + sustainSeconds; const releaseSeconds = this.config.piano.releaseSeconds; const stopAt = releaseAt + releaseSeconds; @@ -88,36 +85,29 @@ export class PianoSampler { source.buffer = sample.buffer; source.playbackRate.setValueAtTime( - Math.pow(2, (midi - sample.midi) / this.engineConfig.piano.pitchSemitonesPerOctave), + Math.pow(2, (midi - sample.midi) / this.config.piano.pitchSemitonesPerOctave), scheduledStart ); filter.type = 'lowpass'; filter.frequency.setValueAtTime( - clamp( - lowpassHz, - this.engineConfig.piano.lowpassMinHz, - this.engineConfig.piano.lowpassMaxHz - ), + clamp(lowpassHz, this.config.piano.lowpassMinHz, this.config.piano.lowpassMaxHz), scheduledStart ); - filter.Q.value = this.engineConfig.piano.filterQ; - gain.gain.setValueAtTime(this.engineConfig.piano.minGain, scheduledStart); + filter.Q.value = this.config.piano.filterQ; + gain.gain.setValueAtTime(this.config.piano.minGain, scheduledStart); gain.gain.exponentialRampToValueAtTime( noteGainValue, - scheduledStart + this.engineConfig.piano.gainAttackSeconds + scheduledStart + this.config.piano.gainAttackSeconds ); gain.gain.setTargetAtTime( - Math.max( - this.engineConfig.piano.minGain, - noteGainValue * this.config.piano.sustainLevel - ), + Math.max(this.config.piano.minGain, noteGainValue * this.config.piano.sustainLevel), sustainAt, Math.max( - this.engineConfig.piano.minFadeSeconds, - sustainSeconds * this.engineConfig.piano.sustainBase + this.config.piano.minFadeSeconds, + sustainSeconds * this.config.piano.sustainBase ) ); - gain.gain.setTargetAtTime(this.engineConfig.piano.minGain, releaseAt, releaseSeconds); + gain.gain.setTargetAtTime(this.config.piano.minGain, releaseAt, releaseSeconds); panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart); source.connect(filter); @@ -133,7 +123,7 @@ export class PianoSampler { } source.start(scheduledStart); - source.stop(stopAt + this.engineConfig.piano.tailStopExtraSeconds); + source.stop(stopAt + this.config.piano.tailStopExtraSeconds); this.activeVoices.push({ gain, source, startAt: scheduledStart, stopAt }); source.addEventListener( @@ -186,13 +176,13 @@ export class PianoSampler { } private stopVoice(voice: ActivePianoVoice, now: number): void { - const stopAt = now + this.engineConfig.piano.voiceStealStopSeconds; + const stopAt = now + this.config.piano.voiceStealStopSeconds; voice.gain.gain.cancelScheduledValues(now); voice.gain.gain.setTargetAtTime( - this.engineConfig.piano.minGain, + this.config.piano.minGain, now, - this.engineConfig.piano.voiceStealFadeSeconds + this.config.piano.voiceStealFadeSeconds ); voice.stopAt = stopAt; try { diff --git a/src/config.ts b/src/config.ts index feacf71..c7e7c33 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,10 +1,10 @@ +import { ADAPTIVE_AGENT_CAP_MAX } from './config/agent-budget'; import { runtimeSettings } from './config/runtime-settings'; import type { GardenAppConfig } from './config/types'; import { audioVibes, defaultVibeId, vibePresets } from './config/vibe-presets'; export type { GardenAppConfig, - GardenAudioEngineConfig, GardenRuntimeSettings, NumberControlConfig, VibePreset, @@ -20,6 +20,13 @@ export const appConfig = { timeSeconds: 0.46, feedback: 0.12, wetGain: 0.044, + erasingActivity: 0.12, + activityFeedbackWeight: 0.08, + feedbackMax: 0.32, + feedbackMin: 0.04, + outputActivityWeight: 0.5, + outputBase: 0.65, + timeRampSeconds: 0.12, }, piano: { maxVoices: 24, @@ -28,13 +35,26 @@ export const appConfig = { sustainLevel: 0.32, releaseSeconds: 0.24, lowpassHz: 7600, + filterQ: 0.7, + gainAttackSeconds: 0.006, + lowpassMaxHz: 12000, + lowpassMinHz: 1400, + minDurationSeconds: 0.08, + minFadeSeconds: 0.08, + minGain: 0.0001, + pitchSemitonesPerOctave: 12, + scheduleAheadSeconds: 0.002, + sustainBase: 0.45, + sustainVelocityRange: 0.55, + tailStopExtraSeconds: 0.05, + voiceStealFadeSeconds: 0.025, + voiceStealStopSeconds: 0.05, }, rhythm: { bpm: 74, stepsPerBeat: 4, stepsPerBar: 16, lookaheadSeconds: 0.3, - speedForFullEnergyPixelsPerSecond: 1800, sparseActivity: 0.055, }, eraser: { @@ -42,6 +62,117 @@ export const appConfig = { noiseGain: 0.028, filterMinHz: 650, filterMaxHz: 3600, + durationSeconds: 0.08, + }, + energy: { + attackSeconds: 0.08, + decaySeconds: 0.9, + immediateActivityScale: 0.85, + releaseSeconds: 1.15, + strokeDecaySeconds: 0.32, + }, + graph: { + closeGain: 0.0001, + closeRampSeconds: 0.015, + delayMaxSeconds: 2, + eventBusGain: 1, + noiseMax: 1, + noiseMin: -1, + unlockTickFrequencyHz: 440, + unlockTickSeconds: 0.035, + }, + input: { + activeActivityThreshold: 0.38, + distanceWindowForFullActivityPixels: 140, + distanceWindowSeconds: 0.5, + fallbackFrameSeconds: 1 / 60, + manicActivityThreshold: 0.82, + manicModeThreshold: 0.72, + }, + muteGain: 0.0001, + muteRampSeconds: 0.02, + noiseBurst: { + attackSeconds: 0.004, + filterQ: 1.4, + offsetRandomSeconds: 0.4, + scheduleAheadSeconds: 0.002, + silentGain: 0.0001, + }, + startDelaySeconds: 0.02, + vibeChangeStingerMinIntervalSeconds: 0.45, + generativePiano: { + colorPools: [ + { + midiMin: 48, + midiMax: 67, + preferredMidi: 55, + pan: -0.18, + scaleDegrees: [0, 1, 2, 4], + }, + { + midiMin: 55, + midiMax: 74, + preferredMidi: 63, + pan: 0, + scaleDegrees: [1, 2, 3, 5], + }, + { + midiMin: 62, + midiMax: 81, + preferredMidi: 72, + pan: 0.18, + scaleDegrees: [2, 3, 4, 6], + }, + ], + padRegisters: [ + { + midiMin: 40, + midiMax: 55, + preferredMidi: 48, + pan: -0.12, + }, + { + midiMin: 48, + midiMax: 64, + preferredMidi: 55, + pan: 0.08, + }, + { + midiMin: 58, + midiMax: 76, + preferredMidi: 67, + pan: 0.2, + }, + ], + chordBars: 4, + supportBarSpacing: 2, + supportBarOffset: 1, + idleTextureBarSpacing: 2, + mediumTextureBarSpacing: 1, + textureBeat: 2, + highActivityExtraBeat: 3, + highActivityExtraThreshold: 0.45, + noteScorePreferenceWeight: 1.8, + noteScoreRegisterWeight: 0.28, + noteScoreRepeatPenalty: 3.2, + gestureAccentSpacingSeconds: 0.26, + gestureAccentMinIntervalSeconds: 2.5, + strokeAccentMinIntervalSeconds: 3.2, + strokeAccentThreshold: 0.58, + stingerSpacingSeconds: 0.08, + stingerDurationSeconds: 1.1, + maxBrushPhraseLayers: 5, + brushLayerBaseSeconds: 5.5, + brushLayerEnergySeconds: 2.5, + brushLayerMirrorSeconds: 3, + brushLayerMinIntensity: 0.08, + brushStreamIdleIntervalBeats: 2, + brushStreamActiveIntervalBeats: 1, + brushStreamIntenseIntervalBeats: 0.5, + brushStreamManicIntervalBeats: 0.25, + brushMotifMaxSteps: 8, + brushMotifCanonDelaySeconds: 0.055, + padDurationBarScale: 0.46, }, colorVoices: [ { @@ -62,80 +193,6 @@ export const appConfig = { ], vibes: audioVibes, }, - audioEngine: { - energy: { - attackSeconds: 0.08, - decaySeconds: 0.9, - releaseSeconds: 1.15, - strokeDecaySeconds: 0.32, - }, - eraser: { - canvasWidthRatioForFullSize: 0.18, - defaultSizePixels: 96, - durationSeconds: 0.08, - filterPressureWeight: 0.26, - filterSizeWeight: 0.16, - filterSpeedWeight: 0.58, - gainBase: 0.45, - gainPressureWeight: 0.24, - gainSizeWeight: 0.18, - gainSpeedWeight: 0.38, - }, - delay: { - erasingActivity: 0.12, - }, - graph: { - closeGain: 0.0001, - closeRampSeconds: 0.015, - delayActivityFeedbackWeight: 0.08, - delayFeedbackMax: 0.32, - delayFeedbackMin: 0.04, - delayOutputActivityWeight: 0.5, - delayOutputBase: 0.65, - delayTimeRampSeconds: 0.12, - eventBusGain: 1, - noiseMax: 1, - noiseMin: -1, - unlockBufferLength: 1, - unlockSampleRate: 22050, - }, - input: { - distanceEnergyBase: 0.34, - distanceEnergyScale: 0.66, - distanceForFullEnergyPixels: 140, - fallbackFrameSeconds: 1 / 60, - strokeEnergyBase: 0.18, - strokeEnergyPressureWeight: 0.22, - strokeEnergySpeedWeight: 0.62, - }, - muteGain: 0.0001, - muteRampSeconds: 0.02, - noiseBurst: { - attackSeconds: 0.004, - filterQ: 1.4, - offsetRandomSeconds: 0.4, - scheduleAheadSeconds: 0.002, - silentGain: 0.0001, - }, - piano: { - filterQ: 0.7, - gainAttackSeconds: 0.006, - lowpassMaxHz: 12000, - lowpassMinHz: 1400, - minDurationSeconds: 0.08, - minFadeSeconds: 0.08, - minGain: 0.0001, - pitchSemitonesPerOctave: 12, - scheduleAheadSeconds: 0.002, - sustainBase: 0.45, - sustainVelocityRange: 0.55, - tailStopExtraSeconds: 0.05, - voiceStealFadeSeconds: 0.025, - voiceStealStopSeconds: 0.05, - }, - startDelaySeconds: 0.02, - vibeChangeStingerMinIntervalSeconds: 0.45, - }, deltaTime: { fpsExponentialDecayStrength: 0.01, maxDeltaTimeSeconds: 1 / 30, @@ -173,13 +230,14 @@ export const appConfig = { simulation: { budget: { adaptiveCapDecreaseAgentsPerSecond: 50_000, + adaptiveCapMax: ADAPTIVE_AGENT_CAP_MAX, adaptiveCapMin: 500_000, fpsHeadroom: 0.95, fpsSmoothingNew: 0.06, fpsSmoothingRetain: 0.94, }, brushEffectFramesPerSecond: 60, - globalAgentCap: 10_000_000, + globalAgentCap: ADAPTIVE_AGENT_CAP_MAX, initialAgentCount: 180_000, intro: { angleJitterRadians: Math.PI * 0.08, @@ -227,10 +285,6 @@ export const appConfig = { audioMutedKey: 'fleeting-garden:audio-muted', vibeKey: 'fleeting-garden:vibe', }, - telemetry: { - enabled: false, - intervalMs: 1000, - }, toolbar: { eraser: { controlScaleMax: 1.34, diff --git a/src/config/runtime-settings.ts b/src/config/runtime-settings.ts index 8e2c9b3..904829d 100644 --- a/src/config/runtime-settings.ts +++ b/src/config/runtime-settings.ts @@ -1,3 +1,4 @@ +import { ADAPTIVE_AGENT_CAP_MAX } from './agent-budget'; import { colorInteractionControl, defaultColorInteractionSettings, @@ -34,8 +35,6 @@ export const runtimeSettings: GardenAppConfig['runtimeSettings'] = { brushSizeVariation: 0.5, - startColorHue: 200, - simulatedDelayMs: 0, }, controls: { @@ -43,7 +42,7 @@ export const runtimeSettings: GardenAppConfig['runtimeSettings'] = { folder: 'Runtime', integer: true, min: 500_000, - max: 10_000_000, + max: ADAPTIVE_AGENT_CAP_MAX, step: 50_000, }, agentCount: { @@ -176,12 +175,6 @@ export const runtimeSettings: GardenAppConfig['runtimeSettings'] = { max: 1, step: 0.001, }, - startColorHue: { - folder: 'Render', - min: 0, - max: 360, - step: 1, - }, turnSpeed: { folder: 'Agent', min: 1, diff --git a/src/config/types.ts b/src/config/types.ts index 44d586b..673aa93 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -78,80 +78,6 @@ type RuntimeSettingControlConfig = { export interface GardenAppConfig { audio: GardenAudioConfig; - audioEngine: { - energy: { - attackSeconds: number; - decaySeconds: number; - releaseSeconds: number; - strokeDecaySeconds: number; - }; - eraser: { - canvasWidthRatioForFullSize: number; - defaultSizePixels: number; - durationSeconds: number; - filterPressureWeight: number; - filterSizeWeight: number; - filterSpeedWeight: number; - gainBase: number; - gainPressureWeight: number; - gainSizeWeight: number; - gainSpeedWeight: number; - }; - delay: { - erasingActivity: number; - }; - graph: { - closeGain: number; - closeRampSeconds: number; - delayActivityFeedbackWeight: number; - delayFeedbackMax: number; - delayFeedbackMin: number; - delayOutputActivityWeight: number; - delayOutputBase: number; - delayTimeRampSeconds: number; - eventBusGain: number; - noiseMax: number; - noiseMin: number; - unlockBufferLength: number; - unlockSampleRate: number; - }; - input: { - distanceEnergyBase: number; - distanceEnergyScale: number; - distanceForFullEnergyPixels: number; - fallbackFrameSeconds: number; - strokeEnergyBase: number; - strokeEnergyPressureWeight: number; - strokeEnergySpeedWeight: number; - }; - muteGain: number; - muteRampSeconds: number; - noiseBurst: { - attackSeconds: number; - filterQ: number; - offsetRandomSeconds: number; - scheduleAheadSeconds: number; - silentGain: number; - }; - piano: { - filterQ: number; - gainAttackSeconds: number; - lowpassMaxHz: number; - lowpassMinHz: number; - minDurationSeconds: number; - minFadeSeconds: number; - minGain: number; - pitchSemitonesPerOctave: number; - scheduleAheadSeconds: number; - sustainBase: number; - sustainVelocityRange: number; - tailStopExtraSeconds: number; - voiceStealFadeSeconds: number; - voiceStealStopSeconds: number; - }; - startDelaySeconds: number; - vibeChangeStingerMinIntervalSeconds: number; - }; deltaTime: { fpsExponentialDecayStrength: number; maxDeltaTimeSeconds: number; @@ -192,6 +118,7 @@ export interface GardenAppConfig { simulation: { budget: { adaptiveCapDecreaseAgentsPerSecond: number; + adaptiveCapMax: number; adaptiveCapMin: number; fpsHeadroom: number; fpsSmoothingNew: number; @@ -246,10 +173,6 @@ export interface GardenAppConfig { audioMutedKey: string; vibeKey: string; }; - telemetry: { - enabled: boolean; - intervalMs: number; - }; toolbar: { eraser: { controlScaleMax: number; @@ -277,5 +200,3 @@ export interface GardenAppConfig { presets: Array; }; } - -export type GardenAudioEngineConfig = GardenAppConfig['audioEngine']; diff --git a/src/game-loop/agent-population.test.ts b/src/game-loop/agent-population.test.ts index 43e5917..362651e 100644 --- a/src/game-loop/agent-population.test.ts +++ b/src/game-loop/agent-population.test.ts @@ -1,6 +1,11 @@ import { vec2 } from 'gl-matrix'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { appConfig } from '../config'; +import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline'; +import { settings } from '../settings'; +import { AgentPopulation } from './agent-population'; + vi.hoisted(() => { Object.defineProperty(globalThis, 'localStorage', { configurable: true, @@ -11,11 +16,6 @@ vi.hoisted(() => { }); }); -import { appConfig } from '../config'; -import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline'; -import { settings } from '../settings'; -import { AgentPopulation } from './agent-population'; - const originalAgentBudgetMax = settings.agentBudgetMax; const originalBrushSize = settings.brushSize; const originalSelectedColorIndex = settings.selectedColorIndex; @@ -63,10 +63,35 @@ describe('AgentPopulation adaptive budget', () => { expect(settings.agentBudgetMax).toBeGreaterThan(1_000_000); expect(population.activeAgentCount).toBeGreaterThan(1_000_000); expect(settings.agentBudgetMax).toBeLessThanOrEqual( - appConfig.simulation.globalAgentCap + appConfig.simulation.budget.adaptiveCapMax ); }); + it('does not grow the cap above the adaptive max agent count', () => { + const population = createPopulation(); + const maxAgentCount = appConfig.simulation.budget.adaptiveCapMax; + settings.agentBudgetMax = maxAgentCount - 1; + setPopulationActiveCount(population, maxAgentCount - 1); + + population.growBudget(1 / 60, 60, 60); + population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(16, 0)); + + expect(settings.agentBudgetMax).toBe(maxAgentCount); + expect(population.activeAgentCount).toBe(maxAgentCount); + }); + + it('clamps a manually raised cap before adding agents', () => { + const population = createPopulation(); + const maxAgentCount = appConfig.simulation.budget.adaptiveCapMax; + settings.agentBudgetMax = maxAgentCount + 1_000; + setPopulationActiveCount(population, maxAgentCount); + + population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(16, 0)); + + expect(settings.agentBudgetMax).toBe(maxAgentCount); + expect(population.activeAgentCount).toBe(maxAgentCount); + }); + it('decreases the cap and active count slowly when FPS falls below the threshold', () => { const population = createPopulation(); setPopulationActiveCount(population, 1_000_000); @@ -74,8 +99,6 @@ describe('AgentPopulation adaptive budget', () => { population.growBudget(10, 50, 60); expect(settings.agentBudgetMax).toBe(appConfig.simulation.budget.adaptiveCapMin); - expect(population.activeAgentCount).toBe( - appConfig.simulation.budget.adaptiveCapMin - ); + expect(population.activeAgentCount).toBe(appConfig.simulation.budget.adaptiveCapMin); }); }); diff --git a/src/game-loop/agent-population.ts b/src/game-loop/agent-population.ts index ca47029..766cf71 100644 --- a/src/game-loop/agent-population.ts +++ b/src/game-loop/agent-population.ts @@ -12,6 +12,7 @@ const INITIAL_AGENT_COUNT = appConfig.simulation.initialAgentCount; const MIN_STROKE_AGENT_COUNT = appConfig.simulation.stroke.minAgentCount; const MAX_STROKE_AGENT_COUNT = appConfig.simulation.stroke.maxAgentCount; const STROKE_AGENT_DENSITY_MULTIPLIER = appConfig.simulation.stroke.densityMultiplier; +const ADAPTIVE_CAP_MAX = appConfig.simulation.budget.adaptiveCapMax; const ADAPTIVE_CAP_MIN = appConfig.simulation.budget.adaptiveCapMin; const ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND = appConfig.simulation.budget.adaptiveCapDecreaseAgentsPerSecond; @@ -129,6 +130,7 @@ export class AgentPopulation { } const count = data.length / AGENT_FLOAT_COUNT; + settings.agentBudgetMax = this.clampAdaptiveCap(settings.agentBudgetMax); this.expandAdaptiveCapForPendingAgents(count); const available = Math.max(0, settings.agentBudgetMax - this.activeCount); @@ -214,8 +216,9 @@ export class AgentPopulation { private clampAdaptiveCap(value: number): number { const pipelineCap = Math.max(0, Math.floor(this.pipeline.maxAgentCount)); - const minCap = Math.min(ADAPTIVE_CAP_MIN, pipelineCap); + const maxCap = Math.min(ADAPTIVE_CAP_MAX, pipelineCap); + const minCap = Math.min(ADAPTIVE_CAP_MIN, maxCap); const finiteValue = Number.isFinite(value) ? value : minCap; - return Math.min(pipelineCap, Math.max(minCap, Math.round(finiteValue))); + return Math.min(maxCap, Math.max(minCap, Math.round(finiteValue))); } } diff --git a/src/game-loop/frame-performance.ts b/src/game-loop/frame-performance.ts index 0127abe..3bd7888 100644 --- a/src/game-loop/frame-performance.ts +++ b/src/game-loop/frame-performance.ts @@ -1,14 +1,5 @@ import { appConfig } from '../config'; -interface TelemetrySnapshot { - frameCpuStartedAt: number; - encodeCpuMs: number; - activeAgentCount: number; - agentBudgetMax: number; - canvas: HTMLCanvasElement; - devicePixelRatio: number; -} - const COMMON_DISPLAY_REFRESH_RATES = [ 50, 60, 72, 75, 90, 100, 120, 144, 165, 180, 240, ] as const; @@ -22,20 +13,11 @@ export class FramePerformance { public displayRefreshFps = 60; public readonly refreshTargetFps = 60; - private lastTelemetryAt = 0; private previousFrameTime: DOMHighResTimeStamp | null = null; private hasConfirmedDisplayRefreshFps = false; private pendingDisplayRefreshFps = 0; private pendingDisplayRefreshFrameCount = 0; - public markCpuStart(): number { - return appConfig.telemetry.enabled ? performance.now() : 0; - } - - public measureSince(startedAt: number): number { - return appConfig.telemetry.enabled ? performance.now() - startedAt : 0; - } - public update(time: DOMHighResTimeStamp): void { const previous = this.previousFrameTime; this.previousFrameTime = time; @@ -56,39 +38,6 @@ export class FramePerformance { fps * appConfig.simulation.budget.fpsSmoothingNew; } - public renderTelemetry({ - frameCpuStartedAt, - encodeCpuMs, - activeAgentCount, - agentBudgetMax, - canvas, - devicePixelRatio, - }: TelemetrySnapshot): void { - if (!appConfig.telemetry.enabled) { - return; - } - - const now = performance.now(); - if (now - this.lastTelemetryAt < appConfig.telemetry.intervalMs) { - return; - } - - this.lastTelemetryAt = now; - console.debug('Fleeting Garden telemetry', { - fps: Math.round(this.latestFps), - smoothedFps: Math.round(this.smoothedFps), - refreshTargetFps: Math.round(this.refreshTargetFps), - displayRefreshFps: Math.round(this.displayRefreshFps), - activeAgentCount, - agentBudgetMax, - canvasWidth: canvas.width, - canvasHeight: canvas.height, - dpr: devicePixelRatio, - frameCpuMs: now - frameCpuStartedAt, - encodeCpuMs, - }); - } - private updateDisplayRefreshEstimate(fps: number): void { const displayRefreshFps = this.snapDisplayRefreshRate(fps); if (displayRefreshFps === null) { diff --git a/src/game-loop/game-loop-settings.ts b/src/game-loop/game-loop-settings.ts index 58ffd55..ef9dbdf 100644 --- a/src/game-loop/game-loop-settings.ts +++ b/src/game-loop/game-loop-settings.ts @@ -4,6 +4,4 @@ export interface GameLoopSettings { simulatedDelayMs: number; selectedColorIndex: number; spawnPerPixel: number; - - startColorHue: number; } diff --git a/src/game-loop/game-loop.ts b/src/game-loop/game-loop.ts index 4b51f10..6dc37f0 100644 --- a/src/game-loop/game-loop.ts +++ b/src/game-loop/game-loop.ts @@ -23,11 +23,7 @@ export default class GameLoop { private static readonly DEV_STATS_INTERVAL_MS = 250; private readonly resources: GameLoopResources; - private readonly audio = new GardenAudio( - gardenAudioConfig, - appConfig.audioEngine, - appConfig.simulation.maxMirrorSegmentCount - ); + private readonly audio = new GardenAudio(gardenAudioConfig); private readonly renderInputs = new RenderInputCache(); private readonly introPrompt: IntroPrompt; private readonly eraserPreview: EraserPreview; @@ -68,7 +64,6 @@ export default class GameLoop { eraserAgentPipeline: this.resources.eraserAgentPipeline, eraserTexturePipeline: this.resources.eraserTexturePipeline, eraserPreview: this.eraserPreview, - getCanvasSize: () => this.canvasSize, getDevicePixelRatio: () => this.devicePixelRatio, getMirrorSegmentCount: () => this.mirrorSegmentCount, onStartDrawing: () => this.introPrompt.markStartedDrawing(), @@ -167,7 +162,6 @@ export default class GameLoop { return; } - const frameCpuStartedAt = this.framePerformance.markCpuStart(); const deltaTime = this.deltaTimeCalculator.calculateDeltaTimeInSeconds(time); this.framePerformance.update(time); this.agentPopulation.growBudget( @@ -194,7 +188,6 @@ export default class GameLoop { vibe: activeVibe, selectedColorIndex: settings.selectedColorIndex, isErasing, - mirrorSegmentCount: this.mirrorSegmentCount, }); this.resources.setFrameParameters({ @@ -212,24 +205,14 @@ export default class GameLoop { eraserPixelSize, }); - const encodeCpuStartedAt = this.framePerformance.markCpuStart(); this.resources.executeFrame( isErasing, this.toolbarContrastMonitor.takeReadbackRequest(time) ); - const encodeCpuMs = this.framePerformance.measureSince(encodeCpuStartedAt); this.pointerInput.clearSwipesIfIdle(); await this.agentPopulation.compactAfterErase(this.pointerInput.isSwipeActive); - this.framePerformance.renderTelemetry({ - frameCpuStartedAt, - encodeCpuMs, - activeAgentCount: this.agentPopulation.activeAgentCount, - agentBudgetMax: settings.agentBudgetMax, - canvas: this.canvas, - devicePixelRatio: this.devicePixelRatio, - }); this.updateDevStats(time); if (settings.simulatedDelayMs > 0) { diff --git a/src/game-loop/pointer-input.test.ts b/src/game-loop/pointer-input.test.ts index dbe7381..454bf74 100644 --- a/src/game-loop/pointer-input.test.ts +++ b/src/game-loop/pointer-input.test.ts @@ -118,7 +118,6 @@ const createPointerInput = async () => { eraserAgentPipeline, eraserPreview, eraserTexturePipeline, - getCanvasSize: () => [canvas.width, canvas.height], getDevicePixelRatio: () => 1, getMirrorSegmentCount: () => 1, onEraseGestureEnded, @@ -185,9 +184,7 @@ describe('GardenPointerInput drawing startup', () => { expect(audio.beginGesture).toHaveBeenCalledTimes(1); expect(audio.touchDown).toHaveBeenCalledWith( expect.objectContaining({ - canvasSize: [300, 200], colorIndex: 0, - position: expect.any(Float32Array), }) ); expect(audio.stroke).not.toHaveBeenCalled(); diff --git a/src/game-loop/pointer-input.ts b/src/game-loop/pointer-input.ts index 8cee375..538e3cf 100644 --- a/src/game-loop/pointer-input.ts +++ b/src/game-loop/pointer-input.ts @@ -16,7 +16,6 @@ interface GardenPointerInputOptions { eraserAgentPipeline: EraserAgentPipeline; eraserTexturePipeline: EraserTexturePipeline; eraserPreview: EraserPreview; - getCanvasSize: () => vec2; getDevicePixelRatio: () => number; getMirrorSegmentCount: () => number; onStartDrawing: () => void; @@ -32,7 +31,6 @@ export class GardenPointerInput { private activePointerId: number | null = null; private lastPointerPosition: vec2 | null = null; private lastPointerEventTimeMs: number | null = null; - private lastPointerPressure = 0.5; private smoothedStrokePoints: Array = []; private lastSmoothedBrushPosition: vec2 | null = null; private isErasing = false; @@ -109,18 +107,12 @@ export class GardenPointerInput { return; } - const position = this.getCanvasPointerPosition(event); if (event.pointerType !== 'touch') { this.options.audio.start(activeVibe, { userGesture: true }); } this.options.audio.beginGesture(); this.options.audio.touchDown({ - vibe: activeVibe, colorIndex: settings.selectedColorIndex, - position, - canvasSize: this.options.getCanvasSize(), - mirrorSegmentCount: this.options.getMirrorSegmentCount(), - pressure: this.getPointerPressure(event), }); this.options.onStartDrawing(); this.activePointerId = event.pointerId; @@ -131,7 +123,6 @@ export class GardenPointerInput { this.lastPointerPosition = null; this.lastPointerEventTimeMs = null; this.clearSmoothedStroke(); - this.lastPointerPressure = this.getPointerPressure(event); this.addSwipeAt(event, { emitAudio: false }); }; @@ -178,7 +169,6 @@ export class GardenPointerInput { }; private addSwipeAt(event: PointerEvent, options: { emitAudio?: boolean } = {}): void { - const devicePixelRatio = this.options.getDevicePixelRatio(); const position = this.getCanvasPointerPosition(event); const previousPosition = this.lastPointerPosition ?? position; const previousTimeMs = this.lastPointerEventTimeMs ?? event.timeStamp; @@ -186,10 +176,6 @@ export class GardenPointerInput { appConfig.deltaTime.minDeltaTimeSeconds, (event.timeStamp - previousTimeMs) / 1000 ); - const distancePixels = vec2.distance(previousPosition, position); - const velocityPixelsPerSecond = distancePixels / elapsedSeconds; - const pressure = this.getPointerPressure(event); - this.lastPointerPressure = pressure > 0 ? pressure : this.lastPointerPressure; const segments = this.isErasing ? [{ from: previousPosition, to: position }] @@ -214,14 +200,9 @@ export class GardenPointerInput { vibe: activeVibe, from: previousPosition, to: position, - canvasSize: this.options.getCanvasSize(), colorIndex: settings.selectedColorIndex, isErasing: this.isErasing, - pressure: pressure > 0 ? pressure : this.lastPointerPressure, - velocityPixelsPerSecond, elapsedSeconds, - eraserSizePixels: settings.eraserSize * devicePixelRatio, - mirrorSegmentCount: this.options.getMirrorSegmentCount(), }); } this.lastPointerPosition = position; @@ -363,14 +344,6 @@ export class GardenPointerInput { return segments; } - - private getPointerPressure(event: PointerEvent): number { - if (Number.isFinite(event.pressure) && event.pressure > 0) { - return Math.min(1, Math.max(0, event.pressure)); - } - - return 0; - } } const rotatePointAround = (point: vec2, center: vec2, angle: number): vec2 => { @@ -409,5 +382,4 @@ const getBrushCurveResolution = (): number => { const isSamePointerSample = (left: PointerEvent, right: PointerEvent): boolean => left.clientX === right.clientX && left.clientY === right.clientY && - left.pressure === right.pressure && left.buttons === right.buttons; diff --git a/src/pipelines/render/render.wgsl b/src/pipelines/render/render.wgsl index 0e8f860..839a496 100644 --- a/src/pipelines/render/render.wgsl +++ b/src/pipelines/render/render.wgsl @@ -48,10 +48,20 @@ fn fragment(@location(0) uv: vec2) -> @location(0) vec4 { let color = max(normalizedTraceColor, brushColor * (1.2 + brushStrength * 1.6)); let strength = clamp(max(max(max(strengths.r, strengths.g), strengths.b), brushStrength), 0, 1); + let background = getTexturedBackground(uv); - return vec4(mix(settings.backgroundColor, clamp(color, vec3(0), vec3(1)), strength), 1); + return vec4(mix(background, clamp(color, vec3(0), vec3(1)), strength), 1); } fn clarity(strength: f32) -> f32 { return pow(clamp(strength, 0, 1), settings.clarity); } + +fn getTexturedBackground(uv: vec2) -> vec3 { + let noiseSize = vec2(textureDimensions(noise, 0)); + let pixel = floor(uv * state.size); + let noiseCoord = vec2(fract(pixel / noiseSize) * noiseSize); + let grain = textureLoad(noise, noiseCoord, 0).r - 0.5; + + return clamp(settings.backgroundColor + vec3(grain * 0.018), vec3(0), vec3(1)); +} From 2c7d72a6997023f3427fac8848eeea2768e89982 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 16 May 2026 16:15:59 +0100 Subject: [PATCH 08/20] . --- src/audio/garden-audio-gesture-state.test.ts | 39 ++++++++++++++++++++ src/config/agent-budget.ts | 1 + 2 files changed, 40 insertions(+) create mode 100644 src/audio/garden-audio-gesture-state.test.ts create mode 100644 src/config/agent-budget.ts diff --git a/src/audio/garden-audio-gesture-state.test.ts b/src/audio/garden-audio-gesture-state.test.ts new file mode 100644 index 0000000..712f98a --- /dev/null +++ b/src/audio/garden-audio-gesture-state.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { gardenAudioConfig } from './garden-audio-config'; +import { GardenAudioGestureState } from './garden-audio-gesture-state'; + +describe('GardenAudioGestureState', () => { + it('uses only distance accumulated in the last half second', () => { + const state = new GardenAudioGestureState(gardenAudioConfig.input); + + state.beginGesture(); + + expect( + state.recordStroke({ + metrics: { + distancePixels: 70, + elapsedSeconds: 0.1, + }, + }).activity + ).toBeCloseTo(0.5); + + expect( + state.recordStroke({ + metrics: { + distancePixels: 70, + elapsedSeconds: 0.1, + }, + }).activity + ).toBe(1); + + expect( + state.recordStroke({ + metrics: { + distancePixels: 0, + elapsedSeconds: 0.51, + }, + }).activity + ).toBe(0); + }); +}); diff --git a/src/config/agent-budget.ts b/src/config/agent-budget.ts new file mode 100644 index 0000000..e5d4282 --- /dev/null +++ b/src/config/agent-budget.ts @@ -0,0 +1 @@ +export const ADAPTIVE_AGENT_CAP_MAX = 2_000_000; From 560398fefb88f5dcf759628c1982b6b8d7f39b7f Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 16 May 2026 20:45:42 +0100 Subject: [PATCH 09/20] more cleaning up --- assets/fonts/open-sans-v34-latin-regular.woff | Bin 20712 -> 0 bytes e2e/app.spec.ts | 190 ++----- index.html | 39 +- package-lock.json | 114 ---- package.json | 15 +- playwright.config.ts | 2 +- public/manifest.webmanifest | 11 +- public/robots.txt | 2 - src/analytics.ts | 8 - src/audio/garden-audio-config.ts | 16 +- src/audio/garden-audio-gesture-state.ts | 59 +-- src/audio/garden-audio-music.ts | 22 +- src/audio/garden-audio-types.ts | 9 - src/audio/garden-audio.test.ts | 26 +- src/audio/garden-audio.ts | 29 +- src/audio/generative-piano.test.ts | 99 +--- src/audio/generative-piano.ts | 498 +++++------------- src/audio/piano-sampler.test.ts | 34 +- src/audio/piano-sampler.ts | 2 +- src/audio/piano-samples.ts | 9 +- src/config.ts | 31 +- src/config/agent-budget.ts | 1 - src/config/color-interactions.ts | 16 +- src/config/default-settings.ts | 17 + src/config/runtime-controls.ts | 133 +++++ src/config/runtime-settings.ts | 191 ------- src/config/types.ts | 105 ++-- src/config/vibe-presets.ts | 22 +- src/game-loop/agent-population.test.ts | 42 +- src/game-loop/agent-population.ts | 59 +-- src/game-loop/export-4k-renderer.ts | 5 +- src/game-loop/export-4k.test.ts | 2 - src/game-loop/export-4k.ts | 16 +- src/game-loop/frame-performance.test.ts | 51 +- src/game-loop/frame-performance.ts | 73 +-- src/game-loop/game-loop-ping-pong.test.ts | 7 +- src/game-loop/game-loop-resources.ts | 27 +- src/game-loop/game-loop-settings.ts | 7 - src/game-loop/game-loop.ts | 97 +--- src/game-loop/intro-prompt.ts | 12 +- src/game-loop/pointer-input.test.ts | 6 - src/game-loop/pointer-input.ts | 11 +- src/game-loop/simulation-frame.ts | 9 +- src/game-loop/simulation-textures.ts | 27 +- .../toolbar-contrast-monitor.test.ts | 43 +- src/game-loop/toolbar-contrast-monitor.ts | 53 +- src/index.scss | 2 +- src/index.ts | 104 ++-- src/page/collapsible-panel-animator.ts | 27 +- src/page/config-pane.ts | 23 +- src/page/full-screen-handler.ts | 21 +- src/page/menu-hider.ts | 5 - .../agent-generation/agent-compaction.wgsl | 12 +- .../agent-generation/agent-counting.wgsl | 35 -- .../agent-first-generation.wgsl | 37 -- .../agent-generation-pipeline.test.ts | 40 +- .../agent-generation-pipeline.ts | 197 +------ .../agents/agent-generation/agent-resize.wgsl | 5 +- .../agent-generation/agent-schema.test.ts | 15 +- .../agents/agent-generation/agent-schema.wgsl | 6 +- .../agent-generation/generation-counts.ts | 4 - src/pipelines/agents/agent-pipeline.ts | 4 +- src/pipelines/agents/agent.wgsl | 5 +- src/pipelines/brush/brush-pipeline.ts | 28 +- src/pipelines/brush/brush-settings.ts | 3 - src/pipelines/brush/brush.wgsl | 15 - src/pipelines/common-state/common-state.ts | 16 +- src/pipelines/copy/copy-pipeline.ts | 5 +- src/pipelines/diffusion/diffuse.wgsl | 4 +- .../diffusion/diffusion-pipeline.test.ts | 6 + src/pipelines/diffusion/diffusion-pipeline.ts | 13 +- src/pipelines/diffusion/diffusion-settings.ts | 1 - src/pipelines/eraser/eraser-agent-pipeline.ts | 42 +- src/pipelines/eraser/eraser-agent.wgsl | 5 +- .../eraser/eraser-texture-pipeline.ts | 15 +- src/pipelines/eraser/eraser-texture.wgsl | 2 +- src/pipelines/render/render-pipeline.ts | 20 +- src/pipelines/render/render.wgsl | 9 +- src/pipelines/wgsl-uniform-layout.test.ts | 9 +- src/settings.ts | 28 +- src/style/_app-shell.scss | 21 - src/style/_control-dock.scss | 4 +- src/style/_garden-prompt.scss | 6 +- src/style/_loading.scss | 13 +- src/style/_motion.scss | 4 - src/style/_panels.scss | 90 ++-- src/style/_toolbar.scss | 26 +- src/style/common.scss | 12 +- src/style/fonts.scss | 6 +- src/style/vars.scss | 1 - src/utils/delta-time-calculator.ts | 25 +- src/utils/dom.ts | 10 +- src/utils/error-handler.ts | 2 - src/utils/exponential-decay.ts | 9 - .../graphics/get-workgroup-count.test.ts | 19 + src/utils/graphics/get-workgroup-count.ts | 17 + .../graphics/get-workgroup-counts.test.ts | 34 -- src/utils/graphics/get-workgroup-counts.ts | 39 -- src/utils/graphics/initialize-gpu.ts | 7 - src/utils/graphics/resizable-texture.ts | 4 +- src/utils/hsl.test.ts | 42 -- src/utils/hsl.ts | 35 -- src/utils/math.test.ts | 36 -- src/utils/mix.ts | 1 - src/utils/random.test.ts | 41 -- src/utils/random.ts | 23 - src/utils/rgb.ts | 3 - src/utils/sleep.ts | 3 - src/vibes.test.ts | 36 +- tsconfig.json | 1 - 110 files changed, 933 insertions(+), 2647 deletions(-) delete mode 100644 assets/fonts/open-sans-v34-latin-regular.woff delete mode 100644 public/robots.txt delete mode 100644 src/config/agent-budget.ts create mode 100644 src/config/default-settings.ts create mode 100644 src/config/runtime-controls.ts delete mode 100644 src/config/runtime-settings.ts delete mode 100644 src/game-loop/game-loop-settings.ts delete mode 100644 src/pipelines/agents/agent-generation/agent-counting.wgsl delete mode 100644 src/pipelines/agents/agent-generation/agent-first-generation.wgsl delete mode 100644 src/pipelines/agents/agent-generation/generation-counts.ts delete mode 100644 src/utils/exponential-decay.ts create mode 100644 src/utils/graphics/get-workgroup-count.test.ts create mode 100644 src/utils/graphics/get-workgroup-count.ts delete mode 100644 src/utils/graphics/get-workgroup-counts.test.ts delete mode 100644 src/utils/graphics/get-workgroup-counts.ts delete mode 100644 src/utils/hsl.test.ts delete mode 100644 src/utils/hsl.ts delete mode 100644 src/utils/mix.ts delete mode 100644 src/utils/random.test.ts delete mode 100644 src/utils/random.ts delete mode 100644 src/utils/rgb.ts delete mode 100644 src/utils/sleep.ts diff --git a/assets/fonts/open-sans-v34-latin-regular.woff b/assets/fonts/open-sans-v34-latin-regular.woff deleted file mode 100644 index b0836266265a09aaf1e3f4300fafcbdecf7680fe..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20712 zcmYg$V~{3I6YVp$ZF9%AZSB~$ZQHi7vt#bqwr$(yo%g%<$E}DH9eLtpS7vo}M^;z4 z$%}~rfB-*D;RJyE-_55001cYhm-k%A-FTxptyp(@(;K3V-x-d z5px+Mapm7a008ih38eXRRS+!DT9#L)X955~w13DSJwV?{ZJ$P2MNs93tNpPhf1s=T zF3f6VV_^Toef-$-8YtvOuFix20O)_ZwjX@(^3#l%+MC$`0ASVt0K_r?fRMb0 zm8)rH;Pm4QC-S2~{2$N&Bxcqgra#UtJ z8FoZrBStDi&4CFa>&wAe4eiZ=e(^)C;y(q?1is`&uv`G3pPt|W(14$4e;Sa8n2ZVl z67XN1fbVYrzL^dzGv$+p)C4II4Fr%8)(R9-ZZAkZqslR`emRo z)`;?s_}Yp7_%%Vg>96-lg}3&3B7q1gzNo5q1an=Oc&5gm#f@(dmX$1@N4x}Ztjr5Z zx#%F+Nmn$tU)z_kq411Er2}0YgGt<{;Ok$}VKzhTHnw?$gkp(|ZzpCwTldive)ljN z<#sFc{_CFdH>G@i?L1>)xC(=2B6fLfAh2Q)>!iV+pS&*wq`Cmb!r#OHjjfTsLBe-2n=9=6=CA%o?yLo0YCx((4N(Hzv6fR;xA}`aXuUv)l_tN zYGiI?$=w#O9MtvxDUzdar5zR(HWzweVgTM>%GdF*JJH|C(Sk2PC4V3$f1bbln)P@9 zIItjU$S$*TJZMuwN;WuBJ}|9+ZvGZ2(1`}JKXGI};R7_;C>a1uq}B@uWTfvE!5cv1 z59~iu14X-cp5^~F2%t_EmYtTl=*_*H+_KOx%s?_~K3UoIhZ4_rSahvEJIk>A3k@nw z(}DNBij89pzJAOwjQx3{EbqBsu{0UqHJ`?FK#j3FF4tV2&_r|iuN9$`XT&|<%Wt?d z>DiZo&%ho20caHfNLQ3{_%CMKm>?Gr1%Rks)xLg;Vc>+)^PlERw&qJ9$K>CAaDf=! z-@_Ut(#$3kLA*A-kezL=D4RM?HNQ5B-9W;TkL8&sQcQ(vczHTdP?SufC@!pTLW4*G zJLD=INCxOTCTSs1&~wCvHpZw3|9ugx+(_V&L%ml8FmDRfG=e3W@1!I zZSMzqZ4HKU9LAdYhFv$l!E@{f*ty_Kk;M{Rh?xFT7H5va^!k-_RW3o$?I210U+j)I znVd#%jZe)>sYj(lp>J(#nP-2;BJb+w688%G0zW!7vM(wpq8}O;k`Iapf?rxz(og=3 zgx}Q8#P9R~{P^_0WyTA-Fd#Al|ltH5Ayi+ zE5iru7aWVk7(#08jvXi!0{1F!vD;B(FB573up)^Qa$mmu7UO^}o@PynuQ^+!+q5SH z!{E8S62%YhIImA zHR2%+zI&Rbx&Tj9NMwY>`VE z#dWPa1`Ng~9_BBV*>G>;3OvxsvV6%EWf<6uq%|z@a5EUc zyT(8YguU&5SRYy3EUKsf>Uk(ThJ`)aD&34Gw(@u4!l+W&>PoHz^fk`S`N=mI5Ret> zo9ksNqz4?&#*5wWvlcPHMa$;5ReflTD_I+QA}iOVg-z0MX<1fVo9kiovRZLV6%tn1 z@?`~HWfRUEunj4>gu&`YnCDFGXgiet?HtnirSnF2{a$aQDywKCQ^QCUUm;OdB9oM@ zw)|3Ee+kG5kwe{7vw<40mFKaDgEdXZAw$CC*m|CjAwnX!mLvcK6AFqm)?uO3J92*z zNIe$bz+c`3ln8k7gfWF?`nv%&Nn0*>ui_!q)L&&~N!-~yYxkQU=(a29`zo=t0`2$2 zuJ6S5Fq&qJt)jaVNm*rUi9t{-Cu8Jo!Dtxal7?xp>%Gohp~(D2qzq+4t!LVKP8$yT z0#e(2(_0#~!fi^mDz5E`~{z-)l{vE{Sw-1{Jc$dJrI`W%MjeDId^PB`Py&l2tM(1VR-;-*c;< z*iM1ogeV)R$P@M=tfiV2l$*H^CJ9CeM@#+7(-t_g(T&;;rTW$#unSSr7EH*hsk1eQ z@xV(yOtHrVoCg%r%cs2U&HKn5pl~$?!Cfos*cayW;G*_5-C{Z3yvit)oGfyLY)ED&JQ)Q^&ibT*lP?^{z)>!%4t2zGNR=a0_P!Nt~h2-{#p) zom>X5!nQ;yK-9T5EOf!*6c7_RobZ{$l;qArE;)zp!7K<)SrJki@3EI+LIo`ZHa{wo znLC`8`)Woxr%Z7sgX#-H45z8I5*1#rrVjr8heoQQ5M`bu#R0(%w@AsH*qTUvjC0)< zRvGT((@+HxJwo56bNaNouyB3KWAlEONwca(#%%H3-&{PXIxm{K5B+qt<#< zbA{HEbC1MLfw8bo2EvL~hN)xFd<@j&Vdzs2o!Ffl{=SPx2tJ%g$&EW)884n#PD%4v z$=H0vr+5rq!>=#6@IP2^#F^_gv$HIbzkE3vrAy1Y@!NjOxGANN69qWfY>je_E(l4) zunhgaGrU&#^R4&mx1Uk(nJt@3knst^pRct7Dc8VeN&tTDV@F67eq&imVNn%FSDdSP zyBpMtNwMqiW&2f{*{o~A6g+4hh`kj>SUi0en|yRoypyj@x=Zw6JmY(}ZOZ^t3JQq{ zi*|BCyEBlHh@2*!97GCCxKpVDrsDC-VssnDLYT5Hr?}#XDVW1Ptk0sfrJ{Z4ts!pv z)fz%WdFPpGs}oYl@<6p^Xx($pke^DxL$-YB<6}8n_gZ~mVw_BRK~%sG<36BO_w76J zP1Jd`$2K)>V?NN9o8`T2vFtew))P(>a6SCt`@H=$A&4=xXiKES71yP+e0;2%BzM?) z*V)!(PT_2!yD5xfxM2?F!-d?#s!~ zFqS%e=@grah%1dyX|Mh$!*{E3d9=8W$s^@^b6NBAEa)4tmF{g8WYII95AJH4oxL7La~<2uEZ=>1s5ut6yPqPhN3Nc~)I#QvCD^#C2GA z@RkYs8^T83h?4*gyu$FsLE7{9j`cF_NYgn| z;E_R9@iPH%2GmzyTnBYH9@cwP;TW z0p-=DtF8BB6<|pTp)f>YeFe=wLBxc3s7Q*6k^B0+z}UETYMDBUb!k?@wNZwhPr7B# zTeetb9FGKK!~a~7$6T(wt_fs};46I3wX7~tL=@)lYaHN~V=)h+8zYpWMTsdgLJ(n~ z;bRptk3&O4j6jS`iHjzYG-H0*-5~UoD+^tDCmO~|$i^^~?T{~BpGGvkyfiL+eIiLz znmr|;~+EjKvIWtJ@x|Rq-QXy!m&>h=hOQeJ!E9 z7Pg4UUmTVQbah=}$4DJcL=G}#2oWuoL(!uZeI0$7{?`7Z0mY9%a>C3rOYm2u28hgZ zokYscD;;ZHIB1vT-WB-?^$KGF12Qgj;Rss3?i#SW-6H=r=WRVzGdi3YTjStfe-o}j zjH^iqJ2ZS^1cc9p@DE1gfl;0#I4m#>CYRpCr33m=s8bOYTE_Vi77FTu_V#Rlz(_4+ zjuIUQQLF%ALLY;t!r6fhr1g(KKsi~pLOC@4&&bJ1I%NYmU&PKoDFR?cbc^KKS;d5# z)nb(%`bNz@wa$d>H(!s*U4t6jEJm-nx{Hxyyr6SAv`#ORsmhs%B2%+C=4ULePx8~l z&UO0e9p<4q6QiLjygmZH5ztrKXIMn2)l#IWVfN;7{sFYRIl($CMcbNvPdd_Z+&r8h z4wkD|Bfik~Fk=abTXrJmJ9!mw&yW{xjh~_y-@|hZNgM?W>CBP8(9eFrCv#d(!5@dq z51)orT%kW^#eeZT?K@13-Nt6E&D3dss3SYOqd@pBeInKkJ7Y%@rV7s#<)1kl08^ zRFuH;P)Lk$1npzsWzM<>u0O^u#ZqASffUp!Q-}g1@x^Tp+{c=pVsMiV{cyjGFDKY!7QK%WufPiRj}*qDOoTZ+mMNwL@HDi6N1wxG*coak}QYq z2s$aLJZ67!4=rTs1pciWE0aXwo$h;H!Y0+yf)UtP$`J%0xPZ zsl$e<$Ocht2L!GK?TbtAg@6a=)4y0^YlwD0Unv-3VC|2x|1uOQxV(eXAnEC`CGg`M zfpf-t7Bd6^f3`ss?^il$Qh7Ydt;71Z;6c$tCeaJU$L-sreMpYWYM0_cbRE)R&r#Jnq(`6$cqTqfWziChb zx^_H)Bj@GifCfu;65KOVbH}X;#EzyY>t7BvG*PxmBmdq!3Tyj^2j-}!4rSf9rv#sC zxsw-agV#oj@plTEiocpmao&zXFUN~@6!u#P-$Nc{Yr#cv*D(Bx%LZu>ZngJwC4DHh z4>am;kTecLnIh#f)Q5+aV57Z&G5mNyr=X-XjHRq`pXr@ByD8@ONzc))`yi7lzFxfT z!+B9nR%pNAl6tT@$W0>q9NAbg_0*nZM+XBMc(7e5{OO}22T(`BP?QYRfOU--rEyFy zt262T8x;h$!4J-VHoQJPobq==BtgprDWrHz)M9KcN77w>re>LCUPp_&C zRbrJOTgkK0wdh*FZY>RWIBA?(F;>i>!`5UUVpuhjMt8(PKHv?=9nk?xH7&^L!f*_y z{}HJSSpkuo1YJ;G!LiOCzLBhn28F~ik6sxixNLqRF@yIbr6HmT8a%UiD zn=r%ceVI@13$XYK2mwDQ4wr@-7{qa`5Dyh832SdxJ&%fC8BS{Ai1HFCZNL!kz)U-t z5-GPxi;X2ef+LvL{IIGxeDj}J9#&z)_@^VOPQb>J+F{0P1DifNLEz6;i~-Rv zhr9>eDWMgHT3;8k7YTmef7@roc|TlM@A87VcUv9X^(qHBVZ!ZUP7#JaMc${;b8d8v z=A*^HZojSmQCKF(EXjfukKlqfRYHw>)D31EFLneb%iig5D6S&x!k3tZfV25X;OnFK zAoSD%vD0uw%`O~2ZXp6W7O}K~!MyPSlS4A7jvs6G7VUn-GyHzF@#a)#V1=3s^5X%F z=SdVlB2u5-fh7u-PA*kpfU|F_DC3rxKuAy5+z9s4Aa+zp@S;klq5%zI6p#<6ovl^x zAoAF5)$Ge)v+}nfqhYRWv!%^wx%89smy{P8tyHg%8H>pjK{7Lm35c8B&5}h(Q%zpYV6;Q%;!pvv$ZB>reER@j?k33y)+qYjAcCQ({?)pl@ZDb zF$G;lF`FLBe{ntb3GFs19e0*~_d4&3qZyrCO&viJv}3>KG=pOl32cTVxRcp|po9?AaikcP1X!*kR|)rTF=7jYgl2Gia? zvl240fFrVVW(Z-%Qteq|g^+RYh7grafCFKB`~Jr;qHeLmAo=U$GAMTO71gdH6_w&H z75@oSW)`zk9Kk4|JlEj*+YY@o;yd4-AlEKKKNHWT6oL)hQg{&qVJzI4gnqT_#{{Ks zZ&k@mS@5C|&WdVCP3_hMhI3{ZUpUgZAd1h)i8&_zZyBp$hSC%)4L$nkni@Yvf-sFx zhVW+Hc0Sf-D%@!fHy)r6s1|s${lAYwHA)7UC-**Itng$Kt;y!tbLdfHZNY$g3XMh>ymwyv}iayLtK4>nxx;U+h*%#Ph{CzAHW`p{UH8JfFQ z5~jZ}IiKvDK&pgZFOzGEX>k8?z6mD_8AM}}>}iu+*Cf}epyff_jU4Ss2P*1ohLn%R zL(9!3=tOVd&iORi4M#}hLDF2W=@aJ>HD+sEjT;iKVga$Py7 z+_;T{U*!G!qzI)PO}=_?akrmDZ!q{blDlvJ=)Erur|=U4M_7KhfM2_pil2s)E*q_R zVT!jN+X=ahah;IvhTwQW2jKljKheq2P$ifFw=NvBj@GQ1DRkicbT4cYlbL7$G~9G$ zFKOwiS-AMx6!}*&!2xe4sZx%rH)uCqumeVJY5eU=faM6}bbf~ldoL_xM+~A@fK{1i zd_sMwNTsTm2a#RM%3g#7#`=1Dz2^1snm~aA1Ch0_YbZNqil?|=SB#rJq{G~XV$m`} z&hsOi;9TS;T@|-yIY^k!dQNLufYr8FQNC}m(ixAttjTAql4;L}#yxdqMOB`)ZZ>}r zf1f!%!#!`Lx<1$eyt{pZv#jP9bf(&+-o2&PQ+kVr+8OiTE>4!CoA};gQy=n3(_DR1Zh@o6oFCfzLP~pFbRdyTlb`Oedq$|t(yn==8DPNZf`pQkKed6p7 zN8#*GR#88Yh0-ncPuS>$uOb|EiFLRCPXa&Ky5Z zB+BjHOW4lSPT)CtsalWf{py&subJyvB&-`T#ncMkU>K6D?o4O6PT-;u0H;v101wMA z^z0SF?;%FR;S`yVHO|a6*79T)adB9tF%eKqRs+WWxtLJ? zl`k*LD1Lz41dE&M%!O;_)}n8H)}Tq#b2ctr!!?BztOGu0=9ad zhX?UBnvm*wLX$9p>S&noSV=8Ig@}!a_rYmD&*ziGg4q9P^q+7zcc>i9Lk2@4U)=*6Z8a%3^;FW?fBgn`^CKQ*ub%&HnPdG7ekK zCS5u~8$LQNAVvsil#Xts$t|CI`svP;#!+>yD7V(=<7&%m#|Ki_%-`0t9$|+scrfa# zmDlfcW9H)7OvQRkc1v5i!_wLPQA=8{J%u2}tE1RhQEKiCD@rbPXKk=dy#2-e`9*9{ z4Sozii{S4xZ3UHkRcr0PI>l}7QW}??mW`d6yO;$fv;hi&J(9xp&~Sql+a;G96;uW~ z4Nrw|O7~$F`vS zYNMH@ytIA)?FAC*X>ITfKFA0qK~u!L;EQul#wkHQI>P1E z1fO9k(0XCaq(CG^GD`xnw4MP48`#BC^b+U>c<4N?)BJM845edIZ!d*(uMh54vGN^G zjHW55s93|4KH1BOC~aPyuA)+g>uwbWhOO1t!)$|2?!cZ}Num&82#9oOvFM9_n(IH1 zwAi^;ka;{s{0;aQ2ffd-jaCX@WZ5zkhE#`oPhekzH`6lTby?MrHBc#KH~hgb-TPE6 zcf~Vl3j%81QgrImiOyDSB!vwEe=$$<`I#Z-ei5T*$%IKzL-)i^786wS(zg7Awmlw! zI7o^WUE(H9u9H0v5`c~=2wwUZZo-*~!59K18UaG}M)_{~*G!q4G zn~m06Y?|yD24jR2XAZT@-07H6rk`zqBNT*5GJKdlSiV-UrH9|f{cOpj@G+qXhAC&I zm^%HcGHZ6BE_R3Rg#Q}J}Fu*(s}}wet~tL zRN%?6g{H43rXlAV0riDeWs!u5a}2ZB2;s5R1y0&+R1DeEHf5<4t**M zp5a&?*TKK*o9y(IHjBxf!vlLj6*Nd+_aKE;YNC5sNz!6hTX5+3V5Jp%IsuR*9C@hmt&CYzJ7~+v|VEy|Ve{K5SVlZ}c7a2(87$3TrXi^TX z36Cmw%lUlRqdm9oe0NmqX2eKa)^%Sz>Dq#U3{|kPMimh>iwjh;bPz<(SXfTO&{(Lp zwUu}>MCI@+bXJ`0C(FT_P|UovU=DbGiVlz@J$uyqI^Mk*B=dDt?dYD;InZh={;nH3 zEMvD@JBjS9#d+ZL8R@a#9{qZG$cy_If&j1M`W`xsljrAleKibUv49AIcUO{fK6)>) zN8Hdtr1(sHaN>pSqXxJbQJlqLBKCnK0{dkv-#C`;M2q4IH@>ahy5ByyNKjd|xhtb& z{~^IUQb+iUb;#}`B?OcvXzdWf5E z4Q;yP9OdP75i#9ERwF@ejSlJ>W}Cec?J0-v!BADyTiNSzVs$Z<(+O<3y9#gn|_darno(_Z(@77Vb9ht!|4P4Zh*;r=2JzK(NGx$!c0gsu7di6Os)p2*;L@F zvYAjv9Pt1-5HzoO2XFk~s*a@rXOrffPEhRIIoC??3^zQ>E*_meXNI4Lj_c%YU*|pg zr0%*Of3l{ze(v{ptDtaPRjpW(v@}knJYBOc2rcNP5TyK0SFu}Y!I(N#zg9SH)5AlU zYfA3(vwiPZVE)TX{!R-A+x{vR8;D&*WK6&)FB>$`op;QRWzu0_hxxPHWBe_80*Qf2 zcAHg+gEh+;ei%pZ5ib4qKu>bPJBn5Ie&D4gsG6BYr%I~w(ATSsYBQJkgOqi$Y!Qi4 zXAcD0owP~?p%V0s_Jnj!cD{2-htF5Q*9scldtcDyI|BX~N&@*bjC>b)@jikr%)RCF zZ8hFCgzDoH=5dr&@YWKV2#Xd#xADBMe+TbUUm{D*WS`ZIMh{w`#1k0n+3oFGvG}Z}CbP9ltiUfmvwC)tc^|j9Stloh%AWti$r<%Mp!F4W98Z+;ispXbUr@kz`<}ny7N?BDk zmP$%k<@j5YKo`0dWnp~EeGb>OibgMKRE&9?L-Jmp^p&Q!@1jg2=w+dC% zjUD~`5+({AT-?O{E_HNxVKP%y*AGjl@^Xn?)?mhl(k3fjD=(gd}?mxR@3j$Rp` zdYiG|pOsEA`@AmVh)Rp~xdl@5+xAk2p=^g4L!K#U=^-{3~x7H{cCe^1jxWC76tSDeo(_)6(x#_2e z%V!nv4+QpCDZgPRnJH53roQ(cD#99!l$&9zGf~+6K3+z z^{Y)5?%>FMs@(MD>N#Iclm&Fr9yhOFjU3~pi*U%ACM1g-9Kde~6Z-2ROiOHvSf0%P zH1-dsvpUa&3{}HoAD!~@dB6M(v{_FYw@vMgAUreSi; z&j`iW62sR-NnOHdq9vDnk`3^H_q2XC66HxWvLC7L_eBP_LIO=hE?A4cr89Vlptwp< zO5jm_J_uGAGgTi~ei9S8z!6~M;|^jN2))U*MmI*dSbDjSCLY1jze^Odj7#A45hD)< z>c>Jd(rSk!3C^J*o7o|lhE?xhULQRif}PydEGt5%N*4nZU8o!StPFw3UWwYd zV8mZQH6{-Ol6@qhgNAi7mb(ns9JnW%i5CwD-*V7R7~36D~o^<)Z^MaR9F1SqXPdd{SO zK`(W1aO~Dsiobn~jHtQ02t$@24(c8$smk@mXmaNTpKm!3Mz{V(5@}J537p7B(8KkNpK*IKA^#b!4$RRwgg(1^NQ^?9qcdiu8B5S9=k2C2Q&?Fjw#h_N{r z0u2cbv`o*s-9H@nm?W6)*#pwQy_Ab8L8hm-vnvLUx`jcnz)KwxnGGzcL^>3FrGh-W1-ol73OJAqntl20B|Jv#h7l$wJ`M?)eAM#a(S-KBZOLjPDgy0#0EeFNim<67jO=kjKiE z0Q9&$oR)Fc$U+$sazwTWdN}IjONRo^4y@&{h*(Oe4wmEf{6cuR${Qi=bk~mK>1QQs zOP&e;p#fijG7X3$@8#}M;XTHB_h0a4{(Mub#&4SdWD8iu96e2v8U$?%_^9%jsT^HBn?qN-Ne#i4J%dKwt+%JcOGlnKY zFrt;|Yk#mIkrcVArMD5|EoiX0FFB*f2LR}@j9lEnhR#o&!o?^V7niceft{pB{KxoT&hg)Kg(kbqNhoFF zH1vO$A+n;INYcV4C1i5(+N@QA{AT(lJUD}n%uFT;IP7)FJu3WMUd9q!z>`K5*d0aH zU6l4JZ%jg5zQ~np-ue!A7bENy(LaMOE?!xlx)9&X6{Iy6xD|u64a&h-TK%1*N^q^z zqi}i7Kh8wb_+6`u3$EXQ(LQ@k69dff&?0NqTLSh&aN0x8Tj+Vz4IUis;5>@@xsFG0 zccENEF+v54#jEDq$7rSnz}BM%VGZ$q%b$1cBM8pkavPgI-Sej}b|6HGmG`tx6?itt zZv#ckZM3IFfv!&nT4$fH+%+p&BhN;8n=V$~^S361BztMdNnv4GL)EQEsFXF3Ol! zZZ#JUHnliiQX>&|EqCs6DaBM)O|Gu@lXSH3O;%Ibe@Gq!uJS?wK$hBHO&DI4B^&H` z4$77o9v~Y{?(!5nxQ9`RX=fVeEI~rVVB1JRe^Hq+l9FN~-_qYg0YK38UCr?EFLeol z4iJCVodRuvbs`=JXFyF?hSNrJG>fDZPK4uM0T-qDXV{aa_?4ads-GnZ;31GL+;o#105n5ix&dol4|5^xO z&zj<2?b_Y0p(&moPQw5Oy~Ct$jP^nR1;&Hyt#C9hHbdOuc$H<<9{@h96YSa1mvqV> z=1`yOAg8ggpPhO0;Ny}`Xl1l`t1ij;UAoX5tiC@pOj~i3nTWCIbWMAXA>(8?zkhUe zw1}mynwN)uuzo^7-Z4>s6n!x8iUtTkt_|dz{J^~F0TapOO_`d?-WZK=Ge8v>B9Wj% zb7SP}Bb>yTfe^x4SivW&t=D@24t+41yM+_fzn4-e zqc9}KWZAQ;3rZxt{||}cB;ACBJ=&+|Pt`XVyDc`szXmiUgbg4-<$|)dKlT6srA~&6 zyOl`*t`2IU5D-+y2ahM#SZgHDoHO4+jCr9WkNkO|JAEqEX7&b?3EtWdag>#Z`#PE$ z8#$EOR&t&4oFyu>6eZM z21ct)fxoj_P!_GE@ip1(<_4%kQ;dhWdB#q)?ZyZwN39y14B6QcKk!%ZeGo!B2y~op zhw3N#u*CSA_FFU!tc%vRr;;v;YIf3MYIas@m8ZGg*~(uZ|NG(k5kVIpo72qI zz}L&ewwaTykd5z9h2DBlcWVTNF!_BCIb)M{$Qw~pa$B6 zQ_qBv2tRolJxK#=pW$h4;Yiks?gsnS_I^q+%>C)F&cM;M@hm@w!=Zj~fAKfUO!#Hy zh6Gr5fPeThxx!6o84$p7$9HjDr+4E4)N`ZTf|hq)rfq>#hAOyB+%2rw!`%dYnodVs zaXlfhHMXxND{X8gO`UHl-7;S9L}fsRf{fCbdKcq^b_bYe(y;`XKxM`s<_fjDNfr|x zRv+%eki;;446~61g8Eg<$V)-$ps|v$2D7FRT!;+(=YU09Z)e>4d|I?~d4{(sC1>wLW*O_wn7RQQEk!Mzi0P>H_#am628a3CUe|1Xy(6O%SUP_+1rxkL!@d z5oPsc1TB5#+*-LvEin%e3PUvOtj%6;B8#?Se(0&a zK3WhEA#8s{qM63SI93z_I=ebyaJrbAmpw0aVa6J^nl^&weUbms%+?U$G6Y*p@aB6w zgWd{QJw3(7z(wCUHn+63P^mBB3Au&gZnMs0ldn2j3ZZcN%q|pp_BdbQu2+S?f-Fk1 zbZhPS@L+|cnPzDwIhEShV6SB@$&r(Im4YCn>@A|S`fkB4BB&#R0|-K zCx{}ju*8bCk~1wO^wUAjqUfOQ0(g_H)A@r9aA`w(KdU*sc>le9*HF=nI@EjF&eIT@ z;CXcG`>1ZI^7O;4n6;Q#c$8kkaf?~Xl*pQ;%XnGQ2118Aj5ao@iV57@)rOx5SP zN@%Pr&0di?>8Z?iPrqFqEC~~8oS2QRf{#_GWE}$ZbxUCHsU5bP2?&U6AWw6n;s(@7 z;ZiT$Yeh_gRW-`iTe9bt>qx*51Nk$>!xR}nC}=J4MkbZ%y-bg*BOx9Q?Q zL}qRk`#FDf?b@5j>rc`lDH%`0m!tK}Ou$x)U`}}M>mG3K^cLAq^J%~$AU`XR|9w{|M367E}ckG*|)b@7bD5)y}Vq{vx%Ca%BDpGKW*hF-w zra6!#n%OpShUX8o-G#j10yVu561_frgLQ5N1e7H$s@6Nk0*%7S_nEKFrS5B#0|xLj z$ZaKSDqHN!tcd0=C}sTI=>YhhPYAeieeMBo8l(>lS(_xc+1+TRcc!To03e48Xx_0E!=ia*jeS6)lA)cmg zU_Z}q`b}I+;icax)O=eI^p&acC*~z{qf|WFM8^lDcHpBt`!93}&wmufNb4imaAb-X z&+;#!1HttGq^`UX6U%N5`Auf49j(E$%`aXJT5ap+$9i(oO>Ctx6a53tj5)L6@Fmve znfcFTWS7)DwSZ7<;)mwq;3_gNtzi0Un-|etbpsWif>t;mm4WPeF{IM-U^S-j742@ATkPVa-UW^x#!0gRKxzR1`L-BwB;D-ik(@qM!r$>3|EzOwCdbJJ^}@ z4HH8dV*Z+#SXw=%<_&i}vN%8PU3bcJnP~ z4iVME0-f!ecHd*Xp(dbtw2*%-R{J~5=i3czt{1EDgeH}^2g3HbDyAkvT#cA$=zj89 z=VH$6Oep9DqeJ<|MG=y@_L))QJWKpVf2m3S6ziLMdG?Q1AL&qKzC9)9vdJz+N2aDX zjzuJ%?ODg+ReUi-b$& zp#DT0zN}gh$b#r25R#^Tt?li0Y&8-6y7nM;JA!RBbA{{~7j$B0>2vdw5&g1EtIRU! zp7L(>U}3Nt;$o<52Z(Lbk*edqEFY@B8s$vl@YHXlG+ui{WNT>SBCl7v-Ftgm@$v}1 zw=&60Aj)uHwzO)m{?NT<+u!X}EvHRowO65bb$g3M@aRiX91ZYbdz?OTC!-{_wYV=O zmZu9gJsd($Sqv>z4}`O}9kjw6H)-0fnX53n(FzJuY^QhEWJeoD?Z~=Iw=|coFIlVr zBsdQ5X4rD5n%}$UpXQ{h7_sIdmOB9`)COB%6qMvw8n8Bt8O^GnFcymk=3%OM-#7q! zZrO9I$WdM~Lq$T}$dLFT5lC@T#tVaW}`hddN{9!vfIm)Cil}zdk&M2n@53 zzc)JFhN5{>=k3m<)b0g|!0NKJ-mSV(eZ3yS`c~corl0-Z4g3V8SQU5vwNc?86)z%w-Itx19tGeIc@m z_8qpt0sg3hS1OEqr||#2K>Q_`Yiiz-=L5wGkT!VWjX=Me_3)ZjYy8>I;8tP+)E-IZL`X6W-X3eLhAYi9v z;U>tiz`F=>2ZD?n{NhzBRKr65c5IrU>*(hLzcU&d9>`VRiB|AvfZ%@6ja~`djoHuD zPf1$&K(9C6u|r6+l`hlROpS%u#zp-r{_VnMb`ZV2U&~}|UWz8H!XhvYT47jdke&aFGM_26KMP>Q zPtXuvkq;mQj}w>Ie*a>M;@;KJIN-wjRj*2!Wl`9SM{7hZJegECxw1z(>FTz%rQLvk zPlPm$&p$=1VgC8n`uyeWxgEglH4{5-gX5GC8*dr;ko(H)6QYtqNE*@zN|s6n-Hnd# z_c0r{9qavbxcA>>Nj_OsL&^gWl|>>vN3u#sdmy7H7e;p&b=A1_KB|WOX=DBXI=oe7 z(Zkg6@`uqC{2|ICKeN)|`F4QNxv1LS)i_@?ci1(%Is>jx|UD z2Gi=H!Ch8$+Gh08#*yrSxZ^u9ybUtrfpkRi$<4%NCE+FYj{_PlzlTKDbY4H>+uq1X zVQDcwRukVwo9CiMkng9l(s!)2YNU$SmGRbfZtmv8BOCEy@aOjY;6`27t1-Ggof(q zW5Vkr*qpi&_R;U$IFW%;Ti)5dceCzR7-t`<)a2w?)Trjxr96a~Yt2Gs@y60px@Z+~ z+S6zXMkl!$f_t=-mSw=Kd-Sitgj;vnVs6Lo)bLoyy^a+HNo6*I{I59*74#{{ti+48y}4CPHC>Kh z#Iq-3d=u6q5i=*O%9EX1qehv3^n0_tVgtdH#ZhTQf-gKEGlC)YZF_M&`DY)gL4y{& z1L1Bk%lhFG4kC@Y%%&T&Ytgo5ccqR}J(q6xnWN}(@&vmchqq1V-;rOmY!#?{U~Jq9 zGE~g%iwN37K;JkVmT#7aO-m4L)RUO_0v!^|3J@Wd^B`Xa;dnC<;z&jOjyfSzeM#WJuJqO zpU%bx!G+&nI9ivd@WAzeX>XwccsBwER3P})kondmN2G?LgIJ(H@C-2gIi~rJ_ae6Q z4G>X~`6W1ugU#gcu6gqgp1>>ssftl>mwdx(0Ijlv;801o^iAXkM2fF;Ab)wCZZW|O zaEJ$aYhNh(KLI2V+wVWg3F_$h1d!P7k+1z*{2|W)M*3Do3<+<;YSB8+{CL774*(R_ z1Ax|viYmy{0?>q_%DS=LJD6qC{SzZzJA1j?AcffVlBP^?)A{#7@cJtvccLp8b4Q;5OP`t8Ze9K*ce9r>>Pr3gk%E<00Jte5?$rr0}9|p<~?4il*?HN znE^K$^=3Ngj6|p+L_@pX$j+dvgF9>%bm6}l;_SG{hTXFn$3?@qFiv{7iOE4*`CA~E z@CJec1O5CMp7(MdK*2VbsY5ENFc}$Ilbx7U&;+#z>%IK5A~#<-b0l|LLfV$p#?GY+ zQwl>e!}we6Uvf@626r3`^dAh1l)z!6C^D1CoQI$EnxP*VObXnno z(D2U6uFX@I%}%d2`$WwxUeYTBsFJdyGm8qdGRp$s22)zvOh4Zl8EGcKfJ)((>=fn7 zhk|aUuBn30^-RXSC8GIQFrnT1(dWKS-xK8OeR0O^I%OddMKi@cnx&S1ckryb+}TGd+) z9vD4*@U_unb=B2%bE_(uye~ih)#E=t`sLRHM^B$Rdi2c6qhic&@E?L;5C{CQ+J&>y z%}u8skWDzxss4$iStDMpQcozmn9x&4SuK1NGTql_hPGo}2JaNqRkeI{_LlnkJ=MDA zQ$AQ_EVkNII=_bh(22J=DXaI?#bxgNR>S6ks&n6J`~L1qp(rk2m)v4%ZYr$<2t?rA z9pFC!K_Hg+_Pte+k-om}@`2!q?0Fbzbqs~d} zT$9cz0^2KwUOv9Ha6x5FMy(;HivOhI^oF(@d#jxI9=Y?p(VD32k>TMJ%Npv+nq%{I zSxsrUje-H--r_@m7R;in)hu)o09!cg=MY}#5H52FFPRAEZ%g4i8V~)BYmt8bo#*_# zg#28*|53%xwB7&UpT7T5b?g{B6UPxf{-5X&vj)k)?*SM)gF^}dfL}06amY$B!~g*5 zX4c}69x+5S{$FT24e1p_R2XxW>Bb=s#Sqi@*Jvw^=Q9!_ru7E18i)Kr49NfhjZn-Z zF~o>t-pj1QA)kvO5%{YE%sL$MM-oDGU+{XDN^iImU5h~1=PHOfNQk+6k{f?^0kKpf zAhz;qe@5d+=Z}4Y6~uO*JUQ}|e%QiW(D(9VSV{%NFwOb_)I+0PK#bh?Bt9U!f*28R zQ?eEpM+GrQ0WlAF3^7bdYSghgaO>8m1jMkA0^MH#*4eHhCimbh{xBc_IBJL`y;wp_ zRbmqm>u*0?S(~xx(A*6_YOg!m`F{$CQN@EfxDHh~)p)XYu?%6xC?jJzPo;~fUOAy3 zHd#Jwl75&ZANI%Nn;q1{hL0D||K8AUn|xT`*$TUQ*q)04j^bf6hwCKuu>3SE9(IS! zY*Q!;l&FWP==#dw1=gJ)Y2G+MuoHROZaG3Pn{-eL4EhrnTOcF^`ib1p4;s5Q!Xw%Hz6(h&uWJ0v^LGK zZcWY0zj-a8EnL$u<-4CY%A7^bu&$r%SbTMO7W``Ck&zLi8CLR?X4o6T<8zZV!;V8( zvDU5{<^cfnZ`?JyL*SjMY#5k^^EVB5T-y%e(@Bb9D{u)js4mSEU?Tg2sAwnOL@v#p z3kA5k4bv!LmU~(sEXh$ljMCa9u`sg6c-hAYl_YI=_2*qxJE{r>W^}!?YVb$PiUv#S zM{D{%-P^jOpm1vw)(gv>zP+oUKY#D4-qC{9-cKupJ*^%#oYPlgR|_jH=*>Yt3n^SY zySN!f_W?ZsiWu!;8iXSKq7>{?+Fj#^=#G}^Vo7GbTh#Y8L2Ys1lgUbqtoRrea}a7DgH95cj@q#uxrVW_(ltzu>Nu z8D9)BpaRs*eL!Y>F+_u=qU|)KR}4{M%vJ6NneoLC6G}r{X*{2i5HYPcxcA76FNS2` zuSO{5kr-mcG4JKxB{RMl5`n)uz}+D;z8E5^OCp}AM*J^4M#eh;0000100003073=f zZX90^JoNwz2mk;8007y{!uJ3G007;0CQ>({keUgO?C*4T{drr)KQZiU~Go#xb$wOJt z(~AzU_AxIK26$(1OuCYkT%YIqHjb$xB%NPhIG3zA7RrTVT9bkpAm@VdDbJdr4f~Ac zU1mRrflxCflSR6v40*01ai@jt2psLFZ`Weq~4>lHA;pBjgcN57=aM{~h`^8d@&~{s=vIOZ_Kt+(GWhDRc?@ z@H5oKj`ZRO=!NynjC@NfKK*Qo0il`bOb@F%?NL=VMOAk!wFIh0AWl6bCW)L^D5*{$ zF-gR`C0&Jex*&DFN^c9IvTB7&q=KVAoA;kbCqeSSpSYwLDMa!J0H->_Kh~&j^7AZX zKx(fklF3e3*q_C`z2Llj*z<61?g#lzysSkxwDUr>m7~q-&XLyX%tcx!ZP^b&qu4@q|5fJX<^$yg9rRz5BfH zAO=c6MWBhW23LaT!Z#5LNsp9ArXV|zQ^`&gNi;vb)*$ zoWQl>rgPW0pL{{S5L9SS76X)++0FB2%JA;(X$zUBO;%Z?JdThwRh# z75lFJ%zkhGNb#nSe*oa~p+*1!0003F0FeL|0ABzF00ICI051TV0008M4}$;$00DT~ zM9cvOgiruQ(fjJpFv-UNGDFf55}>8Ds1^`_kU#*^0$NnjBjA7oL>5vMKEMpN_%hoG zk`0^jwjkGFi%g>(o?ivICi{1Jwf|(=&%($-xq&DQqvugN ztUYW8waxL=i{Ne*ynRmWWELNOU9e9eqsk8n5ix(vu)!LBvIm_{@tRC5%I0Tf5wyQM?WE8UU$y4-t7};?f;i|y+rK} z?O{jqV$h9@UvtC-&wRG;LZ0v;##~_*1-s@s)e()obEmIMu?Fj$U6FX&V_;?gga30F zj2N&004jq4(Exbb6ph6}Tmb+DJID!JoppD2-*|#M`~>~D`-vVX<9ES*YkDlFnbS;1 zFOBEr=kypJ)SzH#yMc&{%FU|@Urv-766UP~oql_>!Hymb$ z9Aw3QcEnzGmff_$PFnm=&GIj0VWh-%s>D`OY$gmg5@J1JvKBX3jlZ!H7t1lR6g60k zS}a7xeB_n6h?pJoZ&>^ZiQhr-EBMOKV22+8@!c=J`7FNr#L#Cj@V?RaiXJ1nhz=HQ zk7#*Jnr_i>iMsQKnp0Grvy?mUC4)kKmTV?YG%`zM(qY>QJCcOPi{KZ#Z7X2U;Y15xJX=_dCgXAzbLJ)`o=-^E^n!2g*`_jy)dz=uz*P#H;h&EE=xH1fvuzXD_}@!IKPH*BSl4i1{$>1+2Aq9 p8Fz|_>^~&P0OA5MLJlqnq#yvoLvRryXnceif+GO{i1@5#0049&*eL)2 diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts index f0381bf..c641649 100644 --- a/e2e/app.spec.ts +++ b/e2e/app.spec.ts @@ -1,7 +1,5 @@ import { expect, test, type Page } from '@playwright/test'; -type WebGpuFailureMode = 'adapter-null' | 'adapter-rejects' | 'device-rejects'; - const disableWebGpu = async (page: Page) => { await page.addInitScript(() => { Object.defineProperty(navigator, 'gpu', { @@ -11,54 +9,6 @@ const disableWebGpu = async (page: Page) => { }); }; -const emulateWebGpuFailure = async (page: Page, mode: WebGpuFailureMode) => { - await page.addInitScript((failureMode) => { - const limits = { - maxBufferSize: 256 * 1024 * 1024, - maxComputeWorkgroupsPerDimension: 65_535, - maxStorageBufferBindingSize: 128 * 1024 * 1024, - }; - const adapter = { - features: new Set(), - info: { - architecture: 'test', - description: 'Playwright fake adapter', - device: 'test-device', - isFallbackAdapter: false, - subgroupMaxSize: 0, - subgroupMinSize: 0, - vendor: 'test-vendor', - }, - limits, - requestDevice: async () => { - if (failureMode === 'device-rejects') { - throw new Error('Playwright fake device failure'); - } - - return {}; - }, - }; - - Object.defineProperty(navigator, 'gpu', { - configurable: true, - value: { - getPreferredCanvasFormat: () => 'rgba8unorm', - requestAdapter: async () => { - if (failureMode === 'adapter-null') { - return null; - } - - if (failureMode === 'adapter-rejects') { - throw new Error('Playwright fake adapter failure'); - } - - return adapter; - }, - }, - }); - }, mode); -}; - const getFirstSwatchColor = (page: Page) => page .locator('.color-swatch') @@ -71,6 +21,18 @@ const getGardenBackground = (page: Page) => ); test('loads the app shell and WebGPU fallback in Chromium', async ({ page }) => { + const browserFailures: Array = []; + + page.on('requestfailed', (request) => { + const failure = request.failure(); + browserFailures.push(`${request.method()} ${request.url()} ${failure?.errorText}`); + }); + page.on('response', (response) => { + if (response.status() >= 400) { + browserFailures.push(`${response.status()} ${response.url()}`); + } + }); + await disableWebGpu(page); await page.goto('/'); @@ -85,6 +47,7 @@ test('loads the app shell and WebGPU fallback in Chromium', async ({ page }) => await page.getByRole('button', { name: 'About' }).click(); await expect(page.getByRole('heading', { name: 'Fleeting Garden' })).toBeVisible(); + expect(browserFailures).toEqual([]); }); test('keeps fallback controls interactive and accessible', async ({ page }) => { @@ -106,13 +69,21 @@ test('keeps fallback controls interactive and accessible', async ({ page }) => { await expect(aboutPanel).toHaveAttribute('aria-hidden', 'true'); await expect(aboutPanel).toHaveAttribute('inert', ''); - const settingsButton = page.getByRole('button', { name: 'Show config overlay' }); + const settingsButton = page.locator('button.settings'); + await expect(settingsButton).toHaveAttribute('aria-label', 'Show config overlay'); + await expect(settingsButton).toHaveAttribute('aria-expanded', 'false'); await settingsButton.click(); - await expect(page.getByRole('button', { name: 'Hide config overlay' })).toHaveAttribute( - 'aria-expanded', - 'true' - ); + await expect(settingsButton).toHaveAttribute('aria-expanded', 'true'); + await expect(settingsButton).toHaveAttribute('aria-label', 'Hide config overlay'); await expect(page.locator('.config-pane')).toBeVisible(); + await expect(page.locator('.config-pane')).toContainText('Runtime'); + await expect(page.locator('.color-reaction-matrix')).toBeVisible(); + + const colorReaction = page.getByLabel('Color 1 agents reacting to color 2'); + await colorReaction.selectOption('-1'); + await expect(colorReaction).toHaveValue('-1'); + await settingsButton.click(); + await expect(settingsButton).toHaveAttribute('aria-expanded', 'false'); const soundButton = page.locator('button.sound'); await expect(soundButton).toHaveAttribute('aria-pressed', 'false'); @@ -146,98 +117,35 @@ test('keeps fallback controls interactive and accessible', async ({ page }) => { await expect(page.locator('.mirror-segment-control')).toHaveClass(/active/); }); -( - [ - { - expectedCode: 'webgpu-adapter-unavailable', - expectedMessage: - 'WebGPU is available, but this browser could not provide a compatible GPU adapter.', - mode: 'adapter-null', - }, - { - expectedCode: 'webgpu-adapter-unavailable', - expectedMessage: 'Could not request a WebGPU adapter.', - mode: 'adapter-rejects', - }, - { - expectedCode: 'webgpu-device-unavailable', - expectedMessage: 'Could not create a WebGPU device for this adapter.', - mode: 'device-rejects', - }, - ] satisfies Array<{ - expectedCode: string; - expectedMessage: string; - mode: WebGpuFailureMode; - }> -).forEach(({ expectedCode, expectedMessage, mode }) => { - test(`reports ${mode} startup failures without leaving the shell loading`, async ({ - page, - }) => { - await emulateWebGpuFailure(page, mode); - - await page.goto('/'); - - await expect(page.locator('body')).not.toHaveClass(/is-loading/); - await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible(); - await expect(page.getByRole('alert')).toContainText(expectedMessage); - await expect(page.getByRole('alert')).toContainText(expectedCode); - }); -}); - -test('serves the production bundle without missing browser assets', async ({ page }) => { - const browserFailures: Array = []; - - page.on('requestfailed', (request) => { - const failure = request.failure(); - browserFailures.push(`${request.method()} ${request.url()} ${failure?.errorText}`); - }); - page.on('response', (response) => { - if (response.status() >= 400) { - browserFailures.push(`${response.status()} ${response.url()}`); - } - }); - +test('keeps the fallback shell usable on mobile', async ({ page }) => { + await page.setViewportSize({ height: 844, width: 390 }); await disableWebGpu(page); + await page.goto('/'); await expect(page.locator('body')).not.toHaveClass(/is-loading/); - expect(browserFailures).toEqual([]); -}); + const canvasBox = await page + .getByRole('img', { name: 'Interactive generative garden canvas' }) + .boundingBox(); + expect(canvasBox?.width).toBeGreaterThan(0); + expect(canvasBox?.height).toBeGreaterThan(0); + await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'About' })).toBeVisible(); + await expect(page.getByRole('alert')).toContainText('Fleeting Garden needs WebGPU'); -[ - { height: 720, name: 'desktop', width: 1280 }, - { height: 844, name: 'mobile', width: 390 }, -].forEach(({ height, name, width }) => { - test(`keeps the fallback shell usable on ${name}`, async ({ page }) => { - await page.setViewportSize({ height, width }); - await disableWebGpu(page); + const aboutButtonReceivesPointer = await page + .getByRole('button', { name: 'About' }) + .evaluate((button) => { + const rect = button.getBoundingClientRect(); + const target = document.elementFromPoint( + rect.left + rect.width / 2, + rect.top + rect.height / 2 + ); - await page.goto('/'); - await expect(page.locator('body')).not.toHaveClass(/is-loading/); + return button === target || button.contains(target); + }); - const canvasBox = await page - .getByRole('img', { name: 'Interactive generative garden canvas' }) - .boundingBox(); - expect(canvasBox?.width).toBeGreaterThan(0); - expect(canvasBox?.height).toBeGreaterThan(0); - await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'About' })).toBeVisible(); - await expect(page.getByRole('alert')).toContainText('Fleeting Garden needs WebGPU'); - - const aboutButtonReceivesPointer = await page - .getByRole('button', { name: 'About' }) - .evaluate((button) => { - const rect = button.getBoundingClientRect(); - const target = document.elementFromPoint( - rect.left + rect.width / 2, - rect.top + rect.height / 2 - ); - - return button === target || button.contains(target); - }); - - expect(aboutButtonReceivesPointer).toBe(true); - }); + expect(aboutButtonReceivesPointer).toBe(true); }); test('hides the bottom dock after the cursor leaves fullscreen controls', async ({ @@ -305,5 +213,5 @@ test('keeps the bottom dock visible in mobile fullscreen', async ({ page }) => { await page.waitForTimeout(5200); await expect(page.locator('aside.control-dock')).not.toHaveClass(/menu-hidden/); - await expect(page.getByRole('button', { name: 'Show config overlay' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'About' })).toBeVisible(); }); diff --git a/index.html b/index.html index 1957196..0a66ab3 100644 --- a/index.html +++ b/index.html @@ -22,10 +22,9 @@ - - - - + + + Fleeting Garden @@ -41,13 +40,13 @@

Fleeting Garden is a pointer-driven WebGPU drawing canvas. Drag or touch the scene - to paint coloured paths, then use the toolbar to change colours, erase, adjust the - config overlay, export, restart, or open more information. + to paint coloured paths, then use the toolbar to change colours, erase, export, + adjust the config overlay, restart, or open more information.

-
+
+ >
@@ -72,7 +69,15 @@