WIP
This commit is contained in:
parent
34ac200437
commit
39b0160064
136 changed files with 7144 additions and 1965 deletions
|
|
@ -43,5 +43,4 @@ jobs:
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
run: |
|
run: |
|
||||||
apt update && apt install -y rsync
|
apt update && apt install -y rsync
|
||||||
mkdir -p /pages
|
|
||||||
rsync -a --delete dist/ /pages/fleeting-garden
|
rsync -a --delete dist/ /pages/fleeting-garden
|
||||||
|
|
|
||||||
25
index.html
25
index.html
|
|
@ -41,8 +41,8 @@
|
||||||
</canvas>
|
</canvas>
|
||||||
<p id="canvas-description" class="visually-hidden">
|
<p id="canvas-description" class="visually-hidden">
|
||||||
Fleeting Garden is a pointer-driven WebGPU drawing canvas. Drag or touch the scene
|
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
|
to paint coloured paths, then use the toolbar to change colours, erase, adjust the
|
||||||
settings, export, restart, or open more information.
|
config overlay, export, restart, or open more information.
|
||||||
</p>
|
</p>
|
||||||
<div class="eraser-preview" aria-hidden="true"></div>
|
<div class="eraser-preview" aria-hidden="true"></div>
|
||||||
<div class="garden-prompt" aria-live="polite"></div>
|
<div class="garden-prompt" aria-live="polite"></div>
|
||||||
|
|
@ -74,18 +74,6 @@
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section
|
|
||||||
id="settings-panel"
|
|
||||||
class="pages hidden settings-page"
|
|
||||||
aria-hidden="true"
|
|
||||||
inert
|
|
||||||
>
|
|
||||||
<section>
|
|
||||||
<div class="settings-content"></div>
|
|
||||||
<button id="apply-defaults" class="large-button">Apply defaults</button>
|
|
||||||
</section>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="toolbar-row" role="toolbar" aria-label="Garden toolbar">
|
<div class="toolbar-row" role="toolbar" aria-label="Garden toolbar">
|
||||||
<button
|
<button
|
||||||
class="previous-vibe vibe-button"
|
class="previous-vibe vibe-button"
|
||||||
|
|
@ -157,10 +145,9 @@
|
||||||
></button>
|
></button>
|
||||||
<button
|
<button
|
||||||
class="settings"
|
class="settings"
|
||||||
aria-label="Settings"
|
aria-label="Show config overlay"
|
||||||
aria-controls="settings-panel"
|
|
||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
title="Settings"
|
title="Show config overlay"
|
||||||
></button>
|
></button>
|
||||||
<button
|
<button
|
||||||
class="sound"
|
class="sound"
|
||||||
|
|
@ -170,8 +157,8 @@
|
||||||
></button>
|
></button>
|
||||||
<button
|
<button
|
||||||
class="export-4k"
|
class="export-4k"
|
||||||
aria-label="Download 4K image"
|
aria-label="Download 4K upscale image"
|
||||||
title="Download 4K image"
|
title="Download 4K upscale of the live simulation"
|
||||||
></button>
|
></button>
|
||||||
<span class="export-status" aria-live="polite"></span>
|
<span class="export-status" aria-live="polite"></span>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
35
package-lock.json
generated
35
package-lock.json
generated
|
|
@ -9,19 +9,22 @@
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"gl-matrix": "^3.4.4"
|
"tweakpane": "^4.0.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@ianvs/prettier-plugin-sort-imports": "^4.7.1",
|
"@ianvs/prettier-plugin-sort-imports": "^4.7.1",
|
||||||
|
"@tweakpane/core": "^2.0.5",
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^25.6.0",
|
||||||
"@vite-pwa/assets-generator": "^1.0.2",
|
"@vite-pwa/assets-generator": "^1.0.2",
|
||||||
|
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
||||||
"@webgpu/types": "^0.1.69",
|
"@webgpu/types": "^0.1.69",
|
||||||
"browserslist": "^4.28.2",
|
"browserslist": "^4.28.2",
|
||||||
"eslint": "^10.3.0",
|
"eslint": "^10.3.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-prettier": "^5.5.5",
|
"eslint-plugin-prettier": "^5.5.5",
|
||||||
"eslint-plugin-unused-imports": "^4.4.1",
|
"eslint-plugin-unused-imports": "^4.4.1",
|
||||||
|
"gl-matrix": "^3.4.4",
|
||||||
"globals": "^17.6.0",
|
"globals": "^17.6.0",
|
||||||
"lightningcss": "^1.32.0",
|
"lightningcss": "^1.32.0",
|
||||||
"npm-check-updates": "^22.1.0",
|
"npm-check-updates": "^22.1.0",
|
||||||
|
|
@ -1560,6 +1563,13 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@tweakpane/core": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tweakpane/core/-/core-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-punBgD5rKCF5vcNo6BsSOXiDR/NSs9VM7SG65QSLJIxfRaGgj54ree9zQW6bO3pNFf3AogiGgaNODUVQRk9YqQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.1",
|
"version": "0.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||||
|
|
@ -1874,6 +1884,19 @@
|
||||||
"url": "https://github.com/sponsors/antfu"
|
"url": "https://github.com/sponsors/antfu"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vitejs/plugin-basic-ssl": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vitest/expect": {
|
"node_modules/@vitest/expect": {
|
||||||
"version": "4.1.5",
|
"version": "4.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
|
||||||
|
|
@ -2740,6 +2763,7 @@
|
||||||
"version": "3.4.4",
|
"version": "3.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
|
||||||
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
|
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/glob-parent": {
|
"node_modules/glob-parent": {
|
||||||
|
|
@ -3831,6 +3855,15 @@
|
||||||
"license": "0BSD",
|
"license": "0BSD",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/tweakpane": {
|
||||||
|
"version": "4.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/tweakpane/-/tweakpane-4.0.5.tgz",
|
||||||
|
"integrity": "sha512-rxEXdSI+ArlG1RyO6FghC4ZUX8JkEfz8F3v1JuteXSV0pEtHJzyo07fcDG+NsJfN5L39kSbCYbB9cBGHyuI/tQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/cocopon"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/type-check": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -38,17 +38,19 @@
|
||||||
"supports webgpu and last 2 years"
|
"supports webgpu and last 2 years"
|
||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"gl-matrix": "^3.4.4",
|
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@ianvs/prettier-plugin-sort-imports": "^4.7.1",
|
"@ianvs/prettier-plugin-sort-imports": "^4.7.1",
|
||||||
|
"@tweakpane/core": "^2.0.5",
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^25.6.0",
|
||||||
"@vite-pwa/assets-generator": "^1.0.2",
|
"@vite-pwa/assets-generator": "^1.0.2",
|
||||||
|
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
||||||
"@webgpu/types": "^0.1.69",
|
"@webgpu/types": "^0.1.69",
|
||||||
"browserslist": "^4.28.2",
|
"browserslist": "^4.28.2",
|
||||||
"eslint": "^10.3.0",
|
"eslint": "^10.3.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-prettier": "^5.5.5",
|
"eslint-plugin-prettier": "^5.5.5",
|
||||||
"eslint-plugin-unused-imports": "^4.4.1",
|
"eslint-plugin-unused-imports": "^4.4.1",
|
||||||
|
"gl-matrix": "^3.4.4",
|
||||||
"globals": "^17.6.0",
|
"globals": "^17.6.0",
|
||||||
"lightningcss": "^1.32.0",
|
"lightningcss": "^1.32.0",
|
||||||
"npm-check-updates": "^22.1.0",
|
"npm-check-updates": "^22.1.0",
|
||||||
|
|
@ -59,5 +61,8 @@
|
||||||
"vite": "^8.0.10",
|
"vite": "^8.0.10",
|
||||||
"vite-plugin-singlefile": "^2.3.3",
|
"vite-plugin-singlefile": "^2.3.3",
|
||||||
"vitest": "^4.1.5"
|
"vitest": "^4.1.5"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"tweakpane": "^4.0.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
BIN
public/audio/piano/A0v12.m4a
Normal file
BIN
public/audio/piano/A0v12.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/audio/piano/A1v12.m4a
Normal file
BIN
public/audio/piano/A1v12.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/audio/piano/A2v12.m4a
Normal file
BIN
public/audio/piano/A2v12.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/audio/piano/A3v12.m4a
Normal file
BIN
public/audio/piano/A3v12.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/audio/piano/A4v12.m4a
Normal file
BIN
public/audio/piano/A4v12.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/audio/piano/A5v12.m4a
Normal file
BIN
public/audio/piano/A5v12.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/audio/piano/A6v12.m4a
Normal file
BIN
public/audio/piano/A6v12.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/audio/piano/A7v12.m4a
Normal file
BIN
public/audio/piano/A7v12.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/audio/piano/C1v12.m4a
Normal file
BIN
public/audio/piano/C1v12.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/audio/piano/C2v12.m4a
Normal file
BIN
public/audio/piano/C2v12.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/audio/piano/C3v12.m4a
Normal file
BIN
public/audio/piano/C3v12.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/audio/piano/C4v12.m4a
Normal file
BIN
public/audio/piano/C4v12.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/audio/piano/C5v12.m4a
Normal file
BIN
public/audio/piano/C5v12.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/audio/piano/C6v12.m4a
Normal file
BIN
public/audio/piano/C6v12.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/audio/piano/C7v12.m4a
Normal file
BIN
public/audio/piano/C7v12.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/audio/piano/C8v12.m4a
Normal file
BIN
public/audio/piano/C8v12.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/audio/piano/Dsharp1v12.m4a
Normal file
BIN
public/audio/piano/Dsharp1v12.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/audio/piano/Dsharp2v12.m4a
Normal file
BIN
public/audio/piano/Dsharp2v12.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/audio/piano/Dsharp3v12.m4a
Normal file
BIN
public/audio/piano/Dsharp3v12.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/audio/piano/Dsharp4v12.m4a
Normal file
BIN
public/audio/piano/Dsharp4v12.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/audio/piano/Dsharp5v12.m4a
Normal file
BIN
public/audio/piano/Dsharp5v12.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/audio/piano/Dsharp6v12.m4a
Normal file
BIN
public/audio/piano/Dsharp6v12.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/audio/piano/Dsharp7v12.m4a
Normal file
BIN
public/audio/piano/Dsharp7v12.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/audio/piano/Fsharp1v12.m4a
Normal file
BIN
public/audio/piano/Fsharp1v12.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/audio/piano/Fsharp2v12.m4a
Normal file
BIN
public/audio/piano/Fsharp2v12.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/audio/piano/Fsharp3v12.m4a
Normal file
BIN
public/audio/piano/Fsharp3v12.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/audio/piano/Fsharp4v12.m4a
Normal file
BIN
public/audio/piano/Fsharp4v12.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/audio/piano/Fsharp5v12.m4a
Normal file
BIN
public/audio/piano/Fsharp5v12.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/audio/piano/Fsharp6v12.m4a
Normal file
BIN
public/audio/piano/Fsharp6v12.m4a
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/audio/piano/Fsharp7v12.m4a
Normal file
BIN
public/audio/piano/Fsharp7v12.m4a
Normal file
Binary file not shown.
Binary file not shown.
|
|
@ -1,5 +1,6 @@
|
||||||
Piano samples are Salamander Grand Piano V3 OGG samples by Alexander Holm,
|
Piano samples are Salamander Grand Piano V3 samples by Alexander Holm,
|
||||||
distributed under CC BY 3.0.
|
transcoded from OGG Vorbis to AAC M4A for iOS browser playback and distributed
|
||||||
|
under CC BY 3.0.
|
||||||
|
|
||||||
Source package: @audio-samples/piano-velocity12
|
Source package: @audio-samples/piano-velocity12
|
||||||
Source recording: https://archive.org/details/SalamanderGrandPianoV3
|
Source recording: https://archive.org/details/SalamanderGrandPianoV3
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
"name": "Fleeting Garden",
|
"name": "Fleeting Garden",
|
||||||
"short_name": "Garden",
|
"short_name": "Garden",
|
||||||
"description": "A joyful WebGPU drawing garden where coloured paths grow into organic agent trails.",
|
"description": "A joyful WebGPU drawing garden where coloured paths grow into organic agent trails.",
|
||||||
"start_url": "/",
|
"start_url": "./",
|
||||||
"scope": "/",
|
"scope": "./",
|
||||||
"display": "fullscreen",
|
"display": "fullscreen",
|
||||||
"display_override": ["fullscreen", "standalone", "minimal-ui"],
|
"display_override": ["fullscreen", "standalone", "minimal-ui"],
|
||||||
"orientation": "any",
|
"orientation": "any",
|
||||||
|
|
@ -11,28 +11,28 @@
|
||||||
"theme_color": "#10151f",
|
"theme_color": "#10151f",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/favicon.svg",
|
"src": "favicon.svg",
|
||||||
"sizes": "any",
|
"sizes": "any",
|
||||||
"type": "image/svg+xml",
|
"type": "image/svg+xml",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/pwa-64x64.png",
|
"src": "pwa-64x64.png",
|
||||||
"sizes": "64x64",
|
"sizes": "64x64",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/pwa-192x192.png",
|
"src": "pwa-192x192.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/pwa-512x512.png",
|
"src": "pwa-512x512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/maskable-icon-512x512.png",
|
"src": "maskable-icon-512x512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable"
|
"purpose": "maskable"
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { appConfig } from '../config';
|
||||||
|
|
||||||
export type GardenAudioChordQuality = 'major' | 'minor';
|
export type GardenAudioChordQuality = 'major' | 'minor';
|
||||||
|
|
||||||
export interface GardenAudioChord {
|
export interface GardenAudioChord {
|
||||||
|
|
@ -7,7 +9,6 @@ export interface GardenAudioChord {
|
||||||
|
|
||||||
export interface GardenAudioColorVoice {
|
export interface GardenAudioColorVoice {
|
||||||
scaleDegreeOffset: number;
|
scaleDegreeOffset: number;
|
||||||
octaveOffset: number;
|
|
||||||
velocityMultiplier: number;
|
velocityMultiplier: number;
|
||||||
panOffset: number;
|
panOffset: number;
|
||||||
}
|
}
|
||||||
|
|
@ -21,19 +22,11 @@ export interface GardenAudioVibeProfile {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GardenAudioConfig {
|
export interface GardenAudioConfig {
|
||||||
enabled: boolean;
|
|
||||||
masterVolume: number;
|
masterVolume: number;
|
||||||
fadeInSeconds: number;
|
fadeInSeconds: number;
|
||||||
updateRampSeconds: number;
|
updateRampSeconds: number;
|
||||||
highPassFrequencyHz: number;
|
highPassFrequencyHz: number;
|
||||||
fallbackVibeId: string;
|
fallbackVibeId: string;
|
||||||
startup: {
|
|
||||||
calmDurationSeconds: number;
|
|
||||||
initialTempoMultiplier: number;
|
|
||||||
initialEnergyMultiplier: number;
|
|
||||||
initialActivityCeiling: number;
|
|
||||||
initialTapIntervalMultiplier: number;
|
|
||||||
};
|
|
||||||
compressor: {
|
compressor: {
|
||||||
thresholdDb: number;
|
thresholdDb: number;
|
||||||
kneeDb: number;
|
kneeDb: number;
|
||||||
|
|
@ -42,7 +35,6 @@ export interface GardenAudioConfig {
|
||||||
releaseSeconds: number;
|
releaseSeconds: number;
|
||||||
};
|
};
|
||||||
delay: {
|
delay: {
|
||||||
enabled: boolean;
|
|
||||||
timeSeconds: number;
|
timeSeconds: number;
|
||||||
feedback: number;
|
feedback: number;
|
||||||
wetGain: number;
|
wetGain: number;
|
||||||
|
|
@ -54,7 +46,6 @@ export interface GardenAudioConfig {
|
||||||
sustainLevel: number;
|
sustainLevel: number;
|
||||||
releaseSeconds: number;
|
releaseSeconds: number;
|
||||||
lowpassHz: number;
|
lowpassHz: number;
|
||||||
preloadOnStart: boolean;
|
|
||||||
};
|
};
|
||||||
input: {
|
input: {
|
||||||
pressureFallback: number;
|
pressureFallback: number;
|
||||||
|
|
@ -64,25 +55,10 @@ export interface GardenAudioConfig {
|
||||||
stepsPerBeat: number;
|
stepsPerBeat: number;
|
||||||
stepsPerBar: number;
|
stepsPerBar: number;
|
||||||
lookaheadSeconds: number;
|
lookaheadSeconds: number;
|
||||||
swing: number;
|
|
||||||
minTailSeconds: number;
|
|
||||||
maxTailSeconds: number;
|
|
||||||
tailDistanceForMaxPixels: number;
|
|
||||||
tailDurationForMaxSeconds: number;
|
|
||||||
tailDecayPower: number;
|
|
||||||
minTapIntervalSeconds: number;
|
|
||||||
speedForFullEnergyPixelsPerSecond: number;
|
speedForFullEnergyPixelsPerSecond: number;
|
||||||
sparseActivity: number;
|
sparseActivity: number;
|
||||||
arpeggioActivity: number;
|
|
||||||
fullChordActivity: number;
|
|
||||||
bassActivity: number;
|
|
||||||
melodySteps: Array<number>;
|
|
||||||
chordSteps: Array<number>;
|
|
||||||
bassSteps: Array<number>;
|
|
||||||
melodyPattern: Array<number>;
|
|
||||||
};
|
};
|
||||||
eraser: {
|
eraser: {
|
||||||
enabled: boolean;
|
|
||||||
minIntervalSeconds: number;
|
minIntervalSeconds: number;
|
||||||
noiseGain: number;
|
noiseGain: number;
|
||||||
filterMinHz: number;
|
filterMinHz: number;
|
||||||
|
|
@ -92,163 +68,4 @@ export interface GardenAudioConfig {
|
||||||
vibes: Record<string, GardenAudioVibeProfile>;
|
vibes: Record<string, GardenAudioVibeProfile>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const majorProgression: Array<GardenAudioChord> = [
|
export const gardenAudioConfig: GardenAudioConfig = appConfig.audio;
|
||||||
{ rootOffset: 0, quality: 'major' },
|
|
||||||
{ rootOffset: 9, quality: 'minor' },
|
|
||||||
{ rootOffset: 5, quality: 'major' },
|
|
||||||
{ rootOffset: 7, quality: 'major' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const minorProgression: Array<GardenAudioChord> = [
|
|
||||||
{ rootOffset: 0, quality: 'minor' },
|
|
||||||
{ rootOffset: 8, quality: 'major' },
|
|
||||||
{ rootOffset: 3, quality: 'major' },
|
|
||||||
{ rootOffset: 10, quality: 'major' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const majorPentatonic = [0, 2, 4, 7, 9];
|
|
||||||
const minorPentatonic = [0, 3, 5, 7, 10];
|
|
||||||
|
|
||||||
export const gardenAudioConfig: GardenAudioConfig = {
|
|
||||||
enabled: true,
|
|
||||||
masterVolume: 0.32,
|
|
||||||
fadeInSeconds: 0.45,
|
|
||||||
updateRampSeconds: 0.08,
|
|
||||||
highPassFrequencyHz: 45,
|
|
||||||
fallbackVibeId: 'candy-rain',
|
|
||||||
startup: {
|
|
||||||
calmDurationSeconds: 6,
|
|
||||||
initialTempoMultiplier: 1.18,
|
|
||||||
initialEnergyMultiplier: 0.62,
|
|
||||||
initialActivityCeiling: 0.52,
|
|
||||||
initialTapIntervalMultiplier: 2.2,
|
|
||||||
},
|
|
||||||
compressor: {
|
|
||||||
thresholdDb: -18,
|
|
||||||
kneeDb: 18,
|
|
||||||
ratio: 2.4,
|
|
||||||
attackSeconds: 0.006,
|
|
||||||
releaseSeconds: 0.18,
|
|
||||||
},
|
|
||||||
delay: {
|
|
||||||
enabled: true,
|
|
||||||
timeSeconds: 0.42,
|
|
||||||
feedback: 0.12,
|
|
||||||
wetGain: 0.048,
|
|
||||||
},
|
|
||||||
piano: {
|
|
||||||
maxVoices: 32,
|
|
||||||
gain: 0.42,
|
|
||||||
sustainSeconds: 0.52,
|
|
||||||
sustainLevel: 0.34,
|
|
||||||
releaseSeconds: 0.16,
|
|
||||||
lowpassHz: 9000,
|
|
||||||
preloadOnStart: true,
|
|
||||||
},
|
|
||||||
input: {
|
|
||||||
pressureFallback: 0.48,
|
|
||||||
},
|
|
||||||
rhythm: {
|
|
||||||
bpm: 82,
|
|
||||||
stepsPerBeat: 4,
|
|
||||||
stepsPerBar: 16,
|
|
||||||
lookaheadSeconds: 0.14,
|
|
||||||
swing: 0.08,
|
|
||||||
minTailSeconds: 0.45,
|
|
||||||
maxTailSeconds: 7.2,
|
|
||||||
tailDistanceForMaxPixels: 1400,
|
|
||||||
tailDurationForMaxSeconds: 3.8,
|
|
||||||
tailDecayPower: 1.85,
|
|
||||||
minTapIntervalSeconds: 0.16,
|
|
||||||
speedForFullEnergyPixelsPerSecond: 1800,
|
|
||||||
sparseActivity: 0.1,
|
|
||||||
arpeggioActivity: 0.32,
|
|
||||||
fullChordActivity: 0.62,
|
|
||||||
bassActivity: 0.48,
|
|
||||||
melodySteps: [0, 3, 6, 10, 12, 14],
|
|
||||||
chordSteps: [0, 8],
|
|
||||||
bassSteps: [0],
|
|
||||||
melodyPattern: [0, 2, 4, 5, 4, 2, 1, 3],
|
|
||||||
},
|
|
||||||
eraser: {
|
|
||||||
enabled: true,
|
|
||||||
minIntervalSeconds: 0.12,
|
|
||||||
noiseGain: 0.028,
|
|
||||||
filterMinHz: 650,
|
|
||||||
filterMaxHz: 3600,
|
|
||||||
},
|
|
||||||
colorVoices: [
|
|
||||||
{
|
|
||||||
scaleDegreeOffset: 0,
|
|
||||||
octaveOffset: 0,
|
|
||||||
velocityMultiplier: 0.92,
|
|
||||||
panOffset: -0.14,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
scaleDegreeOffset: 1,
|
|
||||||
octaveOffset: 0,
|
|
||||||
velocityMultiplier: 1,
|
|
||||||
panOffset: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
scaleDegreeOffset: 2,
|
|
||||||
octaveOffset: 1,
|
|
||||||
velocityMultiplier: 0.86,
|
|
||||||
panOffset: 0.14,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
vibes: {
|
|
||||||
'candy-rain': {
|
|
||||||
rootMidi: 57,
|
|
||||||
scale: majorPentatonic,
|
|
||||||
brightness: 1.04,
|
|
||||||
delayTimeMultiplier: 0.92,
|
|
||||||
progression: majorProgression,
|
|
||||||
},
|
|
||||||
'sunlit-moss': {
|
|
||||||
rootMidi: 53,
|
|
||||||
scale: majorPentatonic,
|
|
||||||
brightness: 0.92,
|
|
||||||
delayTimeMultiplier: 1.08,
|
|
||||||
progression: [
|
|
||||||
{ rootOffset: 0, quality: 'major' },
|
|
||||||
{ rootOffset: 7, quality: 'major' },
|
|
||||||
{ rootOffset: 9, quality: 'minor' },
|
|
||||||
{ rootOffset: 5, quality: 'major' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
'coral-tide': {
|
|
||||||
rootMidi: 50,
|
|
||||||
scale: minorPentatonic,
|
|
||||||
brightness: 1,
|
|
||||||
delayTimeMultiplier: 1.12,
|
|
||||||
progression: minorProgression,
|
|
||||||
},
|
|
||||||
'moon-orchid': {
|
|
||||||
rootMidi: 49,
|
|
||||||
scale: minorPentatonic,
|
|
||||||
brightness: 0.9,
|
|
||||||
delayTimeMultiplier: 1.24,
|
|
||||||
progression: minorProgression,
|
|
||||||
},
|
|
||||||
'peach-neon': {
|
|
||||||
rootMidi: 56,
|
|
||||||
scale: majorPentatonic,
|
|
||||||
brightness: 1.08,
|
|
||||||
delayTimeMultiplier: 0.86,
|
|
||||||
progression: majorProgression,
|
|
||||||
},
|
|
||||||
'frost-bloom': {
|
|
||||||
rootMidi: 62,
|
|
||||||
scale: majorPentatonic,
|
|
||||||
brightness: 0.88,
|
|
||||||
delayTimeMultiplier: 1.32,
|
|
||||||
progression: [
|
|
||||||
{ rootOffset: 0, quality: 'major' },
|
|
||||||
{ rootOffset: 5, quality: 'major' },
|
|
||||||
{ rootOffset: 9, quality: 'minor' },
|
|
||||||
{ rootOffset: 7, quality: 'major' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
|
||||||
48
src/audio/garden-audio-energy.test.ts
Normal file
48
src/audio/garden-audio-energy.test.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { GardenAudioEnergy } from './garden-audio-energy';
|
||||||
|
|
||||||
|
describe('GardenAudioEnergy', () => {
|
||||||
|
it('suspends activity but keeps a fading level when the gesture ends', () => {
|
||||||
|
const energy = new GardenAudioEnergy();
|
||||||
|
|
||||||
|
energy.beginGesture(0);
|
||||||
|
energy.recordStroke(0.8, 0.1);
|
||||||
|
energy.update(0.1);
|
||||||
|
energy.update(0.2);
|
||||||
|
|
||||||
|
const levelBeforeLift = energy.getLevel();
|
||||||
|
expect(energy.getActivity()).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
energy.endGesture();
|
||||||
|
|
||||||
|
expect(energy.getActivity()).toBe(0);
|
||||||
|
expect(energy.getLevel()).toBe(levelBeforeLift);
|
||||||
|
energy.update(0.3);
|
||||||
|
expect(energy.getLevel()).toBeLessThan(levelBeforeLift);
|
||||||
|
expect(energy.getLevel()).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses recent stroke intensity rather than gesture duration alone', () => {
|
||||||
|
const energy = new GardenAudioEnergy();
|
||||||
|
|
||||||
|
energy.beginGesture(0);
|
||||||
|
energy.recordStroke(1, 0.1);
|
||||||
|
energy.update(0.1);
|
||||||
|
energy.update(0.2);
|
||||||
|
const activeLevel = energy.getActivity();
|
||||||
|
|
||||||
|
energy.update(1.2);
|
||||||
|
|
||||||
|
expect(energy.getActivity()).toBeLessThan(activeLevel);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('raises activity immediately when a stroke is recorded', () => {
|
||||||
|
const energy = new GardenAudioEnergy();
|
||||||
|
|
||||||
|
energy.beginGesture(0);
|
||||||
|
energy.recordStroke(0.12, 0.05);
|
||||||
|
|
||||||
|
expect(energy.getActivity()).toBeGreaterThan(0.09);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,71 +1,40 @@
|
||||||
|
import { appConfig } from '../config';
|
||||||
import { clamp01 } from '../utils/clamp';
|
import { clamp01 } from '../utils/clamp';
|
||||||
import { GardenAudioConfig } from './garden-audio-config';
|
|
||||||
|
|
||||||
interface GardenGestureState {
|
const STROKE_IMMEDIATE_ACTIVITY_SCALE = 0.85;
|
||||||
startedAt: number;
|
|
||||||
lastAt: number;
|
|
||||||
distancePixels: number;
|
|
||||||
peakEnergy: number;
|
|
||||||
isErasing: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GestureTail {
|
|
||||||
startedAt: number;
|
|
||||||
durationSeconds: number;
|
|
||||||
level: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
type GardenAudioRhythmConfig = GardenAudioConfig['rhythm'];
|
|
||||||
|
|
||||||
export class GardenAudioEnergy {
|
export class GardenAudioEnergy {
|
||||||
private isGestureActive = false;
|
private isGestureActive = false;
|
||||||
private energy = 0;
|
private energy = 0;
|
||||||
private targetEnergy = 0;
|
private targetEnergy = 0;
|
||||||
private lastEnergyUpdateAt = 0;
|
private lastEnergyUpdateAt = 0;
|
||||||
private currentGesture: GardenGestureState | null = null;
|
|
||||||
private gestureTails: Array<GestureTail> = [];
|
|
||||||
|
|
||||||
public constructor(private readonly rhythm: GardenAudioRhythmConfig) {}
|
|
||||||
|
|
||||||
public beginGesture(now: number): void {
|
public beginGesture(now: number): void {
|
||||||
this.isGestureActive = true;
|
this.isGestureActive = true;
|
||||||
this.currentGesture = createGesture(now, false);
|
this.lastEnergyUpdateAt = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
public endGesture(now: number): void {
|
public endGesture(): void {
|
||||||
if (this.currentGesture && !this.currentGesture.isErasing) {
|
|
||||||
this.addGestureTail(this.currentGesture, now);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.isGestureActive = false;
|
this.isGestureActive = false;
|
||||||
this.currentGesture = null;
|
this.targetEnergy = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public recordStroke(distancePixels: number, strokeEnergy: number, now: number): void {
|
public recordStroke(strokeEnergy: number, now: number): void {
|
||||||
if (!this.currentGesture || this.currentGesture.isErasing) {
|
const energy = clamp01(strokeEnergy);
|
||||||
this.currentGesture = createGesture(now, false);
|
this.targetEnergy = Math.max(this.targetEnergy, energy);
|
||||||
|
if (this.isGestureActive) {
|
||||||
|
this.energy = Math.max(this.energy, energy * STROKE_IMMEDIATE_ACTIVITY_SCALE);
|
||||||
}
|
}
|
||||||
|
this.lastEnergyUpdateAt ||= now;
|
||||||
this.currentGesture.lastAt = now;
|
|
||||||
this.currentGesture.distancePixels += distancePixels;
|
|
||||||
this.currentGesture.peakEnergy = Math.max(
|
|
||||||
this.currentGesture.peakEnergy,
|
|
||||||
strokeEnergy
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public recordEraserStroke(now: number): void {
|
public recordEraserStroke(): void {
|
||||||
this.currentGesture = this.currentGesture ?? createGesture(now, true);
|
this.targetEnergy = 0;
|
||||||
this.currentGesture.isErasing = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public raiseTarget(strokeEnergy: number): void {
|
|
||||||
this.targetEnergy = Math.max(this.targetEnergy, strokeEnergy);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public silence(): void {
|
public silence(): void {
|
||||||
this.targetEnergy = 0;
|
this.targetEnergy = 0;
|
||||||
this.gestureTails = [];
|
this.energy = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public update(now: number): void {
|
public update(now: number): void {
|
||||||
|
|
@ -76,28 +45,31 @@ export class GardenAudioEnergy {
|
||||||
|
|
||||||
const elapsedSeconds = Math.max(0, now - this.lastEnergyUpdateAt);
|
const elapsedSeconds = Math.max(0, now - this.lastEnergyUpdateAt);
|
||||||
this.lastEnergyUpdateAt = now;
|
this.lastEnergyUpdateAt = now;
|
||||||
this.targetEnergy *= Math.exp(-elapsedSeconds / 0.75);
|
this.targetEnergy *= Math.exp(
|
||||||
this.trimGestureTails(now);
|
-elapsedSeconds / appConfig.audioEngine.energy.strokeDecaySeconds
|
||||||
|
);
|
||||||
|
|
||||||
const activeGestureFloor =
|
const target = this.isGestureActive ? this.targetEnergy : 0;
|
||||||
this.isGestureActive && this.currentGesture && !this.currentGesture.isErasing
|
let timeConstant = appConfig.audioEngine.energy.decaySeconds;
|
||||||
? 0.04 + this.getGestureAmount(this.currentGesture, now) * 0.3
|
if (!this.isGestureActive) {
|
||||||
: 0;
|
timeConstant = appConfig.audioEngine.energy.releaseSeconds;
|
||||||
const target = Math.max(activeGestureFloor, this.targetEnergy);
|
} else if (target > this.energy) {
|
||||||
const timeConstant = target > this.energy ? 0.08 : 0.55;
|
timeConstant = appConfig.audioEngine.energy.attackSeconds;
|
||||||
|
}
|
||||||
const amount = 1 - Math.exp(-elapsedSeconds / timeConstant);
|
const amount = 1 - Math.exp(-elapsedSeconds / timeConstant);
|
||||||
this.energy += (target - this.energy) * amount;
|
this.energy += (target - this.energy) * amount;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getActivityAt(time: number): number {
|
public getActivity(): number {
|
||||||
const activeGesture =
|
if (!this.isGestureActive) {
|
||||||
this.isGestureActive && this.currentGesture && !this.currentGesture.isErasing
|
return 0;
|
||||||
? 0.08 + this.getGestureAmount(this.currentGesture, time) * 0.34
|
}
|
||||||
: 0;
|
|
||||||
|
|
||||||
return clamp01(
|
return this.getLevel();
|
||||||
this.energy * 0.58 + this.getTailActivityAt(time) * 0.72 + activeGesture
|
}
|
||||||
);
|
|
||||||
|
public getLevel(): number {
|
||||||
|
return clamp01(this.energy);
|
||||||
}
|
}
|
||||||
|
|
||||||
public reset(): void {
|
public reset(): void {
|
||||||
|
|
@ -105,72 +77,5 @@ export class GardenAudioEnergy {
|
||||||
this.energy = 0;
|
this.energy = 0;
|
||||||
this.targetEnergy = 0;
|
this.targetEnergy = 0;
|
||||||
this.lastEnergyUpdateAt = 0;
|
this.lastEnergyUpdateAt = 0;
|
||||||
this.currentGesture = null;
|
|
||||||
this.gestureTails = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
private addGestureTail(gesture: GardenGestureState, now: number): void {
|
|
||||||
const durationAmount = clamp01(
|
|
||||||
(gesture.lastAt - gesture.startedAt) / this.rhythm.tailDurationForMaxSeconds
|
|
||||||
);
|
|
||||||
const distanceAmount = clamp01(
|
|
||||||
gesture.distancePixels / this.rhythm.tailDistanceForMaxPixels
|
|
||||||
);
|
|
||||||
const gestureAmount = Math.max(distanceAmount, durationAmount * 0.9);
|
|
||||||
const tailAmount = clamp01(gestureAmount * (0.74 + gesture.peakEnergy * 0.26));
|
|
||||||
|
|
||||||
if (tailAmount <= 0.015) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.gestureTails.push({
|
|
||||||
startedAt: now,
|
|
||||||
durationSeconds:
|
|
||||||
this.rhythm.minTailSeconds +
|
|
||||||
(this.rhythm.maxTailSeconds - this.rhythm.minTailSeconds) * tailAmount,
|
|
||||||
level: (0.05 + tailAmount * 0.5) * (0.72 + gesture.peakEnergy * 0.28),
|
|
||||||
});
|
|
||||||
this.trimGestureTails(now);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getGestureAmount(gesture: GardenGestureState, now: number): number {
|
|
||||||
const durationAmount = clamp01(
|
|
||||||
(now - gesture.startedAt) / this.rhythm.tailDurationForMaxSeconds
|
|
||||||
);
|
|
||||||
const distanceAmount = clamp01(
|
|
||||||
gesture.distancePixels / this.rhythm.tailDistanceForMaxPixels
|
|
||||||
);
|
|
||||||
|
|
||||||
return clamp01(
|
|
||||||
distanceAmount * 0.62 + durationAmount * 0.24 + gesture.peakEnergy * 0.14
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getTailActivityAt(time: number): number {
|
|
||||||
const decayPower = Math.max(0.2, this.rhythm.tailDecayPower);
|
|
||||||
|
|
||||||
return this.gestureTails.reduce((activity, tail) => {
|
|
||||||
const elapsedSeconds = time - tail.startedAt;
|
|
||||||
if (elapsedSeconds < 0 || elapsedSeconds >= tail.durationSeconds) {
|
|
||||||
return activity;
|
|
||||||
}
|
|
||||||
|
|
||||||
const remaining = clamp01(1 - elapsedSeconds / tail.durationSeconds);
|
|
||||||
return Math.max(activity, tail.level * Math.pow(remaining, decayPower));
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private trimGestureTails(now: number): void {
|
|
||||||
this.gestureTails = this.gestureTails.filter(
|
|
||||||
(tail) => now - tail.startedAt < tail.durationSeconds
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const createGesture = (now: number, isErasing: boolean): GardenGestureState => ({
|
|
||||||
startedAt: now,
|
|
||||||
lastAt: now,
|
|
||||||
distancePixels: 0,
|
|
||||||
peakEnergy: 0,
|
|
||||||
isErasing,
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { appConfig } from '../config';
|
||||||
import { clamp } from '../utils/clamp';
|
import { clamp } from '../utils/clamp';
|
||||||
import { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
|
import { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
|
||||||
|
|
||||||
|
|
@ -11,6 +12,7 @@ export class GardenAudioGraph {
|
||||||
private delayNode: DelayNode | null = null;
|
private delayNode: DelayNode | null = null;
|
||||||
private delayFeedback: GainNode | null = null;
|
private delayFeedback: GainNode | null = null;
|
||||||
private delayOutput: GainNode | null = null;
|
private delayOutput: GainNode | null = null;
|
||||||
|
private hasUnlocked = false;
|
||||||
|
|
||||||
public constructor(private readonly config: GardenAudioConfig) {}
|
public constructor(private readonly config: GardenAudioConfig) {}
|
||||||
|
|
||||||
|
|
@ -50,6 +52,26 @@ export class GardenAudioGraph {
|
||||||
return context;
|
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.
|
||||||
|
public unlock(): void {
|
||||||
|
if (!this.context || this.hasUnlocked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = this.context.createBuffer(
|
||||||
|
1,
|
||||||
|
appConfig.audioEngine.graph.unlockBufferLength,
|
||||||
|
appConfig.audioEngine.graph.unlockSampleRate
|
||||||
|
);
|
||||||
|
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 {
|
public setMasterGain(targetGain: number, timeConstantSeconds: number): void {
|
||||||
if (!this.context || !this.masterGain) {
|
if (!this.context || !this.masterGain) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -70,7 +92,7 @@ export class GardenAudioGraph {
|
||||||
this.delayNode.delayTime.setTargetAtTime(
|
this.delayNode.delayTime.setTargetAtTime(
|
||||||
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
|
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
|
||||||
this.context.currentTime,
|
this.context.currentTime,
|
||||||
0.12
|
appConfig.audioEngine.graph.delayTimeRampSeconds
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,17 +105,22 @@ export class GardenAudioGraph {
|
||||||
this.delayNode.delayTime.setTargetAtTime(
|
this.delayNode.delayTime.setTargetAtTime(
|
||||||
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
|
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
|
||||||
now,
|
now,
|
||||||
0.12
|
appConfig.audioEngine.graph.delayTimeRampSeconds
|
||||||
);
|
);
|
||||||
this.delayFeedback.gain.setTargetAtTime(
|
this.delayFeedback.gain.setTargetAtTime(
|
||||||
this.config.delay.enabled
|
clamp(
|
||||||
? clamp(this.config.delay.feedback + activity * 0.08, 0.04, 0.32)
|
this.config.delay.feedback +
|
||||||
: 0,
|
activity * appConfig.audioEngine.graph.delayActivityFeedbackWeight,
|
||||||
|
appConfig.audioEngine.graph.delayFeedbackMin,
|
||||||
|
appConfig.audioEngine.graph.delayFeedbackMax
|
||||||
|
),
|
||||||
now,
|
now,
|
||||||
this.config.updateRampSeconds
|
this.config.updateRampSeconds
|
||||||
);
|
);
|
||||||
this.delayOutput.gain.setTargetAtTime(
|
this.delayOutput.gain.setTargetAtTime(
|
||||||
this.config.delay.enabled ? this.config.delay.wetGain * (0.65 + activity * 0.5) : 0,
|
this.config.delay.wetGain *
|
||||||
|
(appConfig.audioEngine.graph.delayOutputBase +
|
||||||
|
activity * appConfig.audioEngine.graph.delayOutputActivityWeight),
|
||||||
now,
|
now,
|
||||||
this.config.updateRampSeconds
|
this.config.updateRampSeconds
|
||||||
);
|
);
|
||||||
|
|
@ -106,7 +133,11 @@ export class GardenAudioGraph {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.masterGain && context.state !== 'closed') {
|
if (this.masterGain && context.state !== 'closed') {
|
||||||
this.masterGain.gain.setTargetAtTime(0.0001, context.currentTime, 0.015);
|
this.masterGain.gain.setTargetAtTime(
|
||||||
|
appConfig.audioEngine.graph.closeGain,
|
||||||
|
context.currentTime,
|
||||||
|
appConfig.audioEngine.graph.closeRampSeconds
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.clearNodes();
|
this.clearNodes();
|
||||||
|
|
@ -123,8 +154,8 @@ export class GardenAudioGraph {
|
||||||
const delayOutput = context.createGain();
|
const delayOutput = context.createGain();
|
||||||
|
|
||||||
delayNode.delayTime.value = this.config.delay.timeSeconds;
|
delayNode.delayTime.value = this.config.delay.timeSeconds;
|
||||||
delayFeedback.gain.value = this.config.delay.enabled ? this.config.delay.feedback : 0;
|
delayFeedback.gain.value = this.config.delay.feedback;
|
||||||
delayOutput.gain.value = this.config.delay.enabled ? this.config.delay.wetGain : 0;
|
delayOutput.gain.value = this.config.delay.wetGain;
|
||||||
|
|
||||||
delayInput.connect(delayNode);
|
delayInput.connect(delayNode);
|
||||||
delayNode.connect(delayFeedback);
|
delayNode.connect(delayFeedback);
|
||||||
|
|
@ -140,7 +171,7 @@ export class GardenAudioGraph {
|
||||||
|
|
||||||
private createBuses(context: AudioContext, masterGain: GainNode): void {
|
private createBuses(context: AudioContext, masterGain: GainNode): void {
|
||||||
this.eventBus = context.createGain();
|
this.eventBus = context.createGain();
|
||||||
this.eventBus.gain.value = 1;
|
this.eventBus.gain.value = appConfig.audioEngine.graph.eventBusGain;
|
||||||
this.eventBus.connect(masterGain);
|
this.eventBus.connect(masterGain);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -149,7 +180,10 @@ export class GardenAudioGraph {
|
||||||
const data = buffer.getChannelData(0);
|
const data = buffer.getChannelData(0);
|
||||||
|
|
||||||
for (let index = 0; index < data.length; index++) {
|
for (let index = 0; index < data.length; index++) {
|
||||||
data[index] = Math.random() * 2 - 1;
|
data[index] =
|
||||||
|
appConfig.audioEngine.graph.noiseMin +
|
||||||
|
Math.random() *
|
||||||
|
(appConfig.audioEngine.graph.noiseMax - appConfig.audioEngine.graph.noiseMin);
|
||||||
}
|
}
|
||||||
|
|
||||||
return buffer;
|
return buffer;
|
||||||
|
|
@ -164,5 +198,6 @@ export class GardenAudioGraph {
|
||||||
this.delayNode = null;
|
this.delayNode = null;
|
||||||
this.delayFeedback = null;
|
this.delayFeedback = null;
|
||||||
this.delayOutput = null;
|
this.delayOutput = null;
|
||||||
|
this.hasUnlocked = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { appConfig } from '../config';
|
||||||
import { clamp01 } from '../utils/clamp';
|
import { clamp01 } from '../utils/clamp';
|
||||||
import { GardenAudioStroke } from './garden-audio-types';
|
import { GardenAudioStroke } from './garden-audio-types';
|
||||||
|
|
||||||
|
|
@ -19,8 +20,16 @@ export const getStrokeMetrics = (
|
||||||
const speedPixelsPerSecond = getStrokeVelocity(stroke, distancePixels);
|
const speedPixelsPerSecond = getStrokeVelocity(stroke, distancePixels);
|
||||||
const pressure = getPressureAmount(stroke, fallbackPressure);
|
const pressure = getPressureAmount(stroke, fallbackPressure);
|
||||||
const speedAmount = clamp01(speedPixelsPerSecond / speedForFullEnergyPixelsPerSecond);
|
const speedAmount = clamp01(speedPixelsPerSecond / speedForFullEnergyPixelsPerSecond);
|
||||||
const strokeEnergy = clamp01(0.18 + speedAmount * 0.62 + pressure * 0.22);
|
const strokeEnergy = clamp01(
|
||||||
const effectiveEnergy = strokeEnergy * (0.25 + clamp01(distancePixels / 140) * 0.75);
|
appConfig.audioEngine.input.strokeEnergyBase +
|
||||||
|
speedAmount * appConfig.audioEngine.input.strokeEnergySpeedWeight +
|
||||||
|
pressure * appConfig.audioEngine.input.strokeEnergyPressureWeight
|
||||||
|
);
|
||||||
|
const effectiveEnergy =
|
||||||
|
strokeEnergy *
|
||||||
|
(appConfig.audioEngine.input.distanceEnergyBase +
|
||||||
|
clamp01(distancePixels / appConfig.audioEngine.input.distanceForFullEnergyPixels) *
|
||||||
|
appConfig.audioEngine.input.distanceEnergyScale);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
distancePixels,
|
distancePixels,
|
||||||
|
|
@ -39,7 +48,7 @@ const getStrokeVelocity = (stroke: GardenAudioStroke, distancePixels: number): n
|
||||||
return stroke.velocityPixelsPerSecond;
|
return stroke.velocityPixelsPerSecond;
|
||||||
}
|
}
|
||||||
|
|
||||||
return distancePixels / (1 / 60);
|
return distancePixels / appConfig.audioEngine.input.fallbackFrameSeconds;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPressureAmount = (
|
const getPressureAmount = (
|
||||||
|
|
@ -55,6 +64,6 @@ const getPressureAmount = (
|
||||||
}
|
}
|
||||||
|
|
||||||
return stroke.pointerType === 'pen'
|
return stroke.pointerType === 'pen'
|
||||||
? Math.max(0.56, clamp01(fallbackPressure))
|
? Math.max(appConfig.audioEngine.input.penMinPressure, clamp01(fallbackPressure))
|
||||||
: clamp01(fallbackPressure);
|
: clamp01(fallbackPressure);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,282 +0,0 @@
|
||||||
import { clamp, clamp01 } from '../utils/clamp';
|
|
||||||
import { VibePreset } from '../vibes';
|
|
||||||
import {
|
|
||||||
GardenAudioChord,
|
|
||||||
GardenAudioConfig,
|
|
||||||
GardenAudioVibeProfile,
|
|
||||||
} from './garden-audio-config';
|
|
||||||
import {
|
|
||||||
clampMidi,
|
|
||||||
degreeToSemitone,
|
|
||||||
getChordAtStep,
|
|
||||||
getChordIntervals,
|
|
||||||
getVibeProfile,
|
|
||||||
} from './garden-audio-music';
|
|
||||||
import {
|
|
||||||
GardenAudioColorIndex,
|
|
||||||
GardenAudioStroke,
|
|
||||||
PianoNote,
|
|
||||||
} from './garden-audio-types';
|
|
||||||
|
|
||||||
interface RhythmStepRequest {
|
|
||||||
vibe: VibePreset;
|
|
||||||
stepIndex: number;
|
|
||||||
startTime: number;
|
|
||||||
activity: number;
|
|
||||||
selectedColorIndex: GardenAudioColorIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StrokeTapRequest {
|
|
||||||
stroke: GardenAudioStroke;
|
|
||||||
energy: number;
|
|
||||||
now: number;
|
|
||||||
selectedColorIndex: GardenAudioColorIndex;
|
|
||||||
stepIndex: number;
|
|
||||||
rhythmAnchorTime: number;
|
|
||||||
stepDurationSeconds: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class GardenAudioScore {
|
|
||||||
public constructor(
|
|
||||||
private readonly config: GardenAudioConfig,
|
|
||||||
private readonly playNote: (note: PianoNote) => void
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public playRhythmStep({
|
|
||||||
vibe,
|
|
||||||
stepIndex,
|
|
||||||
startTime,
|
|
||||||
activity,
|
|
||||||
selectedColorIndex,
|
|
||||||
}: RhythmStepRequest): void {
|
|
||||||
const profile = getVibeProfile(this.config, vibe);
|
|
||||||
const stepInBar = stepIndex % this.config.rhythm.stepsPerBar;
|
|
||||||
if (activity < this.config.rhythm.sparseActivity) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const chord = getChordAtStep(this.config, profile, stepIndex);
|
|
||||||
if (stepInBar === 0 && activity < this.config.rhythm.bassActivity) {
|
|
||||||
this.playRootAnchor(profile, chord, startTime, activity);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
this.config.rhythm.bassSteps.includes(stepInBar) &&
|
|
||||||
activity >= this.config.rhythm.bassActivity
|
|
||||||
) {
|
|
||||||
this.playBassNote(profile, chord, startTime, activity);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
this.config.rhythm.chordSteps.includes(stepInBar) &&
|
|
||||||
activity >= this.config.rhythm.arpeggioActivity
|
|
||||||
) {
|
|
||||||
this.playBrokenChord(profile, chord, startTime, activity, stepInBar === 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.shouldPlayMelodyStep(stepInBar, activity)) {
|
|
||||||
this.playMelodyNote(
|
|
||||||
profile,
|
|
||||||
chord,
|
|
||||||
stepIndex,
|
|
||||||
startTime,
|
|
||||||
activity,
|
|
||||||
selectedColorIndex
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public playStrokeTap({
|
|
||||||
stroke,
|
|
||||||
energy,
|
|
||||||
now,
|
|
||||||
selectedColorIndex,
|
|
||||||
stepIndex,
|
|
||||||
rhythmAnchorTime,
|
|
||||||
stepDurationSeconds,
|
|
||||||
}: StrokeTapRequest): void {
|
|
||||||
const profile = getVibeProfile(this.config, stroke.vibe);
|
|
||||||
const colorVoice = this.config.colorVoices[selectedColorIndex];
|
|
||||||
const chord = getChordAtStep(this.config, profile, stepIndex);
|
|
||||||
const intervals = getChordIntervals(chord, false);
|
|
||||||
const x = clamp01(stroke.to[0] / Math.max(1, stroke.canvasSize[0]));
|
|
||||||
const y = 1 - clamp01(stroke.to[1] / Math.max(1, stroke.canvasSize[1]));
|
|
||||||
const rawRegister = y < 0.35 ? 0 : y > 0.72 ? 2 : 1;
|
|
||||||
const register =
|
|
||||||
energy < this.config.rhythm.arpeggioActivity
|
|
||||||
? Math.min(rawRegister, 1)
|
|
||||||
: rawRegister;
|
|
||||||
const interval =
|
|
||||||
intervals[
|
|
||||||
(register + colorVoice.scaleDegreeOffset + selectedColorIndex) % intervals.length
|
|
||||||
];
|
|
||||||
|
|
||||||
this.playNote({
|
|
||||||
midi: clampMidi(
|
|
||||||
profile.rootMidi +
|
|
||||||
chord.rootOffset +
|
|
||||||
interval +
|
|
||||||
(energy < this.config.rhythm.arpeggioActivity ? 5 : 12) +
|
|
||||||
register * 5 +
|
|
||||||
(energy >= this.config.rhythm.fullChordActivity
|
|
||||||
? colorVoice.octaveOffset * 7
|
|
||||||
: 0),
|
|
||||||
50,
|
|
||||||
energy < this.config.rhythm.arpeggioActivity ? 76 : 88
|
|
||||||
),
|
|
||||||
velocity: (0.26 + energy * 0.34) * colorVoice.velocityMultiplier,
|
|
||||||
durationSeconds: 1.05 + energy * 0.45,
|
|
||||||
pan: clamp(x * 2 - 1 + colorVoice.panOffset * 0.6, -0.85, 0.85),
|
|
||||||
delaySend: 0.014,
|
|
||||||
lowpassHz: this.config.piano.lowpassHz * profile.brightness,
|
|
||||||
startTime: this.getNearGridTime(now, rhythmAnchorTime, stepDurationSeconds),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public playVibeChangeStinger(vibe: VibePreset, now: number): void {
|
|
||||||
const profile = getVibeProfile(this.config, vibe);
|
|
||||||
const chord = profile.progression[0];
|
|
||||||
const intervals = getChordIntervals(chord, true);
|
|
||||||
|
|
||||||
intervals.forEach((interval, index) => {
|
|
||||||
this.playNote({
|
|
||||||
midi: clampMidi(profile.rootMidi + chord.rootOffset + interval, 48, 84),
|
|
||||||
velocity: 0.3 * Math.pow(0.9, index),
|
|
||||||
durationSeconds: 1.4,
|
|
||||||
pan: clamp(-0.22 + index * 0.14, -0.5, 0.5),
|
|
||||||
delaySend: 0.016,
|
|
||||||
lowpassHz: this.config.piano.lowpassHz * profile.brightness,
|
|
||||||
startTime: now + index * 0.045,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private shouldPlayMelodyStep(stepInBar: number, activity: number): boolean {
|
|
||||||
if (activity < this.config.rhythm.sparseActivity) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activity < this.config.rhythm.arpeggioActivity) {
|
|
||||||
return stepInBar === 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activity < this.config.rhythm.fullChordActivity) {
|
|
||||||
return stepInBar === 6 || stepInBar === 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.config.rhythm.melodySteps.includes(stepInBar);
|
|
||||||
}
|
|
||||||
|
|
||||||
private playRootAnchor(
|
|
||||||
profile: GardenAudioVibeProfile,
|
|
||||||
chord: GardenAudioChord,
|
|
||||||
startTime: number,
|
|
||||||
activity: number
|
|
||||||
): void {
|
|
||||||
this.playNote({
|
|
||||||
midi: clampMidi(profile.rootMidi + chord.rootOffset, 43, 64),
|
|
||||||
velocity: 0.13 + activity * 0.14,
|
|
||||||
durationSeconds: 1.35,
|
|
||||||
pan: -0.06,
|
|
||||||
delaySend: 0.004,
|
|
||||||
lowpassHz: this.config.piano.lowpassHz * profile.brightness * 0.78,
|
|
||||||
startTime,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private playBassNote(
|
|
||||||
profile: GardenAudioVibeProfile,
|
|
||||||
chord: GardenAudioChord,
|
|
||||||
startTime: number,
|
|
||||||
activity: number
|
|
||||||
): void {
|
|
||||||
this.playNote({
|
|
||||||
midi: clampMidi(profile.rootMidi + chord.rootOffset - 12, 36, 58),
|
|
||||||
velocity: 0.24 + activity * 0.18,
|
|
||||||
durationSeconds: 1.8,
|
|
||||||
pan: -0.08,
|
|
||||||
delaySend: 0.006,
|
|
||||||
startTime,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private playBrokenChord(
|
|
||||||
profile: GardenAudioVibeProfile,
|
|
||||||
chord: GardenAudioChord,
|
|
||||||
startTime: number,
|
|
||||||
activity: number,
|
|
||||||
isAccent: boolean
|
|
||||||
): void {
|
|
||||||
const intervals = getChordIntervals(chord, true);
|
|
||||||
const velocity = (isAccent ? 0.22 : 0.16) + activity * 0.16;
|
|
||||||
const baseMidi = profile.rootMidi + chord.rootOffset;
|
|
||||||
const isFullChord = activity >= this.config.rhythm.fullChordActivity;
|
|
||||||
const noteCount = isFullChord ? intervals.length : activity >= 0.46 ? 3 : 2;
|
|
||||||
const staggerSeconds = isFullChord ? 0.018 : 0.056;
|
|
||||||
|
|
||||||
intervals.slice(0, noteCount).forEach((interval, index) => {
|
|
||||||
this.playNote({
|
|
||||||
midi: clampMidi(baseMidi + interval, 48, 78),
|
|
||||||
velocity: velocity * Math.pow(0.9, index),
|
|
||||||
durationSeconds: isFullChord ? (isAccent ? 1.9 : 1.35) : 1.08,
|
|
||||||
pan: clamp(-0.16 + index * 0.1, -0.45, 0.45),
|
|
||||||
delaySend: 0.012,
|
|
||||||
lowpassHz: this.config.piano.lowpassHz * profile.brightness,
|
|
||||||
startTime: startTime + index * staggerSeconds,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private playMelodyNote(
|
|
||||||
profile: GardenAudioVibeProfile,
|
|
||||||
chord: GardenAudioChord,
|
|
||||||
stepIndex: number,
|
|
||||||
startTime: number,
|
|
||||||
activity: number,
|
|
||||||
selectedColorIndex: GardenAudioColorIndex
|
|
||||||
): void {
|
|
||||||
const colorVoice = this.config.colorVoices[selectedColorIndex];
|
|
||||||
const patternIndex =
|
|
||||||
Math.floor(stepIndex / 2) + colorVoice.scaleDegreeOffset + selectedColorIndex;
|
|
||||||
const scaleDegree =
|
|
||||||
this.config.rhythm.melodyPattern[
|
|
||||||
patternIndex % this.config.rhythm.melodyPattern.length
|
|
||||||
] + colorVoice.scaleDegreeOffset;
|
|
||||||
const stepInBeat = stepIndex % this.config.rhythm.stepsPerBeat;
|
|
||||||
const semitoneOffset =
|
|
||||||
stepInBeat === 0
|
|
||||||
? chord.rootOffset +
|
|
||||||
getChordIntervals(chord, false)[(patternIndex + selectedColorIndex) % 3]
|
|
||||||
: degreeToSemitone(profile, scaleDegree);
|
|
||||||
const registerLift = activity < this.config.rhythm.arpeggioActivity ? 0 : 12;
|
|
||||||
const colorOctaveLift =
|
|
||||||
activity >= this.config.rhythm.fullChordActivity ? colorVoice.octaveOffset * 12 : 0;
|
|
||||||
|
|
||||||
this.playNote({
|
|
||||||
midi: clampMidi(
|
|
||||||
profile.rootMidi + semitoneOffset + registerLift + colorOctaveLift,
|
|
||||||
activity < this.config.rhythm.arpeggioActivity ? 50 : 57,
|
|
||||||
activity < this.config.rhythm.arpeggioActivity ? 76 : 91
|
|
||||||
),
|
|
||||||
velocity:
|
|
||||||
(0.2 + activity * 0.32) *
|
|
||||||
colorVoice.velocityMultiplier *
|
|
||||||
(stepInBeat === 0 ? 1.08 : 1),
|
|
||||||
durationSeconds: 0.92 + activity * 0.38,
|
|
||||||
pan: colorVoice.panOffset,
|
|
||||||
delaySend: 0.018,
|
|
||||||
lowpassHz: this.config.piano.lowpassHz * profile.brightness,
|
|
||||||
startTime,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private getNearGridTime(
|
|
||||||
now: number,
|
|
||||||
rhythmAnchorTime: number,
|
|
||||||
stepDurationSeconds: number
|
|
||||||
): number {
|
|
||||||
const stepCount = Math.ceil((now - rhythmAnchorTime) / stepDurationSeconds);
|
|
||||||
const gridTime = rhythmAnchorTime + Math.max(0, stepCount) * stepDurationSeconds;
|
|
||||||
return gridTime - now <= 0.1 ? gridTime : now + 0.012;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -4,16 +4,8 @@ export type GardenAudioColorIndex = 0 | 1 | 2;
|
||||||
|
|
||||||
export interface GardenAudioSnapshot {
|
export interface GardenAudioSnapshot {
|
||||||
vibe: VibePreset;
|
vibe: VibePreset;
|
||||||
activeAgentCount: number;
|
|
||||||
agentBudgetMax: number;
|
|
||||||
selectedColorIndex: number;
|
selectedColorIndex: number;
|
||||||
isErasing: boolean;
|
isErasing: boolean;
|
||||||
introProgress: number;
|
|
||||||
moveSpeed: number;
|
|
||||||
diffusionRateTrails: number;
|
|
||||||
decayRateTrails: number;
|
|
||||||
brushEffectDuration: number;
|
|
||||||
clarity: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GardenAudioStroke {
|
export interface GardenAudioStroke {
|
||||||
|
|
@ -41,6 +33,7 @@ export interface LoadedPianoSample {
|
||||||
export interface ActivePianoVoice {
|
export interface ActivePianoVoice {
|
||||||
gain: GainNode;
|
gain: GainNode;
|
||||||
source: AudioBufferSourceNode;
|
source: AudioBufferSourceNode;
|
||||||
|
startAt: number;
|
||||||
stopAt: number;
|
stopAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,18 @@ class FakeAudioContext {
|
||||||
return new FakeAudioBuffer(length) as unknown as AudioBuffer;
|
return new FakeAudioBuffer(length) as unknown as AudioBuffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public createBufferSource(): AudioBufferSourceNode {
|
||||||
|
const node = new FakeAudioNode() as unknown as AudioBufferSourceNode & {
|
||||||
|
buffer: AudioBuffer | null;
|
||||||
|
start: () => void;
|
||||||
|
stop: () => void;
|
||||||
|
};
|
||||||
|
node.buffer = null;
|
||||||
|
node.start = vi.fn();
|
||||||
|
node.stop = vi.fn();
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
public async resume(): Promise<void> {
|
public async resume(): Promise<void> {
|
||||||
calls.resumed += 1;
|
calls.resumed += 1;
|
||||||
contextState = 'running';
|
contextState = 'running';
|
||||||
|
|
@ -90,10 +102,6 @@ class FakeAudioContext {
|
||||||
|
|
||||||
const makeConfig = (): GardenAudioConfig => ({
|
const makeConfig = (): GardenAudioConfig => ({
|
||||||
...gardenAudioConfig,
|
...gardenAudioConfig,
|
||||||
piano: {
|
|
||||||
...gardenAudioConfig.piano,
|
|
||||||
preloadOnStart: false,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('GardenAudio startup policy', () => {
|
describe('GardenAudio startup policy', () => {
|
||||||
|
|
@ -102,6 +110,7 @@ describe('GardenAudio startup policy', () => {
|
||||||
calls.resumed = 0;
|
calls.resumed = 0;
|
||||||
contextState = 'suspended';
|
contextState = 'suspended';
|
||||||
vi.stubGlobal('AudioContext', FakeAudioContext);
|
vi.stubGlobal('AudioContext', FakeAudioContext);
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('not loaded in tests')));
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { appConfig } from '../config';
|
||||||
import { clamp, clamp01 } from '../utils/clamp';
|
import { clamp, clamp01 } from '../utils/clamp';
|
||||||
import { VibePreset } from '../vibes';
|
import { VibePreset } from '../vibes';
|
||||||
import { GardenAudioConfig } from './garden-audio-config';
|
import { GardenAudioConfig } from './garden-audio-config';
|
||||||
|
|
@ -5,13 +6,13 @@ import { GardenAudioEnergy } from './garden-audio-energy';
|
||||||
import { GardenAudioGraph } from './garden-audio-graph';
|
import { GardenAudioGraph } from './garden-audio-graph';
|
||||||
import { getStrokeMetrics } from './garden-audio-input';
|
import { getStrokeMetrics } from './garden-audio-input';
|
||||||
import { getVibeProfile, normalizeColorIndex } from './garden-audio-music';
|
import { getVibeProfile, normalizeColorIndex } from './garden-audio-music';
|
||||||
import { GardenAudioScore } from './garden-audio-score';
|
|
||||||
import type {
|
import type {
|
||||||
GardenAudioColorIndex,
|
GardenAudioColorIndex,
|
||||||
GardenAudioSnapshot,
|
GardenAudioSnapshot,
|
||||||
GardenAudioStartOptions,
|
GardenAudioStartOptions,
|
||||||
GardenAudioStroke,
|
GardenAudioStroke,
|
||||||
} from './garden-audio-types';
|
} from './garden-audio-types';
|
||||||
|
import { GenerativePianoEngine } from './generative-piano';
|
||||||
import { NoiseBurstPlayer } from './noise-burst-player';
|
import { NoiseBurstPlayer } from './noise-burst-player';
|
||||||
import { PianoSampler } from './piano-sampler';
|
import { PianoSampler } from './piano-sampler';
|
||||||
|
|
||||||
|
|
@ -26,18 +27,14 @@ export class GardenAudio {
|
||||||
private readonly piano: PianoSampler;
|
private readonly piano: PianoSampler;
|
||||||
private readonly noise: NoiseBurstPlayer;
|
private readonly noise: NoiseBurstPlayer;
|
||||||
private readonly energy: GardenAudioEnergy;
|
private readonly energy: GardenAudioEnergy;
|
||||||
private readonly score: GardenAudioScore;
|
private readonly pianoEngine: GenerativePianoEngine;
|
||||||
|
|
||||||
private currentVibeId: string | null = null;
|
private currentVibeId: string | null = null;
|
||||||
private hasStarted = false;
|
private hasStarted = false;
|
||||||
private isDestroyed = false;
|
private isDestroyed = false;
|
||||||
private isMuted = false;
|
private isMuted = false;
|
||||||
|
private isGestureActive = false;
|
||||||
private selectedColorIndex: GardenAudioColorIndex = 0;
|
private selectedColorIndex: GardenAudioColorIndex = 0;
|
||||||
private rhythmAnchorTime: number | null = null;
|
|
||||||
private startedAt: number | null = null;
|
|
||||||
private nextStepAt = 0;
|
|
||||||
private stepIndex = 0;
|
|
||||||
private lastTapAt = Number.NEGATIVE_INFINITY;
|
|
||||||
private lastEraserAt = Number.NEGATIVE_INFINITY;
|
private lastEraserAt = Number.NEGATIVE_INFINITY;
|
||||||
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
||||||
|
|
||||||
|
|
@ -45,12 +42,12 @@ export class GardenAudio {
|
||||||
this.graph = new GardenAudioGraph(config);
|
this.graph = new GardenAudioGraph(config);
|
||||||
this.piano = new PianoSampler(config, this.graph);
|
this.piano = new PianoSampler(config, this.graph);
|
||||||
this.noise = new NoiseBurstPlayer(this.graph);
|
this.noise = new NoiseBurstPlayer(this.graph);
|
||||||
this.energy = new GardenAudioEnergy(config.rhythm);
|
this.energy = new GardenAudioEnergy();
|
||||||
this.score = new GardenAudioScore(config, (note) => this.piano.play(note));
|
this.pianoEngine = new GenerativePianoEngine(config, (note) => this.piano.play(note));
|
||||||
}
|
}
|
||||||
|
|
||||||
public start(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
|
public start(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
|
||||||
if (!this.config.enabled || this.isDestroyed || this.isMuted) {
|
if (this.isDestroyed || this.isMuted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -59,6 +56,10 @@ export class GardenAudio {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.userGesture === true) {
|
||||||
|
this.graph.unlock();
|
||||||
|
}
|
||||||
|
|
||||||
if (context.state === 'suspended') {
|
if (context.state === 'suspended') {
|
||||||
if (options.userGesture !== true) {
|
if (options.userGesture !== true) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -67,17 +68,11 @@ export class GardenAudio {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.hasStarted = true;
|
this.hasStarted = true;
|
||||||
this.rhythmAnchorTime ??= context.currentTime;
|
|
||||||
this.startedAt ??= context.currentTime;
|
|
||||||
this.applyVibe(vibe);
|
this.applyVibe(vibe);
|
||||||
if (this.nextStepAt <= 0) {
|
this.pianoEngine.prime(context.currentTime);
|
||||||
this.nextStepAt = context.currentTime + 0.02;
|
|
||||||
}
|
|
||||||
this.graph.setMasterGain(this.config.masterVolume, this.config.fadeInSeconds);
|
this.graph.setMasterGain(this.config.masterVolume, this.config.fadeInSeconds);
|
||||||
|
|
||||||
if (this.config.piano.preloadOnStart) {
|
void this.piano.load(context);
|
||||||
void this.piano.load(context);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public changeVibe(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
|
public changeVibe(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
|
||||||
|
|
@ -100,8 +95,8 @@ export class GardenAudio {
|
||||||
public setMuted(isMuted: boolean): void {
|
public setMuted(isMuted: boolean): void {
|
||||||
this.isMuted = isMuted;
|
this.isMuted = isMuted;
|
||||||
this.graph.setMasterGain(
|
this.graph.setMasterGain(
|
||||||
isMuted ? 0.0001 : this.config.masterVolume,
|
isMuted ? appConfig.audioEngine.muteGain : this.config.masterVolume,
|
||||||
isMuted ? 0.02 : this.config.fadeInSeconds
|
isMuted ? appConfig.audioEngine.muteRampSeconds : this.config.fadeInSeconds
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -111,20 +106,21 @@ export class GardenAudio {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.isGestureActive = true;
|
||||||
this.energy.beginGesture(context.currentTime);
|
this.energy.beginGesture(context.currentTime);
|
||||||
|
this.pianoEngine.beginGesture(context.currentTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
public endGesture(): void {
|
public endGesture(): void {
|
||||||
const context = this.graph.context;
|
const context = this.graph.context;
|
||||||
|
this.isGestureActive = false;
|
||||||
|
this.energy.endGesture();
|
||||||
|
this.pianoEngine.endGesture();
|
||||||
if (!context) {
|
if (!context) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.energy.endGesture(context.currentTime);
|
this.piano.fadeActive(context.currentTime, appConfig.audioEngine.gestureFadeSeconds);
|
||||||
}
|
|
||||||
|
|
||||||
public rememberColor(colorIndex: number): void {
|
|
||||||
this.selectedColorIndex = normalizeColorIndex(colorIndex);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public update(snapshot: GardenAudioSnapshot): void {
|
public update(snapshot: GardenAudioSnapshot): void {
|
||||||
|
|
@ -139,17 +135,27 @@ export class GardenAudio {
|
||||||
|
|
||||||
if (snapshot.isErasing) {
|
if (snapshot.isErasing) {
|
||||||
this.energy.silence();
|
this.energy.silence();
|
||||||
this.piano.fadeActive(context.currentTime);
|
this.piano.fadeActive(
|
||||||
|
context.currentTime,
|
||||||
|
appConfig.audioEngine.gestureFadeSeconds
|
||||||
|
);
|
||||||
this.updateDelay(snapshot);
|
this.updateDelay(snapshot);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.scheduleRhythm(snapshot.vibe);
|
if (this.isGestureActive) {
|
||||||
|
this.pianoEngine.renderLookahead({
|
||||||
|
vibe: snapshot.vibe,
|
||||||
|
now: context.currentTime,
|
||||||
|
activity: this.energy.getActivity(),
|
||||||
|
selectedColorIndex: this.selectedColorIndex,
|
||||||
|
});
|
||||||
|
}
|
||||||
this.updateDelay(snapshot);
|
this.updateDelay(snapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
public stroke(stroke: GardenAudioStroke): void {
|
public stroke(stroke: GardenAudioStroke): void {
|
||||||
if (!this.config.enabled || this.isDestroyed || this.isMuted) {
|
if (this.isDestroyed || this.isMuted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -158,6 +164,9 @@ export class GardenAudio {
|
||||||
if (!context) {
|
if (!context) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!this.isGestureActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const metrics = getStrokeMetrics(
|
const metrics = getStrokeMetrics(
|
||||||
stroke,
|
stroke,
|
||||||
|
|
@ -169,32 +178,14 @@ export class GardenAudio {
|
||||||
this.selectedColorIndex = normalizeColorIndex(stroke.colorIndex);
|
this.selectedColorIndex = normalizeColorIndex(stroke.colorIndex);
|
||||||
|
|
||||||
if (stroke.isErasing) {
|
if (stroke.isErasing) {
|
||||||
this.energy.recordEraserStroke(now);
|
this.energy.recordEraserStroke();
|
||||||
this.playEraser(stroke, metrics.speedAmount, metrics.pressure, now);
|
this.playEraser(stroke, metrics.speedAmount, metrics.pressure, now);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const strokeEnergy = metrics.effectiveEnergy * this.getStartupEnergyScale(now);
|
const strokeEnergy = metrics.effectiveEnergy;
|
||||||
this.energy.recordStroke(metrics.distancePixels, strokeEnergy, now);
|
this.energy.recordStroke(strokeEnergy, now);
|
||||||
if (metrics.distancePixels >= 2.5) {
|
this.pianoEngine.wake(now);
|
||||||
this.energy.raiseTarget(strokeEnergy);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
metrics.distancePixels >= 2.5 &&
|
|
||||||
now - this.lastTapAt >= this.getTapIntervalSeconds(now)
|
|
||||||
) {
|
|
||||||
this.lastTapAt = now;
|
|
||||||
this.score.playStrokeTap({
|
|
||||||
stroke,
|
|
||||||
energy: strokeEnergy,
|
|
||||||
now,
|
|
||||||
selectedColorIndex: this.selectedColorIndex,
|
|
||||||
stepIndex: this.stepIndex,
|
|
||||||
rhythmAnchorTime: this.getRhythmAnchorTime(now),
|
|
||||||
stepDurationSeconds: this.getStepDurationSeconds(now),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async destroy(): Promise<void> {
|
public async destroy(): Promise<void> {
|
||||||
|
|
@ -203,50 +194,15 @@ export class GardenAudio {
|
||||||
|
|
||||||
this.piano.reset();
|
this.piano.reset();
|
||||||
this.energy.reset();
|
this.energy.reset();
|
||||||
|
this.pianoEngine.reset();
|
||||||
this.currentVibeId = null;
|
this.currentVibeId = null;
|
||||||
this.hasStarted = false;
|
this.hasStarted = false;
|
||||||
|
this.isGestureActive = false;
|
||||||
this.selectedColorIndex = 0;
|
this.selectedColorIndex = 0;
|
||||||
this.rhythmAnchorTime = null;
|
|
||||||
this.startedAt = null;
|
|
||||||
this.nextStepAt = 0;
|
|
||||||
this.stepIndex = 0;
|
|
||||||
this.lastTapAt = Number.NEGATIVE_INFINITY;
|
|
||||||
this.lastEraserAt = Number.NEGATIVE_INFINITY;
|
this.lastEraserAt = Number.NEGATIVE_INFINITY;
|
||||||
this.lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
this.lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
||||||
}
|
}
|
||||||
|
|
||||||
private scheduleRhythm(vibe: VibePreset): void {
|
|
||||||
const context = this.graph.context;
|
|
||||||
if (!context || !this.graph.eventBus) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = context.currentTime;
|
|
||||||
const stepSeconds = this.getStepDurationSeconds(now);
|
|
||||||
if (this.nextStepAt <= 0 || this.nextStepAt < now - stepSeconds) {
|
|
||||||
this.nextStepAt = now + 0.02;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lookaheadEnd = now + this.config.rhythm.lookaheadSeconds;
|
|
||||||
while (this.nextStepAt <= lookaheadEnd) {
|
|
||||||
const swingOffset =
|
|
||||||
this.stepIndex % 2 === 1 ? stepSeconds * this.config.rhythm.swing : 0;
|
|
||||||
const startTime = this.nextStepAt + swingOffset;
|
|
||||||
this.score.playRhythmStep({
|
|
||||||
vibe,
|
|
||||||
stepIndex: this.stepIndex,
|
|
||||||
startTime,
|
|
||||||
activity: this.getSettledActivity(
|
|
||||||
this.energy.getActivityAt(startTime),
|
|
||||||
startTime
|
|
||||||
),
|
|
||||||
selectedColorIndex: this.selectedColorIndex,
|
|
||||||
});
|
|
||||||
this.nextStepAt += stepSeconds;
|
|
||||||
this.stepIndex += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private playVibeChangeStinger(vibe: VibePreset): void {
|
private playVibeChangeStinger(vibe: VibePreset): void {
|
||||||
const context = this.graph.context;
|
const context = this.graph.context;
|
||||||
if (!context) {
|
if (!context) {
|
||||||
|
|
@ -254,12 +210,15 @@ export class GardenAudio {
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = context.currentTime;
|
const now = context.currentTime;
|
||||||
if (now - this.lastVibeStingerAt < 0.45) {
|
if (
|
||||||
|
now - this.lastVibeStingerAt <
|
||||||
|
appConfig.audioEngine.vibeChangeStingerMinIntervalSeconds
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.lastVibeStingerAt = now;
|
this.lastVibeStingerAt = now;
|
||||||
this.score.playVibeChangeStinger(vibe, now);
|
this.pianoEngine.playVibeChangeStinger(vibe, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
private playEraser(
|
private playEraser(
|
||||||
|
|
@ -268,27 +227,38 @@ export class GardenAudio {
|
||||||
pressure: number,
|
pressure: number,
|
||||||
now: number
|
now: number
|
||||||
): void {
|
): void {
|
||||||
if (!this.config.eraser.enabled || !this.graph.context) {
|
if (!this.graph.context) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sizeAmount = clamp01(
|
const sizeAmount = clamp01(
|
||||||
(stroke.eraserSizePixels ?? 96) / Math.max(1, stroke.canvasSize[0] * 0.18)
|
(stroke.eraserSizePixels ?? appConfig.audioEngine.eraser.defaultSizePixels) /
|
||||||
|
Math.max(
|
||||||
|
1,
|
||||||
|
stroke.canvasSize[0] * appConfig.audioEngine.eraser.canvasWidthRatioForFullSize
|
||||||
|
)
|
||||||
);
|
);
|
||||||
const x = clamp01(stroke.to[0] / Math.max(1, stroke.canvasSize[0]));
|
const x = clamp01(stroke.to[0] / Math.max(1, stroke.canvasSize[0]));
|
||||||
const filterHz =
|
const filterHz =
|
||||||
this.config.eraser.filterMinHz +
|
this.config.eraser.filterMinHz +
|
||||||
(this.config.eraser.filterMaxHz - this.config.eraser.filterMinHz) *
|
(this.config.eraser.filterMaxHz - this.config.eraser.filterMinHz) *
|
||||||
clamp01(speedAmount * 0.58 + pressure * 0.26 + sizeAmount * 0.16);
|
clamp01(
|
||||||
|
speedAmount * appConfig.audioEngine.eraser.filterSpeedWeight +
|
||||||
|
pressure * appConfig.audioEngine.eraser.filterPressureWeight +
|
||||||
|
sizeAmount * appConfig.audioEngine.eraser.filterSizeWeight
|
||||||
|
);
|
||||||
|
|
||||||
if (now - this.lastEraserAt >= this.config.eraser.minIntervalSeconds) {
|
if (now - this.lastEraserAt >= this.config.eraser.minIntervalSeconds) {
|
||||||
this.lastEraserAt = now;
|
this.lastEraserAt = now;
|
||||||
this.noise.play({
|
this.noise.play({
|
||||||
startTime: now,
|
startTime: now,
|
||||||
durationSeconds: 0.08,
|
durationSeconds: appConfig.audioEngine.eraser.durationSeconds,
|
||||||
gain:
|
gain:
|
||||||
this.config.eraser.noiseGain *
|
this.config.eraser.noiseGain *
|
||||||
(0.45 + speedAmount * 0.38 + pressure * 0.24 + sizeAmount * 0.18),
|
(appConfig.audioEngine.eraser.gainBase +
|
||||||
|
speedAmount * appConfig.audioEngine.eraser.gainSpeedWeight +
|
||||||
|
pressure * appConfig.audioEngine.eraser.gainPressureWeight +
|
||||||
|
sizeAmount * appConfig.audioEngine.eraser.gainSizeWeight),
|
||||||
filterHz,
|
filterHz,
|
||||||
pan: clamp(x * 2 - 1, -1, 1),
|
pan: clamp(x * 2 - 1, -1, 1),
|
||||||
});
|
});
|
||||||
|
|
@ -303,11 +273,8 @@ export class GardenAudio {
|
||||||
|
|
||||||
const profile = getVibeProfile(this.config, snapshot.vibe);
|
const profile = getVibeProfile(this.config, snapshot.vibe);
|
||||||
const activity = snapshot.isErasing
|
const activity = snapshot.isErasing
|
||||||
? 0.12
|
? appConfig.audioEngine.delay.erasingActivity
|
||||||
: this.getSettledActivity(
|
: this.energy.getLevel();
|
||||||
this.energy.getActivityAt(context.currentTime),
|
|
||||||
context.currentTime
|
|
||||||
);
|
|
||||||
this.graph.updateDelay(profile, activity);
|
this.graph.updateDelay(profile, activity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -319,68 +286,4 @@ export class GardenAudio {
|
||||||
this.currentVibeId = vibe.id;
|
this.currentVibeId = vibe.id;
|
||||||
this.graph.applyDelayProfile(getVibeProfile(this.config, vibe));
|
this.graph.applyDelayProfile(getVibeProfile(this.config, vibe));
|
||||||
}
|
}
|
||||||
|
|
||||||
private getStepDurationSeconds(time: number): number {
|
|
||||||
const baseStepSeconds = 60 / this.config.rhythm.bpm / this.config.rhythm.stepsPerBeat;
|
|
||||||
return baseStepSeconds * this.getStartupTempoMultiplier(time);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getRhythmAnchorTime(now: number): number {
|
|
||||||
this.rhythmAnchorTime ??= now;
|
|
||||||
return this.rhythmAnchorTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getTapIntervalSeconds(time: number): number {
|
|
||||||
return (
|
|
||||||
this.config.rhythm.minTapIntervalSeconds *
|
|
||||||
this.getStartupTapIntervalMultiplier(time)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getSettledActivity(activity: number, time: number): number {
|
|
||||||
return Math.min(activity, this.getStartupActivityCeiling(time));
|
|
||||||
}
|
|
||||||
|
|
||||||
private getStartupEnergyScale(time: number): number {
|
|
||||||
return this.interpolateStartupValue(
|
|
||||||
this.config.startup.initialEnergyMultiplier,
|
|
||||||
1,
|
|
||||||
time
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getStartupActivityCeiling(time: number): number {
|
|
||||||
return this.interpolateStartupValue(
|
|
||||||
this.config.startup.initialActivityCeiling,
|
|
||||||
1,
|
|
||||||
time
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getStartupTempoMultiplier(time: number): number {
|
|
||||||
return this.interpolateStartupValue(
|
|
||||||
this.config.startup.initialTempoMultiplier,
|
|
||||||
1,
|
|
||||||
time
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getStartupTapIntervalMultiplier(time: number): number {
|
|
||||||
return this.interpolateStartupValue(
|
|
||||||
this.config.startup.initialTapIntervalMultiplier,
|
|
||||||
1,
|
|
||||||
time
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private interpolateStartupValue(from: number, to: number, time: number): number {
|
|
||||||
const durationSeconds = this.config.startup.calmDurationSeconds;
|
|
||||||
if (this.startedAt === null || durationSeconds <= 0) {
|
|
||||||
return to;
|
|
||||||
}
|
|
||||||
|
|
||||||
const progress = clamp01((time - this.startedAt) / durationSeconds);
|
|
||||||
const easedProgress = progress * progress * (3 - 2 * progress);
|
|
||||||
return from + (to - from) * easedProgress;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
118
src/audio/generative-piano.test.ts
Normal file
118
src/audio/generative-piano.test.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { VIBE_PRESETS } from '../vibes';
|
||||||
|
import { gardenAudioConfig } from './garden-audio-config';
|
||||||
|
import { PianoNote } from './garden-audio-types';
|
||||||
|
import { GenerativePianoEngine } from './generative-piano';
|
||||||
|
|
||||||
|
const makeEngine = () => {
|
||||||
|
const notes: Array<PianoNote> = [];
|
||||||
|
const engine = new GenerativePianoEngine(gardenAudioConfig, (note) => {
|
||||||
|
notes.push(note);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { engine, notes };
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderBars = (
|
||||||
|
engine: GenerativePianoEngine,
|
||||||
|
activity: number,
|
||||||
|
selectedColorIndex = 0,
|
||||||
|
bars = 4
|
||||||
|
) => {
|
||||||
|
engine.renderLookahead({
|
||||||
|
vibe: VIBE_PRESETS[0],
|
||||||
|
now: 0,
|
||||||
|
activity,
|
||||||
|
selectedColorIndex: selectedColorIndex as 0 | 1 | 2,
|
||||||
|
lookaheadSeconds:
|
||||||
|
(60 / gardenAudioConfig.rhythm.bpm) *
|
||||||
|
Math.round(
|
||||||
|
gardenAudioConfig.rhythm.stepsPerBar / gardenAudioConfig.rhythm.stepsPerBeat
|
||||||
|
) *
|
||||||
|
bars,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const average = (values: Array<number>): number =>
|
||||||
|
values.reduce((sum, value) => sum + value, 0) / values.length;
|
||||||
|
|
||||||
|
describe('GenerativePianoEngine', () => {
|
||||||
|
it('does not emit notes below the sparse activity threshold', () => {
|
||||||
|
const { engine, notes } = makeEngine();
|
||||||
|
|
||||||
|
renderBars(engine, gardenAudioConfig.rhythm.sparseActivity - 0.01);
|
||||||
|
|
||||||
|
expect(notes).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps drawing notes on beat starts', () => {
|
||||||
|
const { engine, notes } = makeEngine();
|
||||||
|
const beatSeconds = 60 / gardenAudioConfig.rhythm.bpm;
|
||||||
|
const startDelaySeconds = 0.02;
|
||||||
|
|
||||||
|
renderBars(engine, 1, 1);
|
||||||
|
|
||||||
|
expect(notes.length).toBeGreaterThan(0);
|
||||||
|
notes.forEach((note) => {
|
||||||
|
const beatsFromStart = (note.startTime - startDelaySeconds) / beatSeconds;
|
||||||
|
expect(Math.abs(beatsFromStart - Math.round(beatsFromStart))).toBeLessThan(0.001);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('varies density with activity without exceeding one note per beat', () => {
|
||||||
|
const low = makeEngine();
|
||||||
|
const high = makeEngine();
|
||||||
|
|
||||||
|
renderBars(low.engine, gardenAudioConfig.rhythm.sparseActivity + 0.03, 1);
|
||||||
|
renderBars(high.engine, 1, 1);
|
||||||
|
|
||||||
|
expect(high.notes.length).toBeGreaterThan(low.notes.length);
|
||||||
|
expect(high.notes.length).toBeLessThanOrEqual(16);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wakes every color with a prompt first note at low activity', () => {
|
||||||
|
([0, 1, 2] as const).forEach((selectedColorIndex) => {
|
||||||
|
const { engine, notes } = makeEngine();
|
||||||
|
const now = 4;
|
||||||
|
|
||||||
|
engine.beginGesture(1);
|
||||||
|
engine.wake(now);
|
||||||
|
engine.renderLookahead({
|
||||||
|
vibe: VIBE_PRESETS[0],
|
||||||
|
now,
|
||||||
|
activity: gardenAudioConfig.rhythm.sparseActivity + 0.01,
|
||||||
|
selectedColorIndex,
|
||||||
|
lookaheadSeconds: 0.08,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(notes).toHaveLength(1);
|
||||||
|
expect(notes[0].startTime).toBeCloseTo(now + 0.02);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses different color roles for register and pan', () => {
|
||||||
|
const anchor = makeEngine();
|
||||||
|
const spark = makeEngine();
|
||||||
|
|
||||||
|
renderBars(anchor.engine, 1, 0);
|
||||||
|
renderBars(spark.engine, 1, 2);
|
||||||
|
|
||||||
|
expect(average(spark.notes.map((note) => note.midi))).toBeGreaterThan(
|
||||||
|
average(anchor.notes.map((note) => note.midi))
|
||||||
|
);
|
||||||
|
expect(average(spark.notes.map((note) => note.pan))).toBeGreaterThan(
|
||||||
|
average(anchor.notes.map((note) => note.pan))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is deterministic for the same phrase inputs', () => {
|
||||||
|
const first = makeEngine();
|
||||||
|
const second = makeEngine();
|
||||||
|
|
||||||
|
renderBars(first.engine, 0.78, 2);
|
||||||
|
renderBars(second.engine, 0.78, 2);
|
||||||
|
|
||||||
|
expect(second.notes).toEqual(first.notes);
|
||||||
|
});
|
||||||
|
});
|
||||||
674
src/audio/generative-piano.ts
Normal file
674
src/audio/generative-piano.ts
Normal file
|
|
@ -0,0 +1,674 @@
|
||||||
|
import { appConfig } from '../config';
|
||||||
|
import { clamp, clamp01 } from '../utils/clamp';
|
||||||
|
import { VibePreset } from '../vibes';
|
||||||
|
import {
|
||||||
|
GardenAudioChord,
|
||||||
|
GardenAudioConfig,
|
||||||
|
GardenAudioVibeProfile,
|
||||||
|
} from './garden-audio-config';
|
||||||
|
import {
|
||||||
|
clampMidi,
|
||||||
|
degreeToSemitone,
|
||||||
|
getChordAtStep,
|
||||||
|
getChordIntervals,
|
||||||
|
getVibeProfile,
|
||||||
|
} from './garden-audio-music';
|
||||||
|
import { GardenAudioColorIndex, PianoNote } from './garden-audio-types';
|
||||||
|
|
||||||
|
interface RenderLookaheadRequest {
|
||||||
|
vibe: VibePreset;
|
||||||
|
now: number;
|
||||||
|
activity: number;
|
||||||
|
selectedColorIndex: GardenAudioColorIndex;
|
||||||
|
lookaheadSeconds?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PianoRole {
|
||||||
|
name: string;
|
||||||
|
midiMin: number;
|
||||||
|
midiMax: number;
|
||||||
|
preferredMidi: number;
|
||||||
|
pan: number;
|
||||||
|
delaySend: number;
|
||||||
|
velocityBase: number;
|
||||||
|
velocityActivityScale: number;
|
||||||
|
durationBase: number;
|
||||||
|
durationActivityScale: number;
|
||||||
|
downbeatDurationBoost: number;
|
||||||
|
lowBeats: ReadonlyArray<number>;
|
||||||
|
mediumBeats: ReadonlyArray<number>;
|
||||||
|
highBeats: ReadonlyArray<number>;
|
||||||
|
lowPhraseOffset: number;
|
||||||
|
lowPhraseSpacing: number;
|
||||||
|
lowKeepChance: number;
|
||||||
|
mediumKeepChance: number;
|
||||||
|
highKeepChance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PitchCandidate {
|
||||||
|
midi: number;
|
||||||
|
preference: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLOR_ROLES: [PianoRole, PianoRole, PianoRole] = [
|
||||||
|
{
|
||||||
|
name: 'anchor',
|
||||||
|
midiMin: 45,
|
||||||
|
midiMax: 64,
|
||||||
|
preferredMidi: 53,
|
||||||
|
pan: -0.28,
|
||||||
|
delaySend: 0.012,
|
||||||
|
velocityBase: 0.12,
|
||||||
|
velocityActivityScale: 0.15,
|
||||||
|
durationBase: 1.2,
|
||||||
|
durationActivityScale: 0.75,
|
||||||
|
downbeatDurationBoost: 0.32,
|
||||||
|
lowBeats: [0, 2],
|
||||||
|
mediumBeats: [0, 2],
|
||||||
|
highBeats: [0, 2],
|
||||||
|
lowPhraseOffset: 0,
|
||||||
|
lowPhraseSpacing: 4,
|
||||||
|
lowKeepChance: 1,
|
||||||
|
mediumKeepChance: 0.92,
|
||||||
|
highKeepChance: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'body',
|
||||||
|
midiMin: 55,
|
||||||
|
midiMax: 74,
|
||||||
|
preferredMidi: 64,
|
||||||
|
pan: 0,
|
||||||
|
delaySend: 0.009,
|
||||||
|
velocityBase: 0.115,
|
||||||
|
velocityActivityScale: 0.18,
|
||||||
|
durationBase: 0.86,
|
||||||
|
durationActivityScale: 0.54,
|
||||||
|
downbeatDurationBoost: 0.18,
|
||||||
|
lowBeats: [0, 2],
|
||||||
|
mediumBeats: [0, 2],
|
||||||
|
highBeats: [0, 1, 2, 3],
|
||||||
|
lowPhraseOffset: 0,
|
||||||
|
lowPhraseSpacing: 4,
|
||||||
|
lowKeepChance: 0.86,
|
||||||
|
mediumKeepChance: 0.86,
|
||||||
|
highKeepChance: 0.9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'spark',
|
||||||
|
midiMin: 67,
|
||||||
|
midiMax: 84,
|
||||||
|
preferredMidi: 76,
|
||||||
|
pan: 0.28,
|
||||||
|
delaySend: 0.018,
|
||||||
|
velocityBase: 0.09,
|
||||||
|
velocityActivityScale: 0.14,
|
||||||
|
durationBase: 0.48,
|
||||||
|
durationActivityScale: 0.32,
|
||||||
|
downbeatDurationBoost: 0,
|
||||||
|
lowBeats: [0, 2],
|
||||||
|
mediumBeats: [1, 3],
|
||||||
|
highBeats: [0, 1, 2, 3],
|
||||||
|
lowPhraseOffset: 0,
|
||||||
|
lowPhraseSpacing: 4,
|
||||||
|
lowKeepChance: 0.76,
|
||||||
|
mediumKeepChance: 0.8,
|
||||||
|
highKeepChance: 0.78,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const PHRASE_BAR_COUNT = 4;
|
||||||
|
const PACE_MIN = 0.96;
|
||||||
|
const PACE_MAX = 1.08;
|
||||||
|
const PACE_RAMP_SECONDS = 2.8;
|
||||||
|
const FIRST_NOTE_MAX_WAIT_SECONDS = 0.09;
|
||||||
|
const NOTE_SCORE_PREFERENCE_WEIGHT = 1.8;
|
||||||
|
const NOTE_SCORE_REGISTER_WEIGHT = 0.28;
|
||||||
|
const STINGER_SPACING_SECONDS = 0.07;
|
||||||
|
const STINGER_DURATION_SECONDS = 1.45;
|
||||||
|
const PHRASE_CONTOURS: ReadonlyArray<ReadonlyArray<number>> = [
|
||||||
|
[0, 0, 1, 0, -1, 0, 1, 0],
|
||||||
|
[0, 1, 2, 1, 0, -1, 0, 1],
|
||||||
|
[1, 0, -1, 0, 1, 2, 1, 0],
|
||||||
|
[0, -1, 0, 1, 0, 1, 2, 1],
|
||||||
|
];
|
||||||
|
|
||||||
|
export class GenerativePianoEngine {
|
||||||
|
private nextStepAt: number | null = null;
|
||||||
|
private stepIndex = 0;
|
||||||
|
private pace = 1;
|
||||||
|
private lastPaceUpdateAt: number | null = null;
|
||||||
|
private isWaitingForFirstStrokeNote = false;
|
||||||
|
private readonly lastMidiByColor: [number | null, number | null, number | null] = [
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
];
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly config: GardenAudioConfig,
|
||||||
|
private readonly playNote: (note: PianoNote) => void
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public prime(now: number): void {
|
||||||
|
if (this.nextStepAt === null) {
|
||||||
|
this.nextStepAt = now + appConfig.audioEngine.startDelaySeconds;
|
||||||
|
}
|
||||||
|
this.lastPaceUpdateAt ??= now;
|
||||||
|
}
|
||||||
|
|
||||||
|
public beginGesture(now: number): void {
|
||||||
|
this.isWaitingForFirstStrokeNote = true;
|
||||||
|
this.wake(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
public endGesture(): void {
|
||||||
|
this.isWaitingForFirstStrokeNote = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public wake(now: number): void {
|
||||||
|
this.lastPaceUpdateAt ??= now;
|
||||||
|
if (
|
||||||
|
!this.isWaitingForFirstStrokeNote &&
|
||||||
|
this.nextStepAt !== null &&
|
||||||
|
this.nextStepAt - now <= FIRST_NOTE_MAX_WAIT_SECONDS
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.nextStepAt = now + appConfig.audioEngine.startDelaySeconds;
|
||||||
|
this.stepIndex = this.getNextDownbeatStepIndex();
|
||||||
|
}
|
||||||
|
|
||||||
|
public renderLookahead({
|
||||||
|
vibe,
|
||||||
|
now,
|
||||||
|
activity,
|
||||||
|
selectedColorIndex,
|
||||||
|
lookaheadSeconds = this.config.rhythm.lookaheadSeconds,
|
||||||
|
}: RenderLookaheadRequest): void {
|
||||||
|
this.prime(now);
|
||||||
|
const normalizedActivity = clamp01(activity);
|
||||||
|
|
||||||
|
this.updatePace(now, normalizedActivity);
|
||||||
|
const stepSeconds = this.getStepDurationSeconds();
|
||||||
|
this.skipLateSteps(now, stepSeconds);
|
||||||
|
|
||||||
|
if (this.nextStepAt === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = getVibeProfile(this.config, vibe);
|
||||||
|
const lookaheadEnd = now + lookaheadSeconds;
|
||||||
|
while (this.nextStepAt <= lookaheadEnd) {
|
||||||
|
this.renderStep({
|
||||||
|
vibe,
|
||||||
|
profile,
|
||||||
|
stepIndex: this.stepIndex,
|
||||||
|
startTime: this.nextStepAt,
|
||||||
|
activity: normalizedActivity,
|
||||||
|
selectedColorIndex,
|
||||||
|
});
|
||||||
|
this.nextStepAt += stepSeconds;
|
||||||
|
this.stepIndex += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public playVibeChangeStinger(vibe: VibePreset, now: number): void {
|
||||||
|
const profile = getVibeProfile(this.config, vibe);
|
||||||
|
const chord = profile.progression[0];
|
||||||
|
const intervals = getChordIntervals(chord, true);
|
||||||
|
const rootMidi = profile.rootMidi + chord.rootOffset;
|
||||||
|
const notes = [
|
||||||
|
{
|
||||||
|
midi: clampMidi(rootMidi + intervals[0], 48, 60),
|
||||||
|
velocity: 0.16,
|
||||||
|
pan: -0.24,
|
||||||
|
delaySend: 0.014,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
midi: clampMidi(rootMidi + intervals[2], 60, 72),
|
||||||
|
velocity: 0.13,
|
||||||
|
pan: 0.02,
|
||||||
|
delaySend: 0.016,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
midi: clampMidi(rootMidi + intervals[3], 67, 84),
|
||||||
|
velocity: 0.105,
|
||||||
|
pan: 0.26,
|
||||||
|
delaySend: 0.02,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
notes.forEach((note, index) => {
|
||||||
|
this.playNote({
|
||||||
|
...note,
|
||||||
|
durationSeconds: STINGER_DURATION_SECONDS,
|
||||||
|
lowpassHz: this.getLowpassHz(profile, note.midi, 0.28),
|
||||||
|
startTime: now + index * STINGER_SPACING_SECONDS,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public reset(): void {
|
||||||
|
this.nextStepAt = null;
|
||||||
|
this.stepIndex = 0;
|
||||||
|
this.pace = 1;
|
||||||
|
this.lastPaceUpdateAt = null;
|
||||||
|
this.isWaitingForFirstStrokeNote = false;
|
||||||
|
this.lastMidiByColor[0] = null;
|
||||||
|
this.lastMidiByColor[1] = null;
|
||||||
|
this.lastMidiByColor[2] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderStep({
|
||||||
|
vibe,
|
||||||
|
profile,
|
||||||
|
stepIndex,
|
||||||
|
startTime,
|
||||||
|
activity,
|
||||||
|
selectedColorIndex,
|
||||||
|
}: {
|
||||||
|
vibe: VibePreset;
|
||||||
|
profile: GardenAudioVibeProfile;
|
||||||
|
stepIndex: number;
|
||||||
|
startTime: number;
|
||||||
|
activity: number;
|
||||||
|
selectedColorIndex: GardenAudioColorIndex;
|
||||||
|
}): void {
|
||||||
|
if (
|
||||||
|
activity < this.config.rhythm.sparseActivity ||
|
||||||
|
stepIndex % this.config.rhythm.stepsPerBeat !== 0
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = COLOR_ROLES[selectedColorIndex];
|
||||||
|
const stepInBar = stepIndex % this.config.rhythm.stepsPerBar;
|
||||||
|
const beatsPerBar = this.getBeatsPerBar();
|
||||||
|
const beatInBar =
|
||||||
|
Math.floor(stepInBar / this.config.rhythm.stepsPerBeat) % beatsPerBar;
|
||||||
|
const barIndex = Math.floor(stepIndex / this.config.rhythm.stepsPerBar);
|
||||||
|
const phraseIndex = Math.floor(barIndex / PHRASE_BAR_COUNT);
|
||||||
|
const barInPhrase = barIndex % PHRASE_BAR_COUNT;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!this.shouldPlayBeat({
|
||||||
|
vibeId: vibe.id,
|
||||||
|
role,
|
||||||
|
activity,
|
||||||
|
beatInBar,
|
||||||
|
beatsPerBar,
|
||||||
|
barInPhrase,
|
||||||
|
phraseIndex,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const note = this.createNote({
|
||||||
|
vibeId: vibe.id,
|
||||||
|
profile,
|
||||||
|
role,
|
||||||
|
stepIndex,
|
||||||
|
startTime,
|
||||||
|
activity,
|
||||||
|
selectedColorIndex,
|
||||||
|
beatInBar,
|
||||||
|
beatsPerBar,
|
||||||
|
barInPhrase,
|
||||||
|
phraseIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.lastMidiByColor[selectedColorIndex] = note.midi;
|
||||||
|
this.isWaitingForFirstStrokeNote = false;
|
||||||
|
this.playNote(note);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createNote({
|
||||||
|
vibeId,
|
||||||
|
profile,
|
||||||
|
role,
|
||||||
|
stepIndex,
|
||||||
|
startTime,
|
||||||
|
activity,
|
||||||
|
selectedColorIndex,
|
||||||
|
beatInBar,
|
||||||
|
beatsPerBar,
|
||||||
|
barInPhrase,
|
||||||
|
phraseIndex,
|
||||||
|
}: {
|
||||||
|
vibeId: string;
|
||||||
|
profile: GardenAudioVibeProfile;
|
||||||
|
role: PianoRole;
|
||||||
|
stepIndex: number;
|
||||||
|
startTime: number;
|
||||||
|
activity: number;
|
||||||
|
selectedColorIndex: GardenAudioColorIndex;
|
||||||
|
beatInBar: number;
|
||||||
|
beatsPerBar: number;
|
||||||
|
barInPhrase: number;
|
||||||
|
phraseIndex: number;
|
||||||
|
}): PianoNote {
|
||||||
|
const colorVoice = this.config.colorVoices[selectedColorIndex];
|
||||||
|
const expression = this.getExpression(activity);
|
||||||
|
const midi = this.chooseMidi({
|
||||||
|
vibeId,
|
||||||
|
profile,
|
||||||
|
role,
|
||||||
|
stepIndex,
|
||||||
|
selectedColorIndex,
|
||||||
|
beatInBar,
|
||||||
|
beatsPerBar,
|
||||||
|
barInPhrase,
|
||||||
|
phraseIndex,
|
||||||
|
});
|
||||||
|
const beatAccent = beatInBar === 0 ? 1.08 : beatInBar === 2 ? 0.97 : 0.9;
|
||||||
|
const phrasePosition =
|
||||||
|
(barInPhrase * beatsPerBar + beatInBar) / (PHRASE_BAR_COUNT * beatsPerBar);
|
||||||
|
const phraseSwell = 0.88 + Math.sin(phrasePosition * Math.PI) * 0.12;
|
||||||
|
|
||||||
|
return {
|
||||||
|
midi,
|
||||||
|
velocity: clamp(
|
||||||
|
(role.velocityBase + expression * role.velocityActivityScale) *
|
||||||
|
colorVoice.velocityMultiplier *
|
||||||
|
beatAccent *
|
||||||
|
phraseSwell,
|
||||||
|
0.06,
|
||||||
|
0.52
|
||||||
|
),
|
||||||
|
durationSeconds:
|
||||||
|
role.durationBase +
|
||||||
|
expression * role.durationActivityScale +
|
||||||
|
(beatInBar === 0 ? role.downbeatDurationBoost : 0),
|
||||||
|
pan: clamp(role.pan + colorVoice.panOffset * 0.45, -1, 1),
|
||||||
|
delaySend: clamp(role.delaySend * (1.15 - expression * 0.4), 0, 0.04),
|
||||||
|
lowpassHz: this.getLowpassHz(profile, midi, expression),
|
||||||
|
startTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private chooseMidi({
|
||||||
|
vibeId,
|
||||||
|
profile,
|
||||||
|
role,
|
||||||
|
stepIndex,
|
||||||
|
selectedColorIndex,
|
||||||
|
beatInBar,
|
||||||
|
beatsPerBar,
|
||||||
|
barInPhrase,
|
||||||
|
phraseIndex,
|
||||||
|
}: {
|
||||||
|
vibeId: string;
|
||||||
|
profile: GardenAudioVibeProfile;
|
||||||
|
role: PianoRole;
|
||||||
|
stepIndex: number;
|
||||||
|
selectedColorIndex: GardenAudioColorIndex;
|
||||||
|
beatInBar: number;
|
||||||
|
beatsPerBar: number;
|
||||||
|
barInPhrase: number;
|
||||||
|
phraseIndex: number;
|
||||||
|
}): number {
|
||||||
|
const chord = getChordAtStep(this.config, profile, stepIndex);
|
||||||
|
const rootMidi = profile.rootMidi + chord.rootOffset;
|
||||||
|
const offsets = this.getPitchOffsets({
|
||||||
|
vibeId,
|
||||||
|
profile,
|
||||||
|
chord,
|
||||||
|
selectedColorIndex,
|
||||||
|
beatInBar,
|
||||||
|
beatsPerBar,
|
||||||
|
barInPhrase,
|
||||||
|
phraseIndex,
|
||||||
|
});
|
||||||
|
const previousMidi = this.lastMidiByColor[selectedColorIndex] ?? role.preferredMidi;
|
||||||
|
const candidates = this.getCandidates(rootMidi, offsets, role);
|
||||||
|
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
return clampMidi(rootMidi + offsets[0], role.midiMin, role.midiMax);
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates.reduce((best, candidate) =>
|
||||||
|
this.scoreCandidate(candidate, role, previousMidi) <
|
||||||
|
this.scoreCandidate(best, role, previousMidi)
|
||||||
|
? candidate
|
||||||
|
: best
|
||||||
|
).midi;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPitchOffsets({
|
||||||
|
vibeId,
|
||||||
|
profile,
|
||||||
|
chord,
|
||||||
|
selectedColorIndex,
|
||||||
|
beatInBar,
|
||||||
|
beatsPerBar,
|
||||||
|
barInPhrase,
|
||||||
|
phraseIndex,
|
||||||
|
}: {
|
||||||
|
vibeId: string;
|
||||||
|
profile: GardenAudioVibeProfile;
|
||||||
|
chord: GardenAudioChord;
|
||||||
|
selectedColorIndex: GardenAudioColorIndex;
|
||||||
|
beatInBar: number;
|
||||||
|
beatsPerBar: number;
|
||||||
|
barInPhrase: number;
|
||||||
|
phraseIndex: number;
|
||||||
|
}): Array<number> {
|
||||||
|
const chordIntervals = getChordIntervals(chord, false);
|
||||||
|
const phraseBeat = barInPhrase * beatsPerBar + beatInBar;
|
||||||
|
const contour = this.getPhraseContour(
|
||||||
|
vibeId,
|
||||||
|
phraseIndex,
|
||||||
|
selectedColorIndex,
|
||||||
|
phraseBeat
|
||||||
|
);
|
||||||
|
const colorVoice = this.config.colorVoices[selectedColorIndex];
|
||||||
|
|
||||||
|
if (selectedColorIndex === 0) {
|
||||||
|
return beatInBar === 0 ? [0, 7, 12] : [7, 0, 12];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedColorIndex === 1) {
|
||||||
|
return [
|
||||||
|
chordIntervals[1],
|
||||||
|
chordIntervals[2],
|
||||||
|
degreeToSemitone(profile, 2 + colorVoice.scaleDegreeOffset + contour),
|
||||||
|
chordIntervals[1] + 12,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const degreeBase = 2 + colorVoice.scaleDegreeOffset + contour;
|
||||||
|
return [
|
||||||
|
degreeToSemitone(profile, degreeBase),
|
||||||
|
degreeToSemitone(profile, degreeBase + 2),
|
||||||
|
12 + degreeToSemitone(profile, degreeBase - 1),
|
||||||
|
12 + degreeToSemitone(profile, degreeBase + 1),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCandidates(
|
||||||
|
rootMidi: number,
|
||||||
|
offsets: ReadonlyArray<number>,
|
||||||
|
role: PianoRole
|
||||||
|
): Array<PitchCandidate> {
|
||||||
|
const candidates: Array<PitchCandidate> = [];
|
||||||
|
|
||||||
|
offsets.forEach((offset, preference) => {
|
||||||
|
for (let octave = -3; octave <= 3; octave += 1) {
|
||||||
|
const midi = rootMidi + offset + octave * 12;
|
||||||
|
if (midi >= role.midiMin && midi <= role.midiMax) {
|
||||||
|
candidates.push({ midi: Math.round(midi), preference });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
private scoreCandidate(
|
||||||
|
candidate: PitchCandidate,
|
||||||
|
role: PianoRole,
|
||||||
|
previousMidi: number
|
||||||
|
): number {
|
||||||
|
return (
|
||||||
|
Math.abs(candidate.midi - previousMidi) +
|
||||||
|
Math.abs(candidate.midi - role.preferredMidi) * NOTE_SCORE_REGISTER_WEIGHT +
|
||||||
|
candidate.preference * NOTE_SCORE_PREFERENCE_WEIGHT
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldPlayBeat({
|
||||||
|
vibeId,
|
||||||
|
role,
|
||||||
|
activity,
|
||||||
|
beatInBar,
|
||||||
|
beatsPerBar,
|
||||||
|
barInPhrase,
|
||||||
|
phraseIndex,
|
||||||
|
}: {
|
||||||
|
vibeId: string;
|
||||||
|
role: PianoRole;
|
||||||
|
activity: number;
|
||||||
|
beatInBar: number;
|
||||||
|
beatsPerBar: number;
|
||||||
|
barInPhrase: number;
|
||||||
|
phraseIndex: number;
|
||||||
|
}): boolean {
|
||||||
|
const expression = this.getExpression(activity);
|
||||||
|
const beats =
|
||||||
|
expression < 0.34
|
||||||
|
? role.lowBeats
|
||||||
|
: expression < 0.68
|
||||||
|
? role.mediumBeats
|
||||||
|
: role.highBeats;
|
||||||
|
|
||||||
|
if (!beats.includes(beatInBar)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const phraseBeat = barInPhrase * beatsPerBar + beatInBar;
|
||||||
|
if (
|
||||||
|
expression < 0.34 &&
|
||||||
|
phraseBeat % role.lowPhraseSpacing !== role.lowPhraseOffset
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keepChance =
|
||||||
|
expression < 0.34
|
||||||
|
? role.lowKeepChance
|
||||||
|
: expression < 0.68
|
||||||
|
? role.mediumKeepChance
|
||||||
|
: role.highKeepChance;
|
||||||
|
const gate = hashUnit(vibeId, role.name, phraseIndex, barInPhrase, beatInBar);
|
||||||
|
|
||||||
|
if (this.isWaitingForFirstStrokeNote && beatInBar === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (beatInBar === 0 && role.name !== 'spark' && expression >= 0.52) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return gate <= keepChance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPhraseContour(
|
||||||
|
vibeId: string,
|
||||||
|
phraseIndex: number,
|
||||||
|
selectedColorIndex: GardenAudioColorIndex,
|
||||||
|
phraseBeat: number
|
||||||
|
): number {
|
||||||
|
if (selectedColorIndex === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contourIndex =
|
||||||
|
hashInt(vibeId, phraseIndex, selectedColorIndex) % PHRASE_CONTOURS.length;
|
||||||
|
const contour = PHRASE_CONTOURS[contourIndex];
|
||||||
|
return contour[phraseBeat % contour.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
private getLowpassHz(
|
||||||
|
profile: GardenAudioVibeProfile,
|
||||||
|
midi: number,
|
||||||
|
expression: number
|
||||||
|
): number {
|
||||||
|
const midiLift = clamp01((midi - 48) / 36) * 1100;
|
||||||
|
return clamp(
|
||||||
|
this.config.piano.lowpassHz * profile.brightness * (0.44 + expression * 0.44) +
|
||||||
|
midiLift,
|
||||||
|
appConfig.audioEngine.piano.lowpassMinHz,
|
||||||
|
appConfig.audioEngine.piano.lowpassMaxHz
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updatePace(now: number, activity: number): void {
|
||||||
|
if (this.lastPaceUpdateAt === null) {
|
||||||
|
this.lastPaceUpdateAt = now;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsedSeconds = Math.max(0, now - this.lastPaceUpdateAt);
|
||||||
|
this.lastPaceUpdateAt = now;
|
||||||
|
const targetPace = PACE_MIN + (PACE_MAX - PACE_MIN) * this.getExpression(activity);
|
||||||
|
const amount = 1 - Math.exp(-elapsedSeconds / PACE_RAMP_SECONDS);
|
||||||
|
this.pace += (targetPace - this.pace) * amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
private skipLateSteps(now: number, stepSeconds: number): void {
|
||||||
|
if (this.nextStepAt === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const earliestStart = now + appConfig.audioEngine.piano.scheduleAheadSeconds;
|
||||||
|
if (this.nextStepAt >= earliestStart) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const skippedSteps = Math.floor((earliestStart - this.nextStepAt) / stepSeconds) + 1;
|
||||||
|
this.nextStepAt += skippedSteps * stepSeconds;
|
||||||
|
this.stepIndex += skippedSteps;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getExpression(activity: number): number {
|
||||||
|
return clamp01(
|
||||||
|
(activity - this.config.rhythm.sparseActivity) /
|
||||||
|
(1 - this.config.rhythm.sparseActivity)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getStepDurationSeconds(): number {
|
||||||
|
return 60 / this.config.rhythm.bpm / this.config.rhythm.stepsPerBeat / this.pace;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getNextDownbeatStepIndex(): number {
|
||||||
|
const stepInBar = this.stepIndex % this.config.rhythm.stepsPerBar;
|
||||||
|
if (stepInBar === 0) {
|
||||||
|
return this.stepIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.stepIndex + this.config.rhythm.stepsPerBar - stepInBar;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getBeatsPerBar(): number {
|
||||||
|
return Math.max(
|
||||||
|
1,
|
||||||
|
Math.round(this.config.rhythm.stepsPerBar / this.config.rhythm.stepsPerBeat)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashUnit = (...parts: Array<number | string>): number =>
|
||||||
|
hashInt(...parts) / 0xffffffff;
|
||||||
|
|
||||||
|
const hashInt = (...parts: Array<number | string>): number => {
|
||||||
|
let hash = 2166136261;
|
||||||
|
const input = parts.join(':');
|
||||||
|
for (let index = 0; index < input.length; index += 1) {
|
||||||
|
hash ^= input.charCodeAt(index);
|
||||||
|
hash = Math.imul(hash, 16777619);
|
||||||
|
}
|
||||||
|
return hash >>> 0;
|
||||||
|
};
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { appConfig } from '../config';
|
||||||
import { GardenAudioGraph } from './garden-audio-graph';
|
import { GardenAudioGraph } from './garden-audio-graph';
|
||||||
import { NoiseBurst } from './garden-audio-types';
|
import { NoiseBurst } from './garden-audio-types';
|
||||||
|
|
||||||
|
|
@ -10,7 +11,10 @@ export class NoiseBurstPlayer {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const scheduledStart = Math.max(context.currentTime + 0.002, startTime);
|
const scheduledStart = Math.max(
|
||||||
|
context.currentTime + appConfig.audioEngine.noiseBurst.scheduleAheadSeconds,
|
||||||
|
startTime
|
||||||
|
);
|
||||||
const source = context.createBufferSource();
|
const source = context.createBufferSource();
|
||||||
const filter = context.createBiquadFilter();
|
const filter = context.createBiquadFilter();
|
||||||
const envelope = context.createGain();
|
const envelope = context.createGain();
|
||||||
|
|
@ -20,20 +24,29 @@ export class NoiseBurstPlayer {
|
||||||
source.buffer = noiseBuffer;
|
source.buffer = noiseBuffer;
|
||||||
filter.type = 'bandpass';
|
filter.type = 'bandpass';
|
||||||
filter.frequency.setValueAtTime(filterHz, scheduledStart);
|
filter.frequency.setValueAtTime(filterHz, scheduledStart);
|
||||||
filter.Q.value = 1.4;
|
filter.Q.value = appConfig.audioEngine.noiseBurst.filterQ;
|
||||||
envelope.gain.setValueAtTime(0.0001, scheduledStart);
|
envelope.gain.setValueAtTime(
|
||||||
envelope.gain.exponentialRampToValueAtTime(
|
appConfig.audioEngine.noiseBurst.silentGain,
|
||||||
Math.max(0.0001, gain),
|
scheduledStart
|
||||||
scheduledStart + 0.004
|
);
|
||||||
|
envelope.gain.exponentialRampToValueAtTime(
|
||||||
|
Math.max(appConfig.audioEngine.noiseBurst.silentGain, gain),
|
||||||
|
scheduledStart + appConfig.audioEngine.noiseBurst.attackSeconds
|
||||||
|
);
|
||||||
|
envelope.gain.exponentialRampToValueAtTime(
|
||||||
|
appConfig.audioEngine.noiseBurst.silentGain,
|
||||||
|
stopAt
|
||||||
);
|
);
|
||||||
envelope.gain.exponentialRampToValueAtTime(0.0001, stopAt);
|
|
||||||
panner.pan.setValueAtTime(pan, scheduledStart);
|
panner.pan.setValueAtTime(pan, scheduledStart);
|
||||||
|
|
||||||
source.connect(filter);
|
source.connect(filter);
|
||||||
filter.connect(envelope);
|
filter.connect(envelope);
|
||||||
envelope.connect(panner);
|
envelope.connect(panner);
|
||||||
panner.connect(eventBus);
|
panner.connect(eventBus);
|
||||||
source.start(scheduledStart, Math.random() * 0.4);
|
source.start(
|
||||||
|
scheduledStart,
|
||||||
|
Math.random() * appConfig.audioEngine.noiseBurst.offsetRandomSeconds
|
||||||
|
);
|
||||||
source.stop(stopAt);
|
source.stop(stopAt);
|
||||||
source.addEventListener(
|
source.addEventListener(
|
||||||
'ended',
|
'ended',
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { appConfig } from '../config';
|
||||||
import { clamp, clamp01 } from '../utils/clamp';
|
import { clamp, clamp01 } from '../utils/clamp';
|
||||||
import { GardenAudioConfig } from './garden-audio-config';
|
import { GardenAudioConfig } from './garden-audio-config';
|
||||||
import { GardenAudioGraph } from './garden-audio-graph';
|
import { GardenAudioGraph } from './garden-audio-graph';
|
||||||
|
|
@ -22,6 +23,9 @@ export class PianoSampler {
|
||||||
this.sampleLoadPromise = Promise.all(
|
this.sampleLoadPromise = Promise.all(
|
||||||
pianoSampleDefinitions.map(async (sample) => {
|
pianoSampleDefinitions.map(async (sample) => {
|
||||||
const response = await fetch(sample.url);
|
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 audioData = await response.arrayBuffer();
|
||||||
const buffer = await context.decodeAudioData(audioData);
|
const buffer = await context.decodeAudioData(audioData);
|
||||||
return { midi: sample.midi, buffer };
|
return { midi: sample.midi, buffer };
|
||||||
|
|
@ -56,12 +60,22 @@ export class PianoSampler {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const scheduledStart = Math.max(context.currentTime + 0.002, startTime);
|
const scheduledStart = Math.max(
|
||||||
|
context.currentTime + appConfig.audioEngine.piano.scheduleAheadSeconds,
|
||||||
|
startTime
|
||||||
|
);
|
||||||
const noteVelocity = clamp01(velocity);
|
const noteVelocity = clamp01(velocity);
|
||||||
const noteGainValue = Math.max(0.0001, this.config.piano.gain * noteVelocity);
|
const noteGainValue = Math.max(
|
||||||
|
appConfig.audioEngine.piano.minGain,
|
||||||
|
this.config.piano.gain * noteVelocity
|
||||||
|
);
|
||||||
const sustainSeconds =
|
const sustainSeconds =
|
||||||
this.config.piano.sustainSeconds * (0.45 + noteVelocity * 0.55);
|
this.config.piano.sustainSeconds *
|
||||||
const sustainAt = scheduledStart + Math.max(0.08, durationSeconds);
|
(appConfig.audioEngine.piano.sustainBase +
|
||||||
|
noteVelocity * appConfig.audioEngine.piano.sustainVelocityRange);
|
||||||
|
const sustainAt =
|
||||||
|
scheduledStart +
|
||||||
|
Math.max(appConfig.audioEngine.piano.minDurationSeconds, durationSeconds);
|
||||||
const releaseAt = sustainAt + sustainSeconds;
|
const releaseAt = sustainAt + sustainSeconds;
|
||||||
const releaseSeconds = this.config.piano.releaseSeconds;
|
const releaseSeconds = this.config.piano.releaseSeconds;
|
||||||
const stopAt = releaseAt + releaseSeconds;
|
const stopAt = releaseAt + releaseSeconds;
|
||||||
|
|
@ -75,26 +89,55 @@ export class PianoSampler {
|
||||||
while (this.activeVoices.length >= this.config.piano.maxVoices) {
|
while (this.activeVoices.length >= this.config.piano.maxVoices) {
|
||||||
const oldest = this.activeVoices.shift();
|
const oldest = this.activeVoices.shift();
|
||||||
oldest?.gain.gain.cancelScheduledValues(scheduledStart);
|
oldest?.gain.gain.cancelScheduledValues(scheduledStart);
|
||||||
oldest?.gain.gain.setTargetAtTime(0.0001, scheduledStart, 0.025);
|
oldest?.gain.gain.setTargetAtTime(
|
||||||
oldest?.source.stop(scheduledStart + 0.05);
|
appConfig.audioEngine.piano.minGain,
|
||||||
|
scheduledStart,
|
||||||
|
appConfig.audioEngine.piano.voiceStealFadeSeconds
|
||||||
|
);
|
||||||
|
oldest?.source.stop(
|
||||||
|
scheduledStart + appConfig.audioEngine.piano.voiceStealStopSeconds
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
source.buffer = sample.buffer;
|
source.buffer = sample.buffer;
|
||||||
source.playbackRate.setValueAtTime(
|
source.playbackRate.setValueAtTime(
|
||||||
Math.pow(2, (midi - sample.midi) / 12),
|
Math.pow(
|
||||||
|
2,
|
||||||
|
(midi - sample.midi) / appConfig.audioEngine.piano.pitchSemitonesPerOctave
|
||||||
|
),
|
||||||
scheduledStart
|
scheduledStart
|
||||||
);
|
);
|
||||||
filter.type = 'lowpass';
|
filter.type = 'lowpass';
|
||||||
filter.frequency.setValueAtTime(clamp(lowpassHz, 1400, 12000), scheduledStart);
|
filter.frequency.setValueAtTime(
|
||||||
filter.Q.value = 0.7;
|
clamp(
|
||||||
gain.gain.setValueAtTime(0.0001, scheduledStart);
|
lowpassHz,
|
||||||
gain.gain.exponentialRampToValueAtTime(noteGainValue, scheduledStart + 0.006);
|
appConfig.audioEngine.piano.lowpassMinHz,
|
||||||
gain.gain.setTargetAtTime(
|
appConfig.audioEngine.piano.lowpassMaxHz
|
||||||
Math.max(0.0001, noteGainValue * this.config.piano.sustainLevel),
|
),
|
||||||
sustainAt,
|
scheduledStart
|
||||||
Math.max(0.04, sustainSeconds * 0.45)
|
);
|
||||||
|
filter.Q.value = appConfig.audioEngine.piano.filterQ;
|
||||||
|
gain.gain.setValueAtTime(appConfig.audioEngine.piano.minGain, scheduledStart);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(
|
||||||
|
noteGainValue,
|
||||||
|
scheduledStart + appConfig.audioEngine.piano.gainAttackSeconds
|
||||||
|
);
|
||||||
|
gain.gain.setTargetAtTime(
|
||||||
|
Math.max(
|
||||||
|
appConfig.audioEngine.piano.minGain,
|
||||||
|
noteGainValue * this.config.piano.sustainLevel
|
||||||
|
),
|
||||||
|
sustainAt,
|
||||||
|
Math.max(
|
||||||
|
appConfig.audioEngine.piano.minFadeSeconds,
|
||||||
|
sustainSeconds * appConfig.audioEngine.piano.sustainBase
|
||||||
|
)
|
||||||
|
);
|
||||||
|
gain.gain.setTargetAtTime(
|
||||||
|
appConfig.audioEngine.piano.minGain,
|
||||||
|
releaseAt,
|
||||||
|
releaseSeconds
|
||||||
);
|
);
|
||||||
gain.gain.setTargetAtTime(0.0001, releaseAt, releaseSeconds);
|
|
||||||
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
|
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
|
||||||
|
|
||||||
source.connect(filter);
|
source.connect(filter);
|
||||||
|
|
@ -102,7 +145,7 @@ export class PianoSampler {
|
||||||
gain.connect(panner);
|
gain.connect(panner);
|
||||||
panner.connect(eventBus);
|
panner.connect(eventBus);
|
||||||
|
|
||||||
if (delayInput && this.config.delay.enabled && delaySend > 0) {
|
if (delayInput && delaySend > 0) {
|
||||||
sendGain = context.createGain();
|
sendGain = context.createGain();
|
||||||
sendGain.gain.value = delaySend;
|
sendGain.gain.value = delaySend;
|
||||||
panner.connect(sendGain);
|
panner.connect(sendGain);
|
||||||
|
|
@ -110,8 +153,8 @@ export class PianoSampler {
|
||||||
}
|
}
|
||||||
|
|
||||||
source.start(scheduledStart);
|
source.start(scheduledStart);
|
||||||
source.stop(stopAt + 0.05);
|
source.stop(stopAt + appConfig.audioEngine.piano.tailStopExtraSeconds);
|
||||||
this.activeVoices.push({ gain, source, stopAt });
|
this.activeVoices.push({ gain, source, startAt: scheduledStart, stopAt });
|
||||||
|
|
||||||
source.addEventListener(
|
source.addEventListener(
|
||||||
'ended',
|
'ended',
|
||||||
|
|
@ -127,13 +170,35 @@ export class PianoSampler {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public fadeActive(now: number): void {
|
public fadeActive(
|
||||||
|
now: number,
|
||||||
|
fadeSeconds = appConfig.audioEngine.piano.defaultFadeSeconds
|
||||||
|
): void {
|
||||||
this.activeVoices.forEach((voice) => {
|
this.activeVoices.forEach((voice) => {
|
||||||
voice.gain.gain.cancelScheduledValues(now);
|
voice.gain.gain.cancelScheduledValues(now);
|
||||||
voice.gain.gain.setTargetAtTime(0.0001, now, 0.035);
|
if (voice.startAt > now) {
|
||||||
voice.stopAt = Math.min(voice.stopAt, now + 0.28);
|
voice.gain.gain.setValueAtTime(appConfig.audioEngine.piano.minGain, now);
|
||||||
|
voice.stopAt = now;
|
||||||
|
try {
|
||||||
|
voice.source.stop(now);
|
||||||
|
} catch {
|
||||||
|
// The source may already have a stop time scheduled.
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopAt = Math.min(voice.stopAt, now + fadeSeconds);
|
||||||
|
voice.gain.gain.setTargetAtTime(
|
||||||
|
appConfig.audioEngine.piano.minGain,
|
||||||
|
now,
|
||||||
|
Math.max(
|
||||||
|
appConfig.audioEngine.piano.minFadeSeconds,
|
||||||
|
fadeSeconds * appConfig.audioEngine.piano.fadeTimeConstantRatio
|
||||||
|
)
|
||||||
|
);
|
||||||
|
voice.stopAt = stopAt;
|
||||||
try {
|
try {
|
||||||
voice.source.stop(now + 0.28);
|
voice.source.stop(stopAt);
|
||||||
} catch {
|
} catch {
|
||||||
// The source may already have a stop time scheduled.
|
// The source may already have a stop time scheduled.
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,36 +6,36 @@ export interface PianoSampleDefinition {
|
||||||
const sampleBaseUrl = `${import.meta.env.BASE_URL}audio/piano/`;
|
const sampleBaseUrl = `${import.meta.env.BASE_URL}audio/piano/`;
|
||||||
|
|
||||||
const sampleFiles: Array<[fileName: string, midi: number]> = [
|
const sampleFiles: Array<[fileName: string, midi: number]> = [
|
||||||
['A0v12.ogg', 21],
|
['A0v12.m4a', 21],
|
||||||
['C1v12.ogg', 24],
|
['C1v12.m4a', 24],
|
||||||
['Dsharp1v12.ogg', 27],
|
['Dsharp1v12.m4a', 27],
|
||||||
['Fsharp1v12.ogg', 30],
|
['Fsharp1v12.m4a', 30],
|
||||||
['A1v12.ogg', 33],
|
['A1v12.m4a', 33],
|
||||||
['C2v12.ogg', 36],
|
['C2v12.m4a', 36],
|
||||||
['Dsharp2v12.ogg', 39],
|
['Dsharp2v12.m4a', 39],
|
||||||
['Fsharp2v12.ogg', 42],
|
['Fsharp2v12.m4a', 42],
|
||||||
['A2v12.ogg', 45],
|
['A2v12.m4a', 45],
|
||||||
['C3v12.ogg', 48],
|
['C3v12.m4a', 48],
|
||||||
['Dsharp3v12.ogg', 51],
|
['Dsharp3v12.m4a', 51],
|
||||||
['Fsharp3v12.ogg', 54],
|
['Fsharp3v12.m4a', 54],
|
||||||
['A3v12.ogg', 57],
|
['A3v12.m4a', 57],
|
||||||
['C4v12.ogg', 60],
|
['C4v12.m4a', 60],
|
||||||
['Dsharp4v12.ogg', 63],
|
['Dsharp4v12.m4a', 63],
|
||||||
['Fsharp4v12.ogg', 66],
|
['Fsharp4v12.m4a', 66],
|
||||||
['A4v12.ogg', 69],
|
['A4v12.m4a', 69],
|
||||||
['C5v12.ogg', 72],
|
['C5v12.m4a', 72],
|
||||||
['Dsharp5v12.ogg', 75],
|
['Dsharp5v12.m4a', 75],
|
||||||
['Fsharp5v12.ogg', 78],
|
['Fsharp5v12.m4a', 78],
|
||||||
['A5v12.ogg', 81],
|
['A5v12.m4a', 81],
|
||||||
['C6v12.ogg', 84],
|
['C6v12.m4a', 84],
|
||||||
['Dsharp6v12.ogg', 87],
|
['Dsharp6v12.m4a', 87],
|
||||||
['Fsharp6v12.ogg', 90],
|
['Fsharp6v12.m4a', 90],
|
||||||
['A6v12.ogg', 93],
|
['A6v12.m4a', 93],
|
||||||
['C7v12.ogg', 96],
|
['C7v12.m4a', 96],
|
||||||
['Dsharp7v12.ogg', 99],
|
['Dsharp7v12.m4a', 99],
|
||||||
['Fsharp7v12.ogg', 102],
|
['Fsharp7v12.m4a', 102],
|
||||||
['A7v12.ogg', 105],
|
['A7v12.m4a', 105],
|
||||||
['C8v12.ogg', 108],
|
['C8v12.m4a', 108],
|
||||||
];
|
];
|
||||||
|
|
||||||
export const pianoSampleDefinitions: Array<PianoSampleDefinition> = sampleFiles
|
export const pianoSampleDefinitions: Array<PianoSampleDefinition> = sampleFiles
|
||||||
|
|
|
||||||
915
src/config.ts
Normal file
915
src/config.ts
Normal file
|
|
@ -0,0 +1,915 @@
|
||||||
|
import type {
|
||||||
|
GardenAudioChord,
|
||||||
|
GardenAudioConfig,
|
||||||
|
GardenAudioVibeProfile,
|
||||||
|
} from './audio/garden-audio-config';
|
||||||
|
import type { GameLoopSettings } from './game-loop/game-loop-settings';
|
||||||
|
import type { AgentSettings } from './pipelines/agents/agent-settings';
|
||||||
|
import type { BrushSettings } from './pipelines/brush/brush-settings';
|
||||||
|
import type { DiffusionSettings } from './pipelines/diffusion/diffusion-settings';
|
||||||
|
import type { RenderSettings } from './pipelines/render/render-settings';
|
||||||
|
|
||||||
|
export type GardenRuntimeSettings = GameLoopSettings &
|
||||||
|
AgentSettings &
|
||||||
|
BrushSettings &
|
||||||
|
DiffusionSettings &
|
||||||
|
RenderSettings;
|
||||||
|
|
||||||
|
export type GardenVibeSettings = Partial<
|
||||||
|
Pick<
|
||||||
|
GardenRuntimeSettings,
|
||||||
|
| 'agentBudgetMax'
|
||||||
|
| 'brushSize'
|
||||||
|
| 'clarity'
|
||||||
|
| 'decayRateTrails'
|
||||||
|
| 'diffusionRateTrails'
|
||||||
|
| 'individualTrailWeight'
|
||||||
|
| 'moveSpeed'
|
||||||
|
| 'sensorOffsetAngle'
|
||||||
|
| 'sensorOffsetDistance'
|
||||||
|
| 'spawnPerPixel'
|
||||||
|
| 'turnSpeed'
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export interface VibePreset {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
colors: [string, string, string];
|
||||||
|
backgroundColor: string;
|
||||||
|
settings: GardenVibeSettings;
|
||||||
|
audio: GardenAudioVibeProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NumberControlConfig {
|
||||||
|
folder: string;
|
||||||
|
integer?: boolean;
|
||||||
|
label?: string;
|
||||||
|
max: number;
|
||||||
|
min: number;
|
||||||
|
step?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RuntimeSettingControlConfig = {
|
||||||
|
[Key in keyof GardenRuntimeSettings]: NumberControlConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
gestureFadeSeconds: 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;
|
||||||
|
penMinPressure: number;
|
||||||
|
strokeEnergyBase: number;
|
||||||
|
strokeEnergyPressureWeight: number;
|
||||||
|
strokeEnergySpeedWeight: number;
|
||||||
|
};
|
||||||
|
muteGain: number;
|
||||||
|
muteRampSeconds: number;
|
||||||
|
noiseBurst: {
|
||||||
|
attackSeconds: number;
|
||||||
|
filterQ: number;
|
||||||
|
offsetRandomSeconds: number;
|
||||||
|
scheduleAheadSeconds: number;
|
||||||
|
silentGain: number;
|
||||||
|
};
|
||||||
|
piano: {
|
||||||
|
fadeStopExtraSeconds: number;
|
||||||
|
defaultFadeSeconds: number;
|
||||||
|
fadeTimeConstantRatio: 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;
|
||||||
|
};
|
||||||
|
startDelaySeconds: number;
|
||||||
|
vibeChangeStingerMinIntervalSeconds: number;
|
||||||
|
};
|
||||||
|
deltaTime: {
|
||||||
|
fpsExponentialDecayStrength: number;
|
||||||
|
maxDeltaTimeSeconds: number;
|
||||||
|
minDeltaTimeSeconds: number;
|
||||||
|
};
|
||||||
|
export4k: {
|
||||||
|
bytesPerPixel: number;
|
||||||
|
height: number;
|
||||||
|
jsHeapSafetyMultiplier: number;
|
||||||
|
lowMemoryDeviceGiB: number;
|
||||||
|
lowMemoryExportFraction: number;
|
||||||
|
rowAlignmentBytes: number;
|
||||||
|
width: number;
|
||||||
|
};
|
||||||
|
menuHider: {
|
||||||
|
bottomRevealDistancePx: number;
|
||||||
|
intervalMs: number;
|
||||||
|
timeToLiveMs: number;
|
||||||
|
};
|
||||||
|
pipelines: {
|
||||||
|
brush: {
|
||||||
|
maxLineCount: number;
|
||||||
|
};
|
||||||
|
diffusion: {
|
||||||
|
minDiffusionRate: number;
|
||||||
|
};
|
||||||
|
eraser: {
|
||||||
|
maxSegmentCount: number;
|
||||||
|
maxTextureLineCount: number;
|
||||||
|
segmentFloatCount: number;
|
||||||
|
workgroupSize: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
runtimeSettings: {
|
||||||
|
controls: RuntimeSettingControlConfig;
|
||||||
|
defaults: GardenRuntimeSettings;
|
||||||
|
};
|
||||||
|
simulation: {
|
||||||
|
budget: {
|
||||||
|
fpsHeadroom: number;
|
||||||
|
fpsSmoothingNew: number;
|
||||||
|
fpsSmoothingRetain: number;
|
||||||
|
initialTargetAgentBudget: number;
|
||||||
|
rampAgentsPerSecond: number;
|
||||||
|
refreshTargetDecay: number;
|
||||||
|
};
|
||||||
|
brushEffectFramesPerSecond: number;
|
||||||
|
globalAgentCap: number;
|
||||||
|
initialAgentCount: number;
|
||||||
|
intro: {
|
||||||
|
angleJitterRadians: number;
|
||||||
|
circleMaxSideRatio: number;
|
||||||
|
circleMinSideRatio: number;
|
||||||
|
drawHintClass: string;
|
||||||
|
drawHintDelayMs: number;
|
||||||
|
durationSeconds: number;
|
||||||
|
entryJitterSideRatio: number;
|
||||||
|
fontScaleDown: number;
|
||||||
|
initialFontHeightRatio: number;
|
||||||
|
initialFontWidthRatio: number;
|
||||||
|
letterSpacingEm: number;
|
||||||
|
maskAlphaThreshold: number;
|
||||||
|
maskGradientThreshold: number;
|
||||||
|
maskSampleDensity: number;
|
||||||
|
maxHeightRatio: number;
|
||||||
|
maxWidthRatio: number;
|
||||||
|
minEntryJitterPx: number;
|
||||||
|
minFontSizePx: number;
|
||||||
|
minTargetJitterPx: number;
|
||||||
|
radialJitterRatio: number;
|
||||||
|
targetDelayDistanceMultiplier: number;
|
||||||
|
targetDelayMax: number;
|
||||||
|
targetDelayRandomMultiplier: number;
|
||||||
|
targetJitterSideRatio: number;
|
||||||
|
title: string;
|
||||||
|
titleColorCutLetters: [number, number];
|
||||||
|
titleRadiusMultiplier: number;
|
||||||
|
titleStrokeWidthMinPx: number;
|
||||||
|
titleStrokeWidthRatio: number;
|
||||||
|
verticalAnchor: number;
|
||||||
|
};
|
||||||
|
introCameraZoom: number;
|
||||||
|
introMoveSpeedBaseMultiplier: number;
|
||||||
|
introMoveSpeedProgressMultiplier: number;
|
||||||
|
maxMirrorSegmentCount: number;
|
||||||
|
stroke: {
|
||||||
|
angleJitterRadians: number;
|
||||||
|
densityMultiplier: number;
|
||||||
|
maxAgentCount: number;
|
||||||
|
minAgentCount: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
storage: {
|
||||||
|
audioMutedKey: string;
|
||||||
|
vibeKey: string;
|
||||||
|
};
|
||||||
|
telemetry: {
|
||||||
|
enabled: boolean;
|
||||||
|
intervalMs: number;
|
||||||
|
};
|
||||||
|
toolbar: {
|
||||||
|
eraser: {
|
||||||
|
controlScaleMax: number;
|
||||||
|
controlScaleMin: number;
|
||||||
|
default: number;
|
||||||
|
max: number;
|
||||||
|
min: number;
|
||||||
|
step: number;
|
||||||
|
};
|
||||||
|
mirror: {
|
||||||
|
default: number;
|
||||||
|
max: number;
|
||||||
|
min: number;
|
||||||
|
names: Record<number, string>;
|
||||||
|
step: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
tuningPane: {
|
||||||
|
expandedDepth: number;
|
||||||
|
startHidden: boolean;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
vibes: {
|
||||||
|
defaultVibeId: string;
|
||||||
|
presets: Array<VibePreset>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const majorProgression: Array<GardenAudioChord> = [
|
||||||
|
{ rootOffset: 0, quality: 'major' },
|
||||||
|
{ rootOffset: 9, quality: 'minor' },
|
||||||
|
{ rootOffset: 5, quality: 'major' },
|
||||||
|
{ rootOffset: 7, quality: 'major' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const minorProgression: Array<GardenAudioChord> = [
|
||||||
|
{ rootOffset: 0, quality: 'minor' },
|
||||||
|
{ rootOffset: 8, quality: 'major' },
|
||||||
|
{ rootOffset: 3, quality: 'major' },
|
||||||
|
{ rootOffset: 10, quality: 'major' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const majorPentatonic = [0, 2, 4, 7, 9];
|
||||||
|
const minorPentatonic = [0, 3, 5, 7, 10];
|
||||||
|
|
||||||
|
const defaultVibeId = 'candy-rain';
|
||||||
|
|
||||||
|
const vibePresets: Array<VibePreset> = [
|
||||||
|
{
|
||||||
|
id: 'candy-rain',
|
||||||
|
name: 'Candy Rain',
|
||||||
|
colors: ['#ff5da2', '#36d7d0', '#ffd84d'],
|
||||||
|
backgroundColor: '#10151f',
|
||||||
|
settings: {
|
||||||
|
agentBudgetMax: 1_000_000,
|
||||||
|
brushSize: 14,
|
||||||
|
clarity: 0.62,
|
||||||
|
decayRateTrails: 965,
|
||||||
|
diffusionRateTrails: 0.22,
|
||||||
|
individualTrailWeight: 0.07,
|
||||||
|
moveSpeed: 82,
|
||||||
|
sensorOffsetAngle: 34,
|
||||||
|
sensorOffsetDistance: 38,
|
||||||
|
spawnPerPixel: 0.22,
|
||||||
|
turnSpeed: 58,
|
||||||
|
},
|
||||||
|
audio: {
|
||||||
|
rootMidi: 57,
|
||||||
|
scale: majorPentatonic,
|
||||||
|
brightness: 1.04,
|
||||||
|
delayTimeMultiplier: 0.92,
|
||||||
|
progression: majorProgression,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sunlit-moss',
|
||||||
|
name: 'Sunlit Moss',
|
||||||
|
colors: ['#83d483', '#f6d76b', '#5ec1a1'],
|
||||||
|
backgroundColor: '#172016',
|
||||||
|
settings: {
|
||||||
|
agentBudgetMax: 900_000,
|
||||||
|
brushSize: 16,
|
||||||
|
clarity: 0.68,
|
||||||
|
decayRateTrails: 975,
|
||||||
|
diffusionRateTrails: 0.18,
|
||||||
|
individualTrailWeight: 0.06,
|
||||||
|
moveSpeed: 70,
|
||||||
|
sensorOffsetAngle: 28,
|
||||||
|
sensorOffsetDistance: 46,
|
||||||
|
spawnPerPixel: 0.18,
|
||||||
|
turnSpeed: 44,
|
||||||
|
},
|
||||||
|
audio: {
|
||||||
|
rootMidi: 53,
|
||||||
|
scale: majorPentatonic,
|
||||||
|
brightness: 0.92,
|
||||||
|
delayTimeMultiplier: 1.08,
|
||||||
|
progression: [
|
||||||
|
{ rootOffset: 0, quality: 'major' },
|
||||||
|
{ rootOffset: 7, quality: 'major' },
|
||||||
|
{ rootOffset: 9, quality: 'minor' },
|
||||||
|
{ rootOffset: 5, quality: 'major' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'coral-tide',
|
||||||
|
name: 'Coral Tide',
|
||||||
|
colors: ['#ff7f6e', '#40b8ff', '#f4f0a6'],
|
||||||
|
backgroundColor: '#0f1822',
|
||||||
|
settings: {
|
||||||
|
agentBudgetMax: 1_000_000,
|
||||||
|
brushSize: 13,
|
||||||
|
clarity: 0.58,
|
||||||
|
decayRateTrails: 955,
|
||||||
|
diffusionRateTrails: 0.28,
|
||||||
|
individualTrailWeight: 0.055,
|
||||||
|
moveSpeed: 90,
|
||||||
|
sensorOffsetAngle: 36,
|
||||||
|
sensorOffsetDistance: 35,
|
||||||
|
spawnPerPixel: 0.25,
|
||||||
|
turnSpeed: 62,
|
||||||
|
},
|
||||||
|
audio: {
|
||||||
|
rootMidi: 50,
|
||||||
|
scale: minorPentatonic,
|
||||||
|
brightness: 1,
|
||||||
|
delayTimeMultiplier: 1.12,
|
||||||
|
progression: minorProgression,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'moon-orchid',
|
||||||
|
name: 'Moon Orchid',
|
||||||
|
colors: ['#c993ff', '#7dd8ff', '#f0f4ff'],
|
||||||
|
backgroundColor: '#14121d',
|
||||||
|
settings: {
|
||||||
|
agentBudgetMax: 850_000,
|
||||||
|
brushSize: 12,
|
||||||
|
clarity: 0.64,
|
||||||
|
decayRateTrails: 968,
|
||||||
|
diffusionRateTrails: 0.2,
|
||||||
|
individualTrailWeight: 0.065,
|
||||||
|
moveSpeed: 76,
|
||||||
|
sensorOffsetAngle: 32,
|
||||||
|
sensorOffsetDistance: 42,
|
||||||
|
spawnPerPixel: 0.2,
|
||||||
|
turnSpeed: 52,
|
||||||
|
},
|
||||||
|
audio: {
|
||||||
|
rootMidi: 49,
|
||||||
|
scale: minorPentatonic,
|
||||||
|
brightness: 0.9,
|
||||||
|
delayTimeMultiplier: 1.24,
|
||||||
|
progression: minorProgression,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'peach-neon',
|
||||||
|
name: 'Peach Neon',
|
||||||
|
colors: ['#ff9b73', '#5bf0a9', '#6ea8ff'],
|
||||||
|
backgroundColor: '#191716',
|
||||||
|
settings: {
|
||||||
|
agentBudgetMax: 1_000_000,
|
||||||
|
brushSize: 15,
|
||||||
|
clarity: 0.55,
|
||||||
|
decayRateTrails: 948,
|
||||||
|
diffusionRateTrails: 0.32,
|
||||||
|
individualTrailWeight: 0.05,
|
||||||
|
moveSpeed: 96,
|
||||||
|
sensorOffsetAngle: 40,
|
||||||
|
sensorOffsetDistance: 32,
|
||||||
|
spawnPerPixel: 0.24,
|
||||||
|
turnSpeed: 70,
|
||||||
|
},
|
||||||
|
audio: {
|
||||||
|
rootMidi: 56,
|
||||||
|
scale: majorPentatonic,
|
||||||
|
brightness: 1.08,
|
||||||
|
delayTimeMultiplier: 0.86,
|
||||||
|
progression: majorProgression,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'frost-bloom',
|
||||||
|
name: 'Frost Bloom',
|
||||||
|
colors: ['#b4f7ff', '#9ec8ff', '#ffb8d2'],
|
||||||
|
backgroundColor: '#101820',
|
||||||
|
settings: {
|
||||||
|
agentBudgetMax: 750_000,
|
||||||
|
brushSize: 18,
|
||||||
|
clarity: 0.7,
|
||||||
|
decayRateTrails: 982,
|
||||||
|
diffusionRateTrails: 0.14,
|
||||||
|
individualTrailWeight: 0.075,
|
||||||
|
moveSpeed: 62,
|
||||||
|
sensorOffsetAngle: 26,
|
||||||
|
sensorOffsetDistance: 52,
|
||||||
|
spawnPerPixel: 0.16,
|
||||||
|
turnSpeed: 40,
|
||||||
|
},
|
||||||
|
audio: {
|
||||||
|
rootMidi: 62,
|
||||||
|
scale: majorPentatonic,
|
||||||
|
brightness: 0.88,
|
||||||
|
delayTimeMultiplier: 1.32,
|
||||||
|
progression: [
|
||||||
|
{ rootOffset: 0, quality: 'major' },
|
||||||
|
{ rootOffset: 5, quality: 'major' },
|
||||||
|
{ rootOffset: 9, quality: 'minor' },
|
||||||
|
{ rootOffset: 7, quality: 'major' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const audioVibes = Object.fromEntries(
|
||||||
|
vibePresets.map((vibe) => [vibe.id, vibe.audio])
|
||||||
|
) as Record<string, GardenAudioVibeProfile>;
|
||||||
|
|
||||||
|
export const appConfig: GardenAppConfig = {
|
||||||
|
audio: {
|
||||||
|
masterVolume: 0.32,
|
||||||
|
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.42,
|
||||||
|
feedback: 0.12,
|
||||||
|
wetGain: 0.048,
|
||||||
|
},
|
||||||
|
piano: {
|
||||||
|
maxVoices: 32,
|
||||||
|
gain: 0.42,
|
||||||
|
sustainSeconds: 0.52,
|
||||||
|
sustainLevel: 0.34,
|
||||||
|
releaseSeconds: 0.16,
|
||||||
|
lowpassHz: 9000,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
pressureFallback: 0.48,
|
||||||
|
},
|
||||||
|
rhythm: {
|
||||||
|
bpm: 82,
|
||||||
|
stepsPerBeat: 4,
|
||||||
|
stepsPerBar: 16,
|
||||||
|
lookaheadSeconds: 0.18,
|
||||||
|
speedForFullEnergyPixelsPerSecond: 1800,
|
||||||
|
sparseActivity: 0.055,
|
||||||
|
},
|
||||||
|
eraser: {
|
||||||
|
minIntervalSeconds: 0.12,
|
||||||
|
noiseGain: 0.028,
|
||||||
|
filterMinHz: 650,
|
||||||
|
filterMaxHz: 3600,
|
||||||
|
},
|
||||||
|
colorVoices: [
|
||||||
|
{
|
||||||
|
scaleDegreeOffset: 0,
|
||||||
|
velocityMultiplier: 0.92,
|
||||||
|
panOffset: -0.14,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scaleDegreeOffset: 1,
|
||||||
|
velocityMultiplier: 1,
|
||||||
|
panOffset: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scaleDegreeOffset: 2,
|
||||||
|
velocityMultiplier: 0.86,
|
||||||
|
panOffset: 0.14,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
gestureFadeSeconds: 1.35,
|
||||||
|
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,
|
||||||
|
penMinPressure: 0.56,
|
||||||
|
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: {
|
||||||
|
fadeStopExtraSeconds: 0.05,
|
||||||
|
defaultFadeSeconds: 0.9,
|
||||||
|
fadeTimeConstantRatio: 0.3,
|
||||||
|
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,
|
||||||
|
minDeltaTimeSeconds: 1 / 240,
|
||||||
|
},
|
||||||
|
export4k: {
|
||||||
|
bytesPerPixel: 4,
|
||||||
|
height: 2160,
|
||||||
|
jsHeapSafetyMultiplier: 1.5,
|
||||||
|
lowMemoryDeviceGiB: 2,
|
||||||
|
lowMemoryExportFraction: 0.08,
|
||||||
|
rowAlignmentBytes: 256,
|
||||||
|
width: 3840,
|
||||||
|
},
|
||||||
|
menuHider: {
|
||||||
|
bottomRevealDistancePx: 96,
|
||||||
|
intervalMs: 50,
|
||||||
|
timeToLiveMs: 3500,
|
||||||
|
},
|
||||||
|
pipelines: {
|
||||||
|
brush: {
|
||||||
|
maxLineCount: 240,
|
||||||
|
},
|
||||||
|
diffusion: {
|
||||||
|
minDiffusionRate: 0.000001,
|
||||||
|
},
|
||||||
|
eraser: {
|
||||||
|
maxSegmentCount: 384,
|
||||||
|
maxTextureLineCount: 384,
|
||||||
|
segmentFloatCount: 4,
|
||||||
|
workgroupSize: 64,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
runtimeSettings: {
|
||||||
|
defaults: {
|
||||||
|
agentBudgetMax: 1_000_000,
|
||||||
|
agentCount: 0,
|
||||||
|
selectedColorIndex: 0,
|
||||||
|
spawnPerPixel: 0.22,
|
||||||
|
|
||||||
|
moveSpeed: 82,
|
||||||
|
turnSpeed: 58,
|
||||||
|
sensorOffsetAngle: 34,
|
||||||
|
sensorOffsetDistance: 38,
|
||||||
|
turnWhenLost: 0.8,
|
||||||
|
|
||||||
|
individualTrailWeight: 0.07,
|
||||||
|
|
||||||
|
diffusionRateTrails: 0.22,
|
||||||
|
decayRateTrails: 965,
|
||||||
|
diffusionRateBrush: 0.35,
|
||||||
|
decayRateBrush: 18,
|
||||||
|
brushEffectDuration: 8,
|
||||||
|
|
||||||
|
clarity: 0.62,
|
||||||
|
brushSize: 14,
|
||||||
|
eraserSize: 96,
|
||||||
|
mirrorSegmentCount: 1,
|
||||||
|
|
||||||
|
brushSizeVariation: 0.5,
|
||||||
|
|
||||||
|
startColorHue: 200,
|
||||||
|
|
||||||
|
renderSpeed: 1,
|
||||||
|
simulatedDelayMs: 0,
|
||||||
|
},
|
||||||
|
controls: {
|
||||||
|
agentBudgetMax: {
|
||||||
|
folder: 'Runtime',
|
||||||
|
integer: true,
|
||||||
|
min: 1_000,
|
||||||
|
max: 1_000_000,
|
||||||
|
step: 1_000,
|
||||||
|
},
|
||||||
|
agentCount: {
|
||||||
|
folder: 'Runtime',
|
||||||
|
integer: true,
|
||||||
|
min: 0,
|
||||||
|
max: 1_000_000,
|
||||||
|
step: 1_000,
|
||||||
|
},
|
||||||
|
brushEffectDuration: {
|
||||||
|
folder: 'Diffusion',
|
||||||
|
min: 0.5,
|
||||||
|
max: 20,
|
||||||
|
step: 0.05,
|
||||||
|
},
|
||||||
|
brushSize: {
|
||||||
|
folder: 'Brush',
|
||||||
|
min: 1,
|
||||||
|
max: 60,
|
||||||
|
step: 0.25,
|
||||||
|
},
|
||||||
|
brushSizeVariation: {
|
||||||
|
folder: 'Brush',
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
step: 0.01,
|
||||||
|
},
|
||||||
|
clarity: {
|
||||||
|
folder: 'Render',
|
||||||
|
min: 0.00001,
|
||||||
|
max: 1,
|
||||||
|
step: 0.001,
|
||||||
|
},
|
||||||
|
decayRateBrush: {
|
||||||
|
folder: 'Diffusion',
|
||||||
|
min: 0.1,
|
||||||
|
max: 100,
|
||||||
|
step: 0.1,
|
||||||
|
},
|
||||||
|
decayRateTrails: {
|
||||||
|
folder: 'Diffusion',
|
||||||
|
min: 0.1,
|
||||||
|
max: 5000,
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
|
diffusionRateBrush: {
|
||||||
|
folder: 'Diffusion',
|
||||||
|
min: 0.001,
|
||||||
|
max: 1,
|
||||||
|
step: 0.001,
|
||||||
|
},
|
||||||
|
diffusionRateTrails: {
|
||||||
|
folder: 'Diffusion',
|
||||||
|
min: 0,
|
||||||
|
max: 2,
|
||||||
|
step: 0.001,
|
||||||
|
},
|
||||||
|
eraserSize: {
|
||||||
|
folder: 'Brush',
|
||||||
|
integer: true,
|
||||||
|
min: 24,
|
||||||
|
max: 240,
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
|
individualTrailWeight: {
|
||||||
|
folder: 'Agent',
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
step: 0.001,
|
||||||
|
},
|
||||||
|
mirrorSegmentCount: {
|
||||||
|
folder: 'Brush',
|
||||||
|
integer: true,
|
||||||
|
min: 1,
|
||||||
|
max: 12,
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
|
moveSpeed: {
|
||||||
|
folder: 'Agent',
|
||||||
|
min: 10,
|
||||||
|
max: 500,
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
|
renderSpeed: {
|
||||||
|
folder: 'Runtime',
|
||||||
|
integer: true,
|
||||||
|
min: 1,
|
||||||
|
max: 10,
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
|
selectedColorIndex: {
|
||||||
|
folder: 'Brush',
|
||||||
|
integer: true,
|
||||||
|
min: 0,
|
||||||
|
max: 2,
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
|
sensorOffsetAngle: {
|
||||||
|
folder: 'Agent',
|
||||||
|
min: 0,
|
||||||
|
max: 90,
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
|
sensorOffsetDistance: {
|
||||||
|
folder: 'Agent',
|
||||||
|
min: 0,
|
||||||
|
max: 200,
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
|
simulatedDelayMs: {
|
||||||
|
folder: 'Runtime',
|
||||||
|
integer: true,
|
||||||
|
min: 0,
|
||||||
|
max: 2000,
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
|
spawnPerPixel: {
|
||||||
|
folder: 'Agent',
|
||||||
|
min: 0.01,
|
||||||
|
max: 1,
|
||||||
|
step: 0.001,
|
||||||
|
},
|
||||||
|
startColorHue: {
|
||||||
|
folder: 'Render',
|
||||||
|
min: 0,
|
||||||
|
max: 360,
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
|
turnSpeed: {
|
||||||
|
folder: 'Agent',
|
||||||
|
min: 1,
|
||||||
|
max: 200,
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
|
turnWhenLost: {
|
||||||
|
folder: 'Agent',
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
step: 0.001,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
simulation: {
|
||||||
|
budget: {
|
||||||
|
fpsHeadroom: 0.82,
|
||||||
|
fpsSmoothingNew: 0.06,
|
||||||
|
fpsSmoothingRetain: 0.94,
|
||||||
|
initialTargetAgentBudget: 20_000,
|
||||||
|
rampAgentsPerSecond: 20_000,
|
||||||
|
refreshTargetDecay: 0.995,
|
||||||
|
},
|
||||||
|
brushEffectFramesPerSecond: 60,
|
||||||
|
globalAgentCap: 1_000_000,
|
||||||
|
initialAgentCount: 180_000,
|
||||||
|
intro: {
|
||||||
|
angleJitterRadians: Math.PI * 0.08,
|
||||||
|
circleMaxSideRatio: 0.46,
|
||||||
|
circleMinSideRatio: 0.32,
|
||||||
|
drawHintClass: 'draw-hint',
|
||||||
|
drawHintDelayMs: 3000,
|
||||||
|
durationSeconds: 4,
|
||||||
|
entryJitterSideRatio: 0.035,
|
||||||
|
fontScaleDown: 0.94,
|
||||||
|
initialFontHeightRatio: 0.28,
|
||||||
|
initialFontWidthRatio: 0.19,
|
||||||
|
letterSpacingEm: 0.07,
|
||||||
|
maskAlphaThreshold: 32,
|
||||||
|
maskGradientThreshold: 8,
|
||||||
|
maskSampleDensity: 540,
|
||||||
|
maxHeightRatio: 0.25,
|
||||||
|
maxWidthRatio: 0.76,
|
||||||
|
minEntryJitterPx: 6,
|
||||||
|
minFontSizePx: 18,
|
||||||
|
minTargetJitterPx: 1,
|
||||||
|
radialJitterRatio: 0.35,
|
||||||
|
targetDelayDistanceMultiplier: 0.12,
|
||||||
|
targetDelayMax: 0.22,
|
||||||
|
targetDelayRandomMultiplier: 0.06,
|
||||||
|
targetJitterSideRatio: 0.0035,
|
||||||
|
title: 'Fleeting',
|
||||||
|
titleColorCutLetters: [2, 5],
|
||||||
|
titleRadiusMultiplier: 1.55,
|
||||||
|
titleStrokeWidthMinPx: 6,
|
||||||
|
titleStrokeWidthRatio: 0.11,
|
||||||
|
verticalAnchor: 0.47,
|
||||||
|
},
|
||||||
|
introCameraZoom: 0.12,
|
||||||
|
introMoveSpeedBaseMultiplier: 1.8,
|
||||||
|
introMoveSpeedProgressMultiplier: 0.35,
|
||||||
|
maxMirrorSegmentCount: 12,
|
||||||
|
stroke: {
|
||||||
|
angleJitterRadians: Math.PI * 0.7,
|
||||||
|
densityMultiplier: 110,
|
||||||
|
maxAgentCount: 2_400,
|
||||||
|
minAgentCount: 140,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
storage: {
|
||||||
|
audioMutedKey: 'fleeting-garden:audio-muted',
|
||||||
|
vibeKey: 'fleeting-garden:vibe',
|
||||||
|
},
|
||||||
|
telemetry: {
|
||||||
|
enabled: false,
|
||||||
|
intervalMs: 1000,
|
||||||
|
},
|
||||||
|
toolbar: {
|
||||||
|
eraser: {
|
||||||
|
controlScaleMax: 1.34,
|
||||||
|
controlScaleMin: 0.74,
|
||||||
|
default: 96,
|
||||||
|
max: 240,
|
||||||
|
min: 24,
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
|
mirror: {
|
||||||
|
default: 1,
|
||||||
|
max: 12,
|
||||||
|
min: 1,
|
||||||
|
names: {
|
||||||
|
2: 'halves',
|
||||||
|
3: 'thirds',
|
||||||
|
4: 'quarters',
|
||||||
|
5: 'fifths',
|
||||||
|
6: 'sixths',
|
||||||
|
7: 'sevenths',
|
||||||
|
8: 'eighths',
|
||||||
|
9: 'ninths',
|
||||||
|
10: 'tenths',
|
||||||
|
11: 'elevenths',
|
||||||
|
12: 'twelfths',
|
||||||
|
},
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tuningPane: {
|
||||||
|
expandedDepth: 1,
|
||||||
|
startHidden: true,
|
||||||
|
title: 'Garden Config',
|
||||||
|
},
|
||||||
|
vibes: {
|
||||||
|
defaultVibeId,
|
||||||
|
presets: vibePresets,
|
||||||
|
},
|
||||||
|
};
|
||||||
181
src/game-loop/agent-population.ts
Normal file
181
src/game-loop/agent-population.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
|
import { appConfig } from '../config';
|
||||||
|
import { AGENT_FLOAT_COUNT } from '../pipelines/agents/agent-generation/agent';
|
||||||
|
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
|
||||||
|
import { settings } from '../settings';
|
||||||
|
import { createIntroTitleAgents } from './intro-title-agents';
|
||||||
|
|
||||||
|
export const GLOBAL_AGENT_CAP = appConfig.simulation.globalAgentCap;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
export class AgentPopulation {
|
||||||
|
private activeCount = 0;
|
||||||
|
private targetBudget = appConfig.simulation.budget.initialTargetAgentBudget;
|
||||||
|
private replacementCursor = 0;
|
||||||
|
private shouldCompactAfterErase = false;
|
||||||
|
private isCompacting = false;
|
||||||
|
private readonly strokeAgentData = new Float32Array(
|
||||||
|
MAX_STROKE_AGENT_COUNT * AGENT_FLOAT_COUNT
|
||||||
|
);
|
||||||
|
|
||||||
|
public constructor(private readonly pipeline: AgentGenerationPipeline) {}
|
||||||
|
|
||||||
|
public get activeAgentCount(): number {
|
||||||
|
return this.activeCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get targetAgentBudget(): number {
|
||||||
|
return this.targetBudget;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get maxAgentCount(): number {
|
||||||
|
return this.pipeline.maxAgentCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public initializeIntroAgents(canvasSize: vec2): void {
|
||||||
|
this.targetBudget = Math.min(
|
||||||
|
this.pipeline.maxAgentCount,
|
||||||
|
settings.agentBudgetMax,
|
||||||
|
INITIAL_AGENT_COUNT
|
||||||
|
);
|
||||||
|
this.writeAgentBatch(
|
||||||
|
createIntroTitleAgents({
|
||||||
|
count: this.targetBudget,
|
||||||
|
width: canvasSize[0],
|
||||||
|
height: canvasSize[1],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public onVibeChanged(): void {
|
||||||
|
this.targetBudget = Math.min(
|
||||||
|
this.targetBudget,
|
||||||
|
settings.agentBudgetMax,
|
||||||
|
this.pipeline.maxAgentCount
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public growBudget(
|
||||||
|
deltaTime: number,
|
||||||
|
smoothedFps: number,
|
||||||
|
refreshTargetFps: number
|
||||||
|
): void {
|
||||||
|
const cap = Math.min(settings.agentBudgetMax, this.pipeline.maxAgentCount);
|
||||||
|
if (
|
||||||
|
this.targetBudget < cap &&
|
||||||
|
smoothedFps > refreshTargetFps * appConfig.simulation.budget.fpsHeadroom
|
||||||
|
) {
|
||||||
|
this.targetBudget = Math.min(
|
||||||
|
cap,
|
||||||
|
this.targetBudget +
|
||||||
|
Math.ceil(appConfig.simulation.budget.rampAgentsPerSecond * deltaTime)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public resizeAgents(scale: vec2): void {
|
||||||
|
this.pipeline.resizeAgents(this.activeCount, scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
public requestCompactionAfterErase(): void {
|
||||||
|
this.shouldCompactAfterErase = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async compactAfterErase(isSwipeActive: boolean): Promise<void> {
|
||||||
|
if (!this.shouldCompactAfterErase || this.isCompacting || isSwipeActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.shouldCompactAfterErase = false;
|
||||||
|
if (this.activeCount === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isCompacting = true;
|
||||||
|
try {
|
||||||
|
const compactedAgentCount = await this.pipeline.compactAgents(this.activeCount);
|
||||||
|
this.activeCount = compactedAgentCount;
|
||||||
|
this.replacementCursor =
|
||||||
|
compactedAgentCount === 0 ? 0 : this.replacementCursor % compactedAgentCount;
|
||||||
|
this.targetBudget = Math.max(this.targetBudget, compactedAgentCount);
|
||||||
|
} finally {
|
||||||
|
this.isCompacting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public spawnStrokeAgents(from: vec2, to: vec2): void {
|
||||||
|
const length = Math.max(1, vec2.dist(from, to));
|
||||||
|
const count = Math.max(
|
||||||
|
MIN_STROKE_AGENT_COUNT,
|
||||||
|
Math.min(
|
||||||
|
MAX_STROKE_AGENT_COUNT,
|
||||||
|
Math.ceil(length * settings.spawnPerPixel * STROKE_AGENT_DENSITY_MULTIPLIER)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const direction = vec2.sub(vec2.create(), to, from);
|
||||||
|
const baseAngle = Math.atan2(direction[1], direction[0]);
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const t = count === 1 ? 1 : i / (count - 1);
|
||||||
|
const x = from[0] + (to[0] - from[0]) * t;
|
||||||
|
const y = from[1] + (to[1] - from[1]) * t;
|
||||||
|
const angle =
|
||||||
|
(Number.isFinite(baseAngle) ? baseAngle : Math.random() * Math.PI * 2) +
|
||||||
|
(Math.random() - 0.5) * appConfig.simulation.stroke.angleJitterRadians;
|
||||||
|
const base = i * AGENT_FLOAT_COUNT;
|
||||||
|
this.strokeAgentData[base] = x + (Math.random() - 0.5) * settings.brushSize;
|
||||||
|
this.strokeAgentData[base + 1] = y + (Math.random() - 0.5) * settings.brushSize;
|
||||||
|
this.strokeAgentData[base + 2] = angle;
|
||||||
|
this.strokeAgentData[base + 3] = settings.selectedColorIndex;
|
||||||
|
this.strokeAgentData[base + 4] = -1;
|
||||||
|
this.strokeAgentData[base + 5] = -1;
|
||||||
|
this.strokeAgentData[base + 6] = angle;
|
||||||
|
this.strokeAgentData[base + 7] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.writeAgentBatch(this.strokeAgentData.subarray(0, count * AGENT_FLOAT_COUNT));
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeAgentBatch(data: Float32Array): void {
|
||||||
|
if (data.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = data.length / AGENT_FLOAT_COUNT;
|
||||||
|
const available = Math.max(0, this.targetBudget - this.activeCount);
|
||||||
|
const appendCount = Math.min(count, available);
|
||||||
|
|
||||||
|
if (appendCount > 0) {
|
||||||
|
this.pipeline.writeAgents(
|
||||||
|
this.activeCount,
|
||||||
|
data.subarray(0, appendCount * AGENT_FLOAT_COUNT)
|
||||||
|
);
|
||||||
|
this.activeCount += appendCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
let sourceAgentOffset = appendCount;
|
||||||
|
while (sourceAgentOffset < count && this.activeCount > 0) {
|
||||||
|
const targetAgentOffset = this.replacementCursor % this.activeCount;
|
||||||
|
const chunkAgentCount = Math.min(
|
||||||
|
count - sourceAgentOffset,
|
||||||
|
this.activeCount - targetAgentOffset
|
||||||
|
);
|
||||||
|
|
||||||
|
this.pipeline.writeAgents(
|
||||||
|
targetAgentOffset,
|
||||||
|
data.subarray(
|
||||||
|
sourceAgentOffset * AGENT_FLOAT_COUNT,
|
||||||
|
(sourceAgentOffset + chunkAgentCount) * AGENT_FLOAT_COUNT
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
sourceAgentOffset += chunkAgentCount;
|
||||||
|
this.replacementCursor = (targetAgentOffset + chunkAgentCount) % this.activeCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/game-loop/eraser-preview.ts
Normal file
80
src/game-loop/eraser-preview.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import { settings } from '../settings';
|
||||||
|
|
||||||
|
export class EraserPreview {
|
||||||
|
private previewClientPosition: { x: number; y: number } | null = null;
|
||||||
|
private isErasing = false;
|
||||||
|
private isPointerHoveringCanvas = false;
|
||||||
|
private previousSize: number | null = null;
|
||||||
|
private previousLeft = '';
|
||||||
|
private previousTop = '';
|
||||||
|
private isVisible = false;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly canvas: HTMLCanvasElement,
|
||||||
|
private readonly element: HTMLElement
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public setEraseMode(isErasing: boolean, isSwipeActive: boolean): void {
|
||||||
|
this.isErasing = isErasing;
|
||||||
|
this.update(undefined, isSwipeActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
public setPointerHoveringCanvas(isHovering: boolean): void {
|
||||||
|
this.isPointerHoveringCanvas = isHovering;
|
||||||
|
}
|
||||||
|
|
||||||
|
public update(event?: PointerEvent, isSwipeActive = false): void {
|
||||||
|
if (event) {
|
||||||
|
this.previewClientPosition = {
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.previousSize !== settings.eraserSize) {
|
||||||
|
this.element.style.setProperty('--eraser-preview-size', `${settings.eraserSize}px`);
|
||||||
|
this.previousSize = settings.eraserSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!this.isErasing ||
|
||||||
|
this.previewClientPosition === null ||
|
||||||
|
(!this.isPointerHoveringCanvas && !isSwipeActive)
|
||||||
|
) {
|
||||||
|
this.setVisible(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
|
const left = `${this.previewClientPosition.x - rect.left}px`;
|
||||||
|
const top = `${this.previewClientPosition.y - rect.top}px`;
|
||||||
|
if (this.previousLeft !== left) {
|
||||||
|
this.element.style.left = left;
|
||||||
|
this.previousLeft = left;
|
||||||
|
}
|
||||||
|
if (this.previousTop !== top) {
|
||||||
|
this.element.style.top = top;
|
||||||
|
this.previousTop = top;
|
||||||
|
}
|
||||||
|
this.setVisible(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public isPointerInsideCanvas(event: PointerEvent): boolean {
|
||||||
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
|
return (
|
||||||
|
event.clientX >= rect.left &&
|
||||||
|
event.clientX <= rect.right &&
|
||||||
|
event.clientY >= rect.top &&
|
||||||
|
event.clientY <= rect.bottom
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setVisible(isVisible: boolean): void {
|
||||||
|
if (this.isVisible === isVisible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isVisible = isVisible;
|
||||||
|
this.element.classList.toggle('visible', isVisible);
|
||||||
|
}
|
||||||
|
}
|
||||||
194
src/game-loop/export-4k-renderer.ts
Normal file
194
src/game-loop/export-4k-renderer.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
import { RenderPipeline } from '../pipelines/render/render-pipeline';
|
||||||
|
import {
|
||||||
|
estimateExport4KMemory,
|
||||||
|
getAspectFitExport4KDimensions,
|
||||||
|
getBrowserExportMemoryInfo,
|
||||||
|
getExport4KPreflightError,
|
||||||
|
} from './export-4k';
|
||||||
|
|
||||||
|
interface Export4KRendererOptions {
|
||||||
|
device: GPUDevice;
|
||||||
|
renderPipeline: RenderPipeline;
|
||||||
|
statusElement: HTMLElement;
|
||||||
|
seed: string;
|
||||||
|
getSourceSize: () => { width: number; height: number };
|
||||||
|
getColorTextureView: () => GPUTextureView;
|
||||||
|
getSourceTextureView: () => GPUTextureView;
|
||||||
|
getVibeId: () => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Export4KRenderer {
|
||||||
|
private isExporting = false;
|
||||||
|
|
||||||
|
public constructor(private readonly options: Export4KRendererOptions) {}
|
||||||
|
|
||||||
|
public async export(): Promise<void> {
|
||||||
|
if (this.isExporting) {
|
||||||
|
this.statusElement.textContent = '4K upscale already rendering...';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isExporting = true;
|
||||||
|
this.statusElement.textContent = 'Rendering 4K upscale...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sourceSize = this.options.getSourceSize();
|
||||||
|
const exportDimensions = getAspectFitExport4KDimensions(
|
||||||
|
sourceSize.width,
|
||||||
|
sourceSize.height
|
||||||
|
);
|
||||||
|
const estimate = estimateExport4KMemory(
|
||||||
|
exportDimensions.width,
|
||||||
|
exportDimensions.height
|
||||||
|
);
|
||||||
|
const preflightError = getExport4KPreflightError({
|
||||||
|
limits: this.device.limits,
|
||||||
|
memoryInfo: getBrowserExportMemoryInfo(),
|
||||||
|
estimate,
|
||||||
|
});
|
||||||
|
if (preflightError) {
|
||||||
|
this.statusElement.textContent = '4K upscale unavailable';
|
||||||
|
throw preflightError;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.renderExport(estimate);
|
||||||
|
this.statusElement.textContent = '';
|
||||||
|
} finally {
|
||||||
|
this.isExporting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async renderExport(
|
||||||
|
estimate: ReturnType<typeof estimateExport4KMemory>
|
||||||
|
): Promise<void> {
|
||||||
|
const { width, height, unpaddedBytesPerRow, bytesPerRow } = estimate;
|
||||||
|
const format = navigator.gpu.getPreferredCanvasFormat();
|
||||||
|
let texture: GPUTexture | null = null;
|
||||||
|
let output: GPUBuffer | null = null;
|
||||||
|
let isOutputMapped = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
texture = this.device.createTexture({
|
||||||
|
size: { width, height },
|
||||||
|
format,
|
||||||
|
usage:
|
||||||
|
GPUTextureUsage.RENDER_ATTACHMENT |
|
||||||
|
GPUTextureUsage.COPY_SRC |
|
||||||
|
GPUTextureUsage.TEXTURE_BINDING,
|
||||||
|
});
|
||||||
|
output = this.device.createBuffer({
|
||||||
|
size: estimate.readbackBufferBytes,
|
||||||
|
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
|
||||||
|
});
|
||||||
|
|
||||||
|
const commandEncoder = this.device.createCommandEncoder();
|
||||||
|
this.options.renderPipeline.executeToView(
|
||||||
|
commandEncoder,
|
||||||
|
this.options.getColorTextureView(),
|
||||||
|
this.options.getSourceTextureView(),
|
||||||
|
texture.createView()
|
||||||
|
);
|
||||||
|
commandEncoder.copyTextureToBuffer(
|
||||||
|
{ texture },
|
||||||
|
{ buffer: output, bytesPerRow, rowsPerImage: height },
|
||||||
|
{ width, height }
|
||||||
|
);
|
||||||
|
this.device.queue.submit([commandEncoder.finish()]);
|
||||||
|
|
||||||
|
await output.mapAsync(GPUMapMode.READ);
|
||||||
|
isOutputMapped = true;
|
||||||
|
const pixels = readExportPixels({
|
||||||
|
mapped: new Uint8Array(output.getMappedRange()),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
unpaddedBytesPerRow,
|
||||||
|
bytesPerRow,
|
||||||
|
isBgra: format === 'bgra8unorm',
|
||||||
|
});
|
||||||
|
output.unmap();
|
||||||
|
isOutputMapped = false;
|
||||||
|
output.destroy();
|
||||||
|
output = null;
|
||||||
|
texture.destroy();
|
||||||
|
texture = null;
|
||||||
|
|
||||||
|
await this.downloadPixels(pixels, width, height);
|
||||||
|
} catch (error) {
|
||||||
|
this.statusElement.textContent = '4K upscale failed';
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (output && isOutputMapped) {
|
||||||
|
output.unmap();
|
||||||
|
}
|
||||||
|
output?.destroy();
|
||||||
|
texture?.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async downloadPixels(
|
||||||
|
pixels: Uint8ClampedArray<ArrayBuffer>,
|
||||||
|
width: number,
|
||||||
|
height: number
|
||||||
|
): Promise<void> {
|
||||||
|
const canvas = new OffscreenCanvas(width, height);
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('Could not create export canvas');
|
||||||
|
}
|
||||||
|
context.putImageData(new ImageData(pixels, width, height), 0, 0);
|
||||||
|
const blob = await canvas.convertToBlob({ type: 'image/png' });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
try {
|
||||||
|
link.href = objectUrl;
|
||||||
|
link.download = `fleeting-garden_${this.options.getVibeId()}_${
|
||||||
|
this.options.seed
|
||||||
|
}_${width}x${height}-upscale.png`;
|
||||||
|
link.click();
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private get device(): GPUDevice {
|
||||||
|
return this.options.device;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get statusElement(): HTMLElement {
|
||||||
|
return this.options.statusElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const readExportPixels = ({
|
||||||
|
mapped,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
unpaddedBytesPerRow,
|
||||||
|
bytesPerRow,
|
||||||
|
isBgra,
|
||||||
|
}: {
|
||||||
|
mapped: Uint8Array;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
unpaddedBytesPerRow: number;
|
||||||
|
bytesPerRow: number;
|
||||||
|
isBgra: boolean;
|
||||||
|
}): Uint8ClampedArray<ArrayBuffer> => {
|
||||||
|
const pixels: Uint8ClampedArray<ArrayBuffer> = new Uint8ClampedArray(
|
||||||
|
unpaddedBytesPerRow * height
|
||||||
|
);
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
const sourceOffset = y * bytesPerRow;
|
||||||
|
const targetOffset = y * unpaddedBytesPerRow;
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
const source = sourceOffset + x * 4;
|
||||||
|
const target = targetOffset + x * 4;
|
||||||
|
pixels[target] = isBgra ? mapped[source + 2] : mapped[source];
|
||||||
|
pixels[target + 1] = mapped[source + 1];
|
||||||
|
pixels[target + 2] = isBgra ? mapped[source] : mapped[source + 2];
|
||||||
|
pixels[target + 3] = mapped[source + 3];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pixels;
|
||||||
|
};
|
||||||
87
src/game-loop/export-4k.test.ts
Normal file
87
src/game-loop/export-4k.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
estimateExport4KMemory,
|
||||||
|
formatByteSize,
|
||||||
|
getAspectFitExport4KDimensions,
|
||||||
|
getExport4KPreflightError,
|
||||||
|
} from './export-4k';
|
||||||
|
|
||||||
|
const generousLimits = {
|
||||||
|
maxBufferSize: Number.MAX_SAFE_INTEGER,
|
||||||
|
maxTextureDimension2D: Number.MAX_SAFE_INTEGER,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('4K export preflight', () => {
|
||||||
|
it('fits export dimensions inside 4K while preserving source aspect ratio', () => {
|
||||||
|
expect(getAspectFitExport4KDimensions(3840, 2160)).toEqual({
|
||||||
|
width: 3840,
|
||||||
|
height: 2160,
|
||||||
|
});
|
||||||
|
expect(getAspectFitExport4KDimensions(800, 600)).toEqual({
|
||||||
|
width: 2880,
|
||||||
|
height: 2160,
|
||||||
|
});
|
||||||
|
expect(getAspectFitExport4KDimensions(600, 800)).toEqual({
|
||||||
|
width: 1620,
|
||||||
|
height: 2160,
|
||||||
|
});
|
||||||
|
expect(getAspectFitExport4KDimensions(1000, 1000)).toEqual({
|
||||||
|
width: 2160,
|
||||||
|
height: 2160,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('estimates padded readback and temporary memory for the export', () => {
|
||||||
|
const estimate = estimateExport4KMemory();
|
||||||
|
|
||||||
|
expect(estimate.width).toBe(3840);
|
||||||
|
expect(estimate.height).toBe(2160);
|
||||||
|
expect(estimate.bytesPerRow % 256).toBe(0);
|
||||||
|
expect(estimate.estimatedPeakBytes).toBeGreaterThan(estimate.textureBytes);
|
||||||
|
expect(formatByteSize(estimate.estimatedPeakBytes)).toMatch(/MiB$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects GPUs that cannot allocate the export texture', () => {
|
||||||
|
const error = getExport4KPreflightError({
|
||||||
|
limits: {
|
||||||
|
maxBufferSize: Number.MAX_SAFE_INTEGER,
|
||||||
|
maxTextureDimension2D: 2048,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(error?.code).toBe('export-4k-texture-too-large');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects GPUs that cannot allocate the readback buffer', () => {
|
||||||
|
const estimate = estimateExport4KMemory();
|
||||||
|
const error = getExport4KPreflightError({
|
||||||
|
limits: {
|
||||||
|
maxBufferSize: estimate.readbackBufferBytes - 1,
|
||||||
|
maxTextureDimension2D: Number.MAX_SAFE_INTEGER,
|
||||||
|
},
|
||||||
|
estimate,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(error?.code).toBe('export-4k-readback-too-large');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects browser-reported low-memory devices', () => {
|
||||||
|
const error = getExport4KPreflightError({
|
||||||
|
limits: generousLimits,
|
||||||
|
memoryInfo: {
|
||||||
|
deviceMemoryBytes: 2 * 1024 ** 3,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(error?.code).toBe('export-4k-low-device-memory');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows export when memory hints are unavailable', () => {
|
||||||
|
expect(
|
||||||
|
getExport4KPreflightError({
|
||||||
|
limits: generousLimits,
|
||||||
|
})
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
222
src/game-loop/export-4k.ts
Normal file
222
src/game-loop/export-4k.ts
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
import { appConfig } from '../config';
|
||||||
|
import { RuntimeError } from '../utils/error-handler';
|
||||||
|
|
||||||
|
export const EXPORT_4K_WIDTH = appConfig.export4k.width;
|
||||||
|
export const EXPORT_4K_HEIGHT = appConfig.export4k.height;
|
||||||
|
|
||||||
|
const BYTES_PER_PIXEL = appConfig.export4k.bytesPerPixel;
|
||||||
|
const ROW_ALIGNMENT_BYTES = appConfig.export4k.rowAlignmentBytes;
|
||||||
|
const GIBIBYTE = 1024 ** 3;
|
||||||
|
const LOW_MEMORY_DEVICE_GIB = appConfig.export4k.lowMemoryDeviceGiB;
|
||||||
|
const LOW_MEMORY_EXPORT_FRACTION = appConfig.export4k.lowMemoryExportFraction;
|
||||||
|
const JS_HEAP_SAFETY_MULTIPLIER = appConfig.export4k.jsHeapSafetyMultiplier;
|
||||||
|
|
||||||
|
export interface Export4KMemoryEstimate {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
bytesPerPixel: number;
|
||||||
|
unpaddedBytesPerRow: number;
|
||||||
|
bytesPerRow: number;
|
||||||
|
textureBytes: number;
|
||||||
|
readbackBufferBytes: number;
|
||||||
|
pixelBytes: number;
|
||||||
|
canvasBytes: number;
|
||||||
|
encoderSafetyBytes: number;
|
||||||
|
estimatedJsHeapBytes: number;
|
||||||
|
estimatedPeakBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Export4KDimensions {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrowserMemoryInfo {
|
||||||
|
deviceMemoryBytes?: number;
|
||||||
|
jsHeapSizeLimitBytes?: number;
|
||||||
|
usedJsHeapSizeBytes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Export4KPreflightOptions {
|
||||||
|
limits: Pick<GPUSupportedLimits, 'maxBufferSize' | 'maxTextureDimension2D'>;
|
||||||
|
memoryInfo?: BrowserMemoryInfo;
|
||||||
|
estimate?: Export4KMemoryEstimate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const alignTo = (value: number, alignment: number): number =>
|
||||||
|
Math.ceil(value / alignment) * alignment;
|
||||||
|
|
||||||
|
const getPositiveFiniteNumber = (value: unknown): number | undefined =>
|
||||||
|
typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined;
|
||||||
|
|
||||||
|
export const formatByteSize = (bytes: number): string =>
|
||||||
|
`${Math.ceil(bytes / 1024 / 1024)} MiB`;
|
||||||
|
|
||||||
|
export const getAspectFitExport4KDimensions = (
|
||||||
|
sourceWidth: number,
|
||||||
|
sourceHeight: number,
|
||||||
|
maxWidth = EXPORT_4K_WIDTH,
|
||||||
|
maxHeight = EXPORT_4K_HEIGHT
|
||||||
|
): Export4KDimensions => {
|
||||||
|
if (
|
||||||
|
!Number.isFinite(sourceWidth) ||
|
||||||
|
!Number.isFinite(sourceHeight) ||
|
||||||
|
sourceWidth <= 0 ||
|
||||||
|
sourceHeight <= 0
|
||||||
|
) {
|
||||||
|
return { width: maxWidth, height: maxHeight };
|
||||||
|
}
|
||||||
|
|
||||||
|
const scale = Math.min(maxWidth / sourceWidth, maxHeight / sourceHeight);
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: Math.min(maxWidth, Math.max(1, Math.round(sourceWidth * scale))),
|
||||||
|
height: Math.min(maxHeight, Math.max(1, Math.round(sourceHeight * scale))),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const estimateExport4KMemory = (
|
||||||
|
width = EXPORT_4K_WIDTH,
|
||||||
|
height = EXPORT_4K_HEIGHT
|
||||||
|
): Export4KMemoryEstimate => {
|
||||||
|
const unpaddedBytesPerRow = width * BYTES_PER_PIXEL;
|
||||||
|
const bytesPerRow = alignTo(unpaddedBytesPerRow, ROW_ALIGNMENT_BYTES);
|
||||||
|
const textureBytes = unpaddedBytesPerRow * height;
|
||||||
|
const readbackBufferBytes = bytesPerRow * height;
|
||||||
|
const pixelBytes = textureBytes;
|
||||||
|
const canvasBytes = textureBytes;
|
||||||
|
const encoderSafetyBytes = textureBytes * 2;
|
||||||
|
const estimatedJsHeapBytes = pixelBytes + canvasBytes + encoderSafetyBytes;
|
||||||
|
|
||||||
|
return {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
bytesPerPixel: BYTES_PER_PIXEL,
|
||||||
|
unpaddedBytesPerRow,
|
||||||
|
bytesPerRow,
|
||||||
|
textureBytes,
|
||||||
|
readbackBufferBytes,
|
||||||
|
pixelBytes,
|
||||||
|
canvasBytes,
|
||||||
|
encoderSafetyBytes,
|
||||||
|
estimatedJsHeapBytes,
|
||||||
|
estimatedPeakBytes: textureBytes + readbackBufferBytes + estimatedJsHeapBytes,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getBrowserExportMemoryInfo = (): BrowserMemoryInfo => {
|
||||||
|
const navigatorWithMemory =
|
||||||
|
typeof navigator === 'undefined'
|
||||||
|
? undefined
|
||||||
|
: (navigator as Navigator & { deviceMemory?: number });
|
||||||
|
const performanceWithMemory =
|
||||||
|
typeof performance === 'undefined'
|
||||||
|
? undefined
|
||||||
|
: (performance as Performance & {
|
||||||
|
memory?: {
|
||||||
|
jsHeapSizeLimit?: number;
|
||||||
|
usedJSHeapSize?: number;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const deviceMemoryGib = getPositiveFiniteNumber(navigatorWithMemory?.deviceMemory);
|
||||||
|
const jsHeapSizeLimitBytes = getPositiveFiniteNumber(
|
||||||
|
performanceWithMemory?.memory?.jsHeapSizeLimit
|
||||||
|
);
|
||||||
|
const usedJsHeapSizeBytes = getPositiveFiniteNumber(
|
||||||
|
performanceWithMemory?.memory?.usedJSHeapSize
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(deviceMemoryGib === undefined
|
||||||
|
? {}
|
||||||
|
: { deviceMemoryBytes: deviceMemoryGib * GIBIBYTE }),
|
||||||
|
...(jsHeapSizeLimitBytes === undefined ? {} : { jsHeapSizeLimitBytes }),
|
||||||
|
...(usedJsHeapSizeBytes === undefined ? {} : { usedJsHeapSizeBytes }),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getExport4KPreflightError = ({
|
||||||
|
limits,
|
||||||
|
memoryInfo = {},
|
||||||
|
estimate = estimateExport4KMemory(),
|
||||||
|
}: Export4KPreflightOptions): RuntimeError | null => {
|
||||||
|
if (
|
||||||
|
estimate.width > limits.maxTextureDimension2D ||
|
||||||
|
estimate.height > limits.maxTextureDimension2D
|
||||||
|
) {
|
||||||
|
return new RuntimeError(
|
||||||
|
'export-4k-texture-too-large',
|
||||||
|
'This GPU cannot create a 3840x2160 export texture.',
|
||||||
|
{
|
||||||
|
details: {
|
||||||
|
exportWidth: estimate.width,
|
||||||
|
exportHeight: estimate.height,
|
||||||
|
maxTextureDimension2D: limits.maxTextureDimension2D,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (estimate.readbackBufferBytes > limits.maxBufferSize) {
|
||||||
|
return new RuntimeError(
|
||||||
|
'export-4k-readback-too-large',
|
||||||
|
'This GPU cannot allocate the 4K export readback buffer.',
|
||||||
|
{
|
||||||
|
details: {
|
||||||
|
readbackBufferBytes: estimate.readbackBufferBytes,
|
||||||
|
maxBufferSize: limits.maxBufferSize,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
memoryInfo.deviceMemoryBytes !== undefined &&
|
||||||
|
memoryInfo.deviceMemoryBytes <= LOW_MEMORY_DEVICE_GIB * GIBIBYTE &&
|
||||||
|
estimate.estimatedPeakBytes >
|
||||||
|
memoryInfo.deviceMemoryBytes * LOW_MEMORY_EXPORT_FRACTION
|
||||||
|
) {
|
||||||
|
return new RuntimeError(
|
||||||
|
'export-4k-low-device-memory',
|
||||||
|
`4K upscale export needs about ${formatByteSize(
|
||||||
|
estimate.estimatedPeakBytes
|
||||||
|
)} of temporary memory, which is not safe on this low-memory device.`,
|
||||||
|
{
|
||||||
|
details: {
|
||||||
|
deviceMemoryBytes: memoryInfo.deviceMemoryBytes,
|
||||||
|
estimatedPeakBytes: estimate.estimatedPeakBytes,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
memoryInfo.jsHeapSizeLimitBytes !== undefined &&
|
||||||
|
memoryInfo.usedJsHeapSizeBytes !== undefined
|
||||||
|
) {
|
||||||
|
const availableJsHeapBytes =
|
||||||
|
memoryInfo.jsHeapSizeLimitBytes - memoryInfo.usedJsHeapSizeBytes;
|
||||||
|
if (
|
||||||
|
availableJsHeapBytes <
|
||||||
|
estimate.estimatedJsHeapBytes * JS_HEAP_SAFETY_MULTIPLIER
|
||||||
|
) {
|
||||||
|
return new RuntimeError(
|
||||||
|
'export-4k-low-js-heap',
|
||||||
|
`4K upscale export needs about ${formatByteSize(
|
||||||
|
estimate.estimatedJsHeapBytes
|
||||||
|
)} of JavaScript heap, and this browser does not report enough free heap.`,
|
||||||
|
{
|
||||||
|
details: {
|
||||||
|
availableJsHeapBytes,
|
||||||
|
estimatedJsHeapBytes: estimate.estimatedJsHeapBytes,
|
||||||
|
jsHeapSizeLimitBytes: memoryInfo.jsHeapSizeLimitBytes,
|
||||||
|
usedJsHeapSizeBytes: memoryInfo.usedJsHeapSizeBytes,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
73
src/game-loop/frame-performance.ts
Normal file
73
src/game-loop/frame-performance.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { appConfig } from '../config';
|
||||||
|
|
||||||
|
interface TelemetrySnapshot {
|
||||||
|
frameCpuStartedAt: number;
|
||||||
|
encodeCpuMs: number;
|
||||||
|
activeAgentCount: number;
|
||||||
|
targetAgentBudget: number;
|
||||||
|
canvas: HTMLCanvasElement;
|
||||||
|
devicePixelRatio: number;
|
||||||
|
renderSpeed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FramePerformance {
|
||||||
|
public latestFps = 60;
|
||||||
|
public smoothedFps = 60;
|
||||||
|
public refreshTargetFps = 60;
|
||||||
|
|
||||||
|
private lastTelemetryAt = 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(deltaTime: number): void {
|
||||||
|
const fps = 1 / Math.max(deltaTime, appConfig.deltaTime.minDeltaTimeSeconds);
|
||||||
|
this.latestFps = fps;
|
||||||
|
this.refreshTargetFps = Math.max(
|
||||||
|
this.refreshTargetFps * appConfig.simulation.budget.refreshTargetDecay,
|
||||||
|
fps
|
||||||
|
);
|
||||||
|
this.smoothedFps =
|
||||||
|
this.smoothedFps * appConfig.simulation.budget.fpsSmoothingRetain +
|
||||||
|
fps * appConfig.simulation.budget.fpsSmoothingNew;
|
||||||
|
}
|
||||||
|
|
||||||
|
public renderTelemetry({
|
||||||
|
frameCpuStartedAt,
|
||||||
|
encodeCpuMs,
|
||||||
|
activeAgentCount,
|
||||||
|
targetAgentBudget,
|
||||||
|
canvas,
|
||||||
|
devicePixelRatio,
|
||||||
|
renderSpeed,
|
||||||
|
}: 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),
|
||||||
|
activeAgentCount,
|
||||||
|
targetAgentBudget,
|
||||||
|
canvasWidth: canvas.width,
|
||||||
|
canvasHeight: canvas.height,
|
||||||
|
dpr: devicePixelRatio,
|
||||||
|
renderSpeed,
|
||||||
|
frameCpuMs: now - frameCpuStartedAt,
|
||||||
|
encodeCpuMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/game-loop/game-loop-ping-pong.test.ts
Normal file
50
src/game-loop/game-loop-ping-pong.test.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
const simulationFrameSource = readFileSync(
|
||||||
|
join(process.cwd(), 'src/game-loop/simulation-frame.ts'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
const simulationTexturesSource = readFileSync(
|
||||||
|
join(process.cwd(), 'src/game-loop/simulation-textures.ts'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const getRenderStepSource = () => {
|
||||||
|
const start = simulationFrameSource.indexOf('for (let i = 0; i < renderSpeed; i++)');
|
||||||
|
const end = simulationFrameSource.indexOf(' public clearSwipes', start);
|
||||||
|
|
||||||
|
if (start < 0 || end < 0) {
|
||||||
|
throw new Error('Could not find the render-speed simulation loop');
|
||||||
|
}
|
||||||
|
|
||||||
|
return simulationFrameSource.slice(start, end);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('GameLoop ping-pong texture flow', () => {
|
||||||
|
it('copies only the trail map 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).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 ping-pong texture references mutable and swaps A/B identities', () => {
|
||||||
|
expect(simulationTexturesSource).toContain('public sourceMapA: ResizableTexture;');
|
||||||
|
expect(simulationTexturesSource).toContain('public sourceMapB: ResizableTexture;');
|
||||||
|
expect(simulationTexturesSource).toContain('public influenceMapA: ResizableTexture;');
|
||||||
|
expect(simulationTexturesSource).toContain('public influenceMapB: ResizableTexture;');
|
||||||
|
expect(simulationTexturesSource).toContain(
|
||||||
|
'[this.sourceMapA, this.sourceMapB] = [this.sourceMapB, this.sourceMapA];'
|
||||||
|
);
|
||||||
|
expect(simulationTexturesSource).toContain(
|
||||||
|
'[this.influenceMapA, this.influenceMapB] = [this.influenceMapB, this.influenceMapA];'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
192
src/game-loop/game-loop-resources.ts
Normal file
192
src/game-loop/game-loop-resources.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
|
import { appConfig } from '../config';
|
||||||
|
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
|
||||||
|
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';
|
||||||
|
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 { SimulationFrameRenderer } from './simulation-frame';
|
||||||
|
import { SimulationTextures } from './simulation-textures';
|
||||||
|
|
||||||
|
interface FrameParameters extends RenderInputs {
|
||||||
|
time: number;
|
||||||
|
deltaTime: number;
|
||||||
|
canvasSize: vec2;
|
||||||
|
activeAgentCount: number;
|
||||||
|
introProgress: number;
|
||||||
|
selectedColorIndex: number;
|
||||||
|
isErasing: boolean;
|
||||||
|
cameraCenter: [number, number];
|
||||||
|
cameraZoom: number;
|
||||||
|
eraserPixelSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
public readonly eraserAgentPipeline: EraserAgentPipeline;
|
||||||
|
public readonly eraserTexturePipeline: EraserTexturePipeline;
|
||||||
|
public readonly diffusionPipeline: DiffusionPipeline;
|
||||||
|
public readonly brushEffectDiffusionPipeline: DiffusionPipeline;
|
||||||
|
public readonly renderPipeline: RenderPipeline;
|
||||||
|
|
||||||
|
private readonly frameRenderer: SimulationFrameRenderer;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
private readonly device: GPUDevice,
|
||||||
|
canvasSize: vec2
|
||||||
|
) {
|
||||||
|
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({
|
||||||
|
canvasSize,
|
||||||
|
time: 0,
|
||||||
|
deltaTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.agentGenerationPipeline = new AgentGenerationPipeline(
|
||||||
|
this.device,
|
||||||
|
this.commonState,
|
||||||
|
GLOBAL_AGENT_CAP
|
||||||
|
);
|
||||||
|
|
||||||
|
this.agentPipeline = new AgentPipeline(
|
||||||
|
this.device,
|
||||||
|
this.commonState,
|
||||||
|
this.agentGenerationPipeline.agentsBuffer
|
||||||
|
);
|
||||||
|
this.brushPipeline = new BrushPipeline(this.device, this.commonState);
|
||||||
|
this.eraserAgentPipeline = new EraserAgentPipeline(
|
||||||
|
this.device,
|
||||||
|
this.commonState,
|
||||||
|
this.agentGenerationPipeline.agentsBuffer
|
||||||
|
);
|
||||||
|
this.eraserTexturePipeline = new EraserTexturePipeline(this.device, this.commonState);
|
||||||
|
this.diffusionPipeline = new DiffusionPipeline(this.device, this.commonState);
|
||||||
|
this.brushEffectDiffusionPipeline = new DiffusionPipeline(
|
||||||
|
this.device,
|
||||||
|
this.commonState
|
||||||
|
);
|
||||||
|
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,
|
||||||
|
eraserTexturePipeline: this.eraserTexturePipeline,
|
||||||
|
diffusionPipeline: this.diffusionPipeline,
|
||||||
|
brushEffectDiffusionPipeline: this.brushEffectDiffusionPipeline,
|
||||||
|
renderPipeline: this.renderPipeline,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public resizeSimulationTo(nextSize: vec2): vec2 | null {
|
||||||
|
return this.textures.resizeTo(nextSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
public setFrameParameters({
|
||||||
|
time,
|
||||||
|
deltaTime,
|
||||||
|
canvasSize,
|
||||||
|
activeAgentCount,
|
||||||
|
introProgress,
|
||||||
|
selectedColorIndex,
|
||||||
|
isErasing,
|
||||||
|
channelColors,
|
||||||
|
backgroundColor,
|
||||||
|
cameraCenter,
|
||||||
|
cameraZoom,
|
||||||
|
eraserPixelSize,
|
||||||
|
}: FrameParameters): void {
|
||||||
|
this.commonState.setParameters({
|
||||||
|
canvasSize,
|
||||||
|
time,
|
||||||
|
deltaTime,
|
||||||
|
});
|
||||||
|
this.agentPipeline.setParameters({
|
||||||
|
...settings,
|
||||||
|
deltaTime,
|
||||||
|
agentCount: activeAgentCount,
|
||||||
|
moveSpeed:
|
||||||
|
settings.moveSpeed *
|
||||||
|
(introProgress >= 1
|
||||||
|
? 1
|
||||||
|
: appConfig.simulation.introMoveSpeedBaseMultiplier +
|
||||||
|
introProgress * appConfig.simulation.introMoveSpeedProgressMultiplier),
|
||||||
|
introProgress,
|
||||||
|
});
|
||||||
|
this.brushPipeline.setParameters({
|
||||||
|
...settings,
|
||||||
|
selectedColorIndex,
|
||||||
|
isErasing,
|
||||||
|
});
|
||||||
|
this.diffusionPipeline.setParameters(settings);
|
||||||
|
this.renderPipeline.setParameters({
|
||||||
|
...settings,
|
||||||
|
channelColors,
|
||||||
|
backgroundColor,
|
||||||
|
cameraCenter,
|
||||||
|
cameraZoom,
|
||||||
|
});
|
||||||
|
this.eraserAgentPipeline.setParameters({
|
||||||
|
agentCount: activeAgentCount,
|
||||||
|
eraserSize: eraserPixelSize,
|
||||||
|
});
|
||||||
|
this.eraserTexturePipeline.setParameters({
|
||||||
|
eraserSize: eraserPixelSize,
|
||||||
|
});
|
||||||
|
this.setBrushEffectDiffusionParameters();
|
||||||
|
}
|
||||||
|
|
||||||
|
public executeFrame(renderSpeed: number, isErasing: boolean): void {
|
||||||
|
this.frameRenderer.execute(renderSpeed, isErasing);
|
||||||
|
}
|
||||||
|
|
||||||
|
public clearSwipes(): void {
|
||||||
|
this.frameRenderer.clearSwipes();
|
||||||
|
}
|
||||||
|
|
||||||
|
public destroy(): void {
|
||||||
|
this.copyPipeline.destroy();
|
||||||
|
this.agentGenerationPipeline.destroy();
|
||||||
|
this.agentPipeline.destroy();
|
||||||
|
this.brushPipeline.destroy();
|
||||||
|
this.eraserAgentPipeline.destroy();
|
||||||
|
this.eraserTexturePipeline.destroy();
|
||||||
|
this.diffusionPipeline.destroy();
|
||||||
|
this.brushEffectDiffusionPipeline.destroy();
|
||||||
|
this.renderPipeline.destroy();
|
||||||
|
this.commonState.destroy();
|
||||||
|
this.textures.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setBrushEffectDiffusionParameters(): void {
|
||||||
|
const framesToOneE = Math.max(
|
||||||
|
1,
|
||||||
|
settings.brushEffectDuration * appConfig.simulation.brushEffectFramesPerSecond
|
||||||
|
);
|
||||||
|
this.brushEffectDiffusionPipeline.setParameters({
|
||||||
|
...settings,
|
||||||
|
decayRateTrails: Math.exp(-1 / framesToOneE) * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/game-loop/game-loop-types.ts
Normal file
17
src/game-loop/game-loop-types.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
|
export interface GardenUi {
|
||||||
|
prompt: HTMLElement;
|
||||||
|
eraserPreview: HTMLElement;
|
||||||
|
exportStatus: HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderInputs {
|
||||||
|
channelColors: Array<[number, number, number]>;
|
||||||
|
backgroundColor: [number, number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StrokeSegment {
|
||||||
|
from: vec2;
|
||||||
|
to: vec2;
|
||||||
|
}
|
||||||
|
|
@ -1,258 +1,244 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
|
import { GardenAudio } from '../audio/garden-audio';
|
||||||
import { AgentPipeline } from '../pipelines/agents/agent-pipeline';
|
import { gardenAudioConfig } from '../audio/garden-audio-config';
|
||||||
import { BrushPipeline } from '../pipelines/brush/brush-pipeline';
|
import { appConfig } from '../config';
|
||||||
import { CommonState } from '../pipelines/common-state/common-state';
|
import { activeVibe, settings } from '../settings';
|
||||||
import { CopyPipeline } from '../pipelines/copy/copy-pipeline';
|
|
||||||
import { DiffusionPipeline } from '../pipelines/diffusion/diffusion-pipeline';
|
|
||||||
import { RenderPipeline } from '../pipelines/render/render-pipeline';
|
|
||||||
import { settings } from '../settings';
|
|
||||||
import { DeltaTimeCalculator } from '../utils/delta-time-calculator';
|
import { DeltaTimeCalculator } from '../utils/delta-time-calculator';
|
||||||
import { initializeContext } from '../utils/graphics/initialize-context';
|
|
||||||
import { ResizableTexture } from '../utils/graphics/resizable-texture';
|
|
||||||
import { sleep } from '../utils/sleep';
|
import { sleep } from '../utils/sleep';
|
||||||
import { GamePresentation } from './game-presentation';
|
import { AgentPopulation } from './agent-population';
|
||||||
import { GameRules } from './game-rules';
|
import { EraserPreview } from './eraser-preview';
|
||||||
|
import { Export4KRenderer } from './export-4k-renderer';
|
||||||
|
import { FramePerformance } from './frame-performance';
|
||||||
|
import { GameLoopResources } from './game-loop-resources';
|
||||||
|
import { GardenUi } from './game-loop-types';
|
||||||
|
import { IntroPrompt } from './intro-prompt';
|
||||||
|
import { GardenPointerInput } from './pointer-input';
|
||||||
|
import { RenderInputCache } from './render-input-cache';
|
||||||
|
|
||||||
export default class GameLoop {
|
export default class GameLoop {
|
||||||
private readonly trailMapA: ResizableTexture;
|
private static readonly MAX_MIRROR_SEGMENT_COUNT =
|
||||||
private readonly trailMapB: ResizableTexture;
|
appConfig.simulation.maxMirrorSegmentCount;
|
||||||
|
|
||||||
private readonly commonState: CommonState;
|
private readonly resources: GameLoopResources;
|
||||||
private readonly copyPipeline: CopyPipeline;
|
private readonly audio = new GardenAudio(gardenAudioConfig);
|
||||||
private readonly agentGenerationPipeline: AgentGenerationPipeline;
|
private readonly renderInputs = new RenderInputCache();
|
||||||
private readonly agentPipeline: AgentPipeline;
|
private readonly introPrompt: IntroPrompt;
|
||||||
private readonly renderPipeline: RenderPipeline;
|
private readonly eraserPreview: EraserPreview;
|
||||||
private readonly brushPipeline: BrushPipeline;
|
private readonly pointerInput: GardenPointerInput;
|
||||||
private readonly diffusionPipeline: DiffusionPipeline;
|
private readonly agentPopulation: AgentPopulation;
|
||||||
|
private readonly export4KRenderer: Export4KRenderer;
|
||||||
|
private readonly framePerformance = new FramePerformance();
|
||||||
|
private readonly seed = Math.floor(Math.random() * 0xffffffff).toString(16);
|
||||||
|
private readonly resizeListener = this.resize.bind(this);
|
||||||
|
private readonly keydownListener: (event: KeyboardEvent) => void;
|
||||||
|
|
||||||
private hasFinished = false;
|
private hasFinished = false;
|
||||||
private readonly finished = Promise.withResolvers<void>();
|
private readonly finished = Promise.withResolvers<void>();
|
||||||
|
|
||||||
private activePointerId: number | null = null;
|
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly canvas: HTMLCanvasElement,
|
private readonly canvas: HTMLCanvasElement,
|
||||||
private readonly device: GPUDevice,
|
device: GPUDevice,
|
||||||
private readonly deltaTimeCalculator: DeltaTimeCalculator,
|
private readonly deltaTimeCalculator: DeltaTimeCalculator,
|
||||||
private readonly gameRules: GameRules
|
ui: GardenUi
|
||||||
) {
|
) {
|
||||||
const context = initializeContext({ device, canvas });
|
|
||||||
|
|
||||||
this.trailMapA = new ResizableTexture(this.device, this.canvasSize);
|
|
||||||
this.trailMapB = new ResizableTexture(this.device, this.canvasSize);
|
|
||||||
this.resize();
|
this.resize();
|
||||||
|
this.resources = new GameLoopResources(canvas, device, this.canvasSize);
|
||||||
this.copyPipeline = new CopyPipeline(this.device);
|
this.introPrompt = new IntroPrompt(ui.prompt);
|
||||||
|
this.eraserPreview = new EraserPreview(canvas, ui.eraserPreview);
|
||||||
this.commonState = new CommonState(this.device);
|
this.agentPopulation = new AgentPopulation(this.resources.agentGenerationPipeline);
|
||||||
this.commonState.setParameters({
|
this.agentPopulation.initializeIntroAgents(this.canvasSize);
|
||||||
canvasSize: this.canvasSize,
|
this.pointerInput = new GardenPointerInput({
|
||||||
time: 0,
|
canvas,
|
||||||
deltaTime: 0,
|
audio: this.audio,
|
||||||
|
brushPipeline: this.resources.brushPipeline,
|
||||||
|
eraserAgentPipeline: this.resources.eraserAgentPipeline,
|
||||||
|
eraserTexturePipeline: this.resources.eraserTexturePipeline,
|
||||||
|
eraserPreview: this.eraserPreview,
|
||||||
|
getCanvasSize: () => this.canvasSize,
|
||||||
|
getDevicePixelRatio: () => this.devicePixelRatio,
|
||||||
|
getMirrorSegmentCount: () => this.mirrorSegmentCount,
|
||||||
|
onStartDrawing: () => {
|
||||||
|
this.introPrompt.markStartedDrawing();
|
||||||
|
this.introPrompt.complete();
|
||||||
|
},
|
||||||
|
onEraseGestureEnded: () => this.agentPopulation.requestCompactionAfterErase(),
|
||||||
|
spawnStrokeAgents: (from, to) => this.agentPopulation.spawnStrokeAgents(from, to),
|
||||||
});
|
});
|
||||||
|
this.export4KRenderer = new Export4KRenderer({
|
||||||
|
device,
|
||||||
|
renderPipeline: this.resources.renderPipeline,
|
||||||
|
statusElement: ui.exportStatus,
|
||||||
|
seed: this.seed,
|
||||||
|
getSourceSize: () => ({
|
||||||
|
width: this.canvas.width,
|
||||||
|
height: this.canvas.height,
|
||||||
|
}),
|
||||||
|
getColorTextureView: () => this.resources.textures.trailMapA.getTextureView(),
|
||||||
|
getSourceTextureView: () => this.resources.textures.sourceMapA.getTextureView(),
|
||||||
|
getVibeId: () => activeVibe.id,
|
||||||
|
});
|
||||||
|
this.keydownListener = (event: KeyboardEvent) => {
|
||||||
|
this.audio.start(activeVibe, { userGesture: event.isTrusted });
|
||||||
|
this.introPrompt.complete();
|
||||||
|
};
|
||||||
|
|
||||||
this.agentGenerationPipeline = new AgentGenerationPipeline(
|
window.addEventListener('resize', this.resizeListener);
|
||||||
this.device,
|
window.addEventListener('keydown', this.keydownListener, { once: true });
|
||||||
this.commonState,
|
this.pointerInput.attach();
|
||||||
settings.maxAgentCountUpperLimit
|
|
||||||
);
|
|
||||||
this.agentGenerationPipeline.spawnFirstGeneration();
|
|
||||||
|
|
||||||
this.agentPipeline = new AgentPipeline(
|
|
||||||
this.device,
|
|
||||||
this.commonState,
|
|
||||||
this.agentGenerationPipeline.agentsBuffer
|
|
||||||
);
|
|
||||||
this.brushPipeline = new BrushPipeline(this.device, this.commonState);
|
|
||||||
this.diffusionPipeline = new DiffusionPipeline(this.device, this.commonState);
|
|
||||||
this.renderPipeline = new RenderPipeline(context, this.device, this.commonState);
|
|
||||||
|
|
||||||
window.addEventListener('resize', this.resize.bind(this));
|
|
||||||
canvas.addEventListener('pointerdown', this.onPointerDown.bind(this));
|
|
||||||
canvas.addEventListener('pointermove', this.onPointerMove.bind(this));
|
|
||||||
canvas.addEventListener('pointerup', this.onPointerUp.bind(this));
|
|
||||||
canvas.addEventListener('pointercancel', this.onPointerUp.bind(this));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private onPointerDown(event: PointerEvent) {
|
public setEraseMode(isErasing: boolean): void {
|
||||||
if (this.activePointerId !== null) {
|
this.pointerInput.setEraseMode(isErasing);
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.activePointerId = event.pointerId;
|
|
||||||
this.canvas.setPointerCapture(event.pointerId);
|
|
||||||
this.brushPipeline.clearSwipes();
|
|
||||||
this.addSwipeAt(event);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private onPointerMove(event: PointerEvent) {
|
public updateEraserPreview(event?: PointerEvent): void {
|
||||||
if (event.pointerId !== this.activePointerId) {
|
this.pointerInput.updateEraserPreview(event);
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.addSwipeAt(event);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private onPointerUp(event: PointerEvent) {
|
public onVibeChanged(): void {
|
||||||
if (event.pointerId !== this.activePointerId) {
|
this.agentPopulation.onVibeChanged();
|
||||||
return;
|
this.renderInputs.invalidate();
|
||||||
}
|
|
||||||
this.addSwipeAt(event);
|
|
||||||
this.canvas.releasePointerCapture(event.pointerId);
|
|
||||||
this.activePointerId = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private addSwipeAt(event: PointerEvent) {
|
public setAudioMuted(isMuted: boolean): void {
|
||||||
const position = vec2.fromValues(
|
this.audio.setMuted(isMuted);
|
||||||
event.clientX * this.devicePixelRatio,
|
|
||||||
this.canvas.height - event.clientY * this.devicePixelRatio
|
|
||||||
);
|
|
||||||
this.brushPipeline.addSwipe(position);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private get isSwipeActive(): boolean {
|
public startAudio(userGesture = false): void {
|
||||||
return this.activePointerId !== null;
|
this.audio.start(activeVibe, { userGesture });
|
||||||
|
}
|
||||||
|
|
||||||
|
public playVibeChangeAudio(userGesture = false): void {
|
||||||
|
this.audio.changeVibe(activeVibe, { userGesture });
|
||||||
}
|
}
|
||||||
|
|
||||||
public async start(): Promise<void> {
|
public async start(): Promise<void> {
|
||||||
requestAnimationFrame(this.render.bind(this));
|
requestAnimationFrame(this.render);
|
||||||
requestAnimationFrame(this.updateCounts.bind(this));
|
|
||||||
return this.finished.promise;
|
return this.finished.promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async updateCounts(): Promise<void> {
|
|
||||||
if (this.hasFinished) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const generationCounts = await this.agentGenerationPipeline.countAgents(
|
|
||||||
settings.agentCount
|
|
||||||
);
|
|
||||||
this.gameRules.updateGenerationCounts(generationCounts);
|
|
||||||
requestAnimationFrame(this.updateCounts.bind(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
public get aliveAgentCounts(): {
|
|
||||||
currentGenerationCount: number;
|
|
||||||
nextGenerationCount: number;
|
|
||||||
} {
|
|
||||||
return this.gameRules.generationCounts;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get maxAgentCount(): number {
|
public get maxAgentCount(): number {
|
||||||
return this.agentGenerationPipeline.maxAgentCount;
|
return this.agentPopulation.maxAgentCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
private resize() {
|
public async export4K(): Promise<void> {
|
||||||
this.canvas.width = this.canvas.clientWidth * this.devicePixelRatio;
|
return this.export4KRenderer.export();
|
||||||
this.canvas.height = this.canvas.clientHeight * this.devicePixelRatio;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async render(time: DOMHighResTimeStamp) {
|
public async destroy(): Promise<void> {
|
||||||
|
this.hasFinished = true;
|
||||||
|
await this.finished.promise;
|
||||||
|
|
||||||
|
window.removeEventListener('resize', this.resizeListener);
|
||||||
|
window.removeEventListener('keydown', this.keydownListener);
|
||||||
|
this.pointerInput.detach();
|
||||||
|
this.introPrompt.destroy();
|
||||||
|
this.resources.destroy();
|
||||||
|
await this.audio.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly render = async (time: DOMHighResTimeStamp) => {
|
||||||
if (this.hasFinished) {
|
if (this.hasFinished) {
|
||||||
this.finished.resolve();
|
this.finished.resolve();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const accentColor = GamePresentation.getGenerationColor(
|
const frameCpuStartedAt = this.framePerformance.markCpuStart();
|
||||||
this.gameRules.nextGenerationId - 1
|
|
||||||
);
|
|
||||||
document.documentElement.style.setProperty(
|
|
||||||
'--accent-color',
|
|
||||||
`rgb(${accentColor[0] * 255},${accentColor[1] * 255},${accentColor[2] * 255})`
|
|
||||||
);
|
|
||||||
|
|
||||||
const deltaTime = this.deltaTimeCalculator.calculateDeltaTimeInSeconds(time);
|
const deltaTime = this.deltaTimeCalculator.calculateDeltaTimeInSeconds(time);
|
||||||
|
this.framePerformance.update(deltaTime);
|
||||||
time *= settings.renderSpeed;
|
this.agentPopulation.growBudget(
|
||||||
const timeInSeconds = time / 1000;
|
deltaTime,
|
||||||
const spawnAction = this.gameRules.getSpawnAction(timeInSeconds, this.canvasSize);
|
this.framePerformance.smoothedFps,
|
||||||
|
this.framePerformance.refreshTargetFps
|
||||||
[
|
|
||||||
this.commonState,
|
|
||||||
this.agentPipeline,
|
|
||||||
this.brushPipeline,
|
|
||||||
this.diffusionPipeline,
|
|
||||||
this.renderPipeline,
|
|
||||||
].forEach((pipeline) =>
|
|
||||||
pipeline.setParameters({
|
|
||||||
time,
|
|
||||||
isNextGenerationOdd: this.gameRules.nextGenerationId % 2,
|
|
||||||
nextGenerationSensorOffsetDistance: this.gameRules.getSensorOffset(),
|
|
||||||
nextGenerationSpeed: this.gameRules.getNextGenerationMoveSpeed(),
|
|
||||||
infectionProbability: this.gameRules.getInfectionProbability(),
|
|
||||||
deltaTime,
|
|
||||||
canvasSize: this.canvasSize,
|
|
||||||
brushColor: GamePresentation.getGenerationColor(
|
|
||||||
this.gameRules.nextGenerationId - 1
|
|
||||||
),
|
|
||||||
evenGenerationColor: GamePresentation.getGenerationColor(
|
|
||||||
this.gameRules.nextGenerationId % 2 == 0
|
|
||||||
? this.gameRules.nextGenerationId
|
|
||||||
: this.gameRules.nextGenerationId - 1
|
|
||||||
),
|
|
||||||
oddGenerationColor: GamePresentation.getGenerationColor(
|
|
||||||
this.gameRules.nextGenerationId % 2 == 1
|
|
||||||
? this.gameRules.nextGenerationId
|
|
||||||
: this.gameRules.nextGenerationId - 1
|
|
||||||
),
|
|
||||||
...settings,
|
|
||||||
center: spawnAction.position,
|
|
||||||
radius: spawnAction.radius,
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
this.introPrompt.update();
|
||||||
|
this.resize();
|
||||||
|
this.resizeSimulationToCanvas();
|
||||||
|
|
||||||
for (let i = 0; i < settings.renderSpeed; i++) {
|
const scaledTime = time * settings.renderSpeed;
|
||||||
const commandEncoder = this.device.createCommandEncoder();
|
const { channelColors, backgroundColor } = this.renderInputs.get();
|
||||||
|
const introProgress = this.introPrompt.progress;
|
||||||
|
const cameraZoom = 1 + (1 - introProgress) * appConfig.simulation.introCameraZoom;
|
||||||
|
const cameraCenter: [number, number] = [
|
||||||
|
this.canvas.width / 2,
|
||||||
|
this.canvas.height / 2,
|
||||||
|
];
|
||||||
|
const eraserPixelSize = settings.eraserSize * this.devicePixelRatio;
|
||||||
|
const isErasing = this.pointerInput.isEraseMode;
|
||||||
|
const accentColor = channelColors[settings.selectedColorIndex] ?? channelColors[0];
|
||||||
|
this.renderInputs.updateAccentColor(accentColor);
|
||||||
|
this.audio.update({
|
||||||
|
vibe: activeVibe,
|
||||||
|
selectedColorIndex: settings.selectedColorIndex,
|
||||||
|
isErasing,
|
||||||
|
});
|
||||||
|
|
||||||
this.copyPipeline.execute(
|
this.resources.setFrameParameters({
|
||||||
commandEncoder,
|
time: scaledTime,
|
||||||
this.trailMapA.getTextureView(),
|
deltaTime,
|
||||||
this.trailMapB.getTextureView()
|
canvasSize: this.canvasSize,
|
||||||
);
|
activeAgentCount: this.agentPopulation.activeAgentCount,
|
||||||
this.brushPipeline.execute(commandEncoder, this.trailMapB.getTextureView());
|
introProgress,
|
||||||
this.agentPipeline.execute(
|
selectedColorIndex: settings.selectedColorIndex,
|
||||||
commandEncoder,
|
isErasing,
|
||||||
this.trailMapA.getTextureView(),
|
channelColors,
|
||||||
this.trailMapB.getTextureView()
|
backgroundColor,
|
||||||
);
|
cameraCenter,
|
||||||
this.diffusionPipeline.execute(
|
cameraZoom,
|
||||||
commandEncoder,
|
eraserPixelSize,
|
||||||
this.trailMapB.getTextureView(),
|
});
|
||||||
this.trailMapA.getTextureView()
|
|
||||||
);
|
|
||||||
this.renderPipeline.execute(commandEncoder, this.trailMapA.getTextureView());
|
|
||||||
|
|
||||||
this.device.queue.submit([commandEncoder.finish()]);
|
const encodeCpuStartedAt = this.framePerformance.markCpuStart();
|
||||||
}
|
this.resources.executeFrame(settings.renderSpeed, isErasing);
|
||||||
|
const encodeCpuMs = this.framePerformance.measureSince(encodeCpuStartedAt);
|
||||||
|
|
||||||
if (!this.isSwipeActive) {
|
this.pointerInput.clearSwipesIfIdle();
|
||||||
this.brushPipeline.clearSwipes();
|
await this.agentPopulation.compactAfterErase(this.pointerInput.isSwipeActive);
|
||||||
}
|
|
||||||
|
this.framePerformance.renderTelemetry({
|
||||||
|
frameCpuStartedAt,
|
||||||
|
encodeCpuMs,
|
||||||
|
activeAgentCount: this.agentPopulation.activeAgentCount,
|
||||||
|
targetAgentBudget: this.agentPopulation.targetAgentBudget,
|
||||||
|
canvas: this.canvas,
|
||||||
|
devicePixelRatio: this.devicePixelRatio,
|
||||||
|
renderSpeed: settings.renderSpeed,
|
||||||
|
});
|
||||||
|
|
||||||
if (settings.simulatedDelayMs > 0) {
|
if (settings.simulatedDelayMs > 0) {
|
||||||
await sleep(settings.simulatedDelayMs);
|
await sleep(settings.simulatedDelayMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
// avoid resizing during rendering
|
requestAnimationFrame(this.render);
|
||||||
this.trailMapA.resize(this.canvasSize);
|
};
|
||||||
this.trailMapB.resize(this.canvasSize);
|
|
||||||
|
|
||||||
requestAnimationFrame(this.render.bind(this));
|
private resize(): void {
|
||||||
|
const width = Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor(this.canvas.clientWidth * this.devicePixelRatio)
|
||||||
|
);
|
||||||
|
const height = Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor(this.canvas.clientHeight * this.devicePixelRatio)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (this.canvas.width === width && this.canvas.height === height) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.canvas.width = width;
|
||||||
|
this.canvas.height = height;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async destroy() {
|
private resizeSimulationToCanvas(): void {
|
||||||
this.hasFinished = true;
|
const scale = this.resources.resizeSimulationTo(this.canvasSize);
|
||||||
await this.finished.promise;
|
if (!scale) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.copyPipeline?.destroy();
|
this.agentPopulation.resizeAgents(scale);
|
||||||
this.agentGenerationPipeline?.destroy();
|
this.pointerInput.scaleLastPointerPosition(scale);
|
||||||
this.agentPipeline?.destroy();
|
|
||||||
this.brushPipeline?.destroy();
|
|
||||||
this.diffusionPipeline?.destroy();
|
|
||||||
this.renderPipeline?.destroy();
|
|
||||||
this.commonState?.destroy();
|
|
||||||
this.trailMapA?.destroy();
|
|
||||||
this.trailMapB?.destroy();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private get canvasSize(): vec2 {
|
private get canvasSize(): vec2 {
|
||||||
|
|
@ -260,6 +246,14 @@ export default class GameLoop {
|
||||||
}
|
}
|
||||||
|
|
||||||
private get devicePixelRatio(): number {
|
private get devicePixelRatio(): number {
|
||||||
return window.devicePixelRatio || 1;
|
const ratio = window.devicePixelRatio;
|
||||||
|
return Number.isFinite(ratio) && ratio > 0 ? ratio : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get mirrorSegmentCount(): number {
|
||||||
|
const count = Number.isFinite(settings.mirrorSegmentCount)
|
||||||
|
? settings.mirrorSegmentCount
|
||||||
|
: 1;
|
||||||
|
return Math.min(GameLoop.MAX_MIRROR_SEGMENT_COUNT, Math.max(1, Math.round(count)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
80
src/game-loop/intro-prompt.ts
Normal file
80
src/game-loop/intro-prompt.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import { appConfig } from '../config';
|
||||||
|
|
||||||
|
const INTRO_TITLE_DURATION_MS = appConfig.simulation.intro.durationSeconds * 1000;
|
||||||
|
|
||||||
|
export class IntroPrompt {
|
||||||
|
private introComplete = false;
|
||||||
|
private introStartedAt = performance.now();
|
||||||
|
private introCompletedAt: number | null = null;
|
||||||
|
private hasStartedDrawing = false;
|
||||||
|
private isDrawHintVisible = false;
|
||||||
|
|
||||||
|
public constructor(private readonly prompt: HTMLElement) {}
|
||||||
|
|
||||||
|
public get progress(): number {
|
||||||
|
return this.introComplete
|
||||||
|
? 1
|
||||||
|
: Math.min(1, (performance.now() - this.introStartedAt) / INTRO_TITLE_DURATION_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
public update(): void {
|
||||||
|
const now = performance.now();
|
||||||
|
|
||||||
|
if (!this.introComplete && now - this.introStartedAt > INTRO_TITLE_DURATION_MS) {
|
||||||
|
this.complete(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!this.introComplete ||
|
||||||
|
this.hasStartedDrawing ||
|
||||||
|
this.introCompletedAt === null ||
|
||||||
|
now - this.introCompletedAt < appConfig.simulation.intro.drawHintDelayMs
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showDrawHint();
|
||||||
|
}
|
||||||
|
|
||||||
|
public complete(completedAt = performance.now()): void {
|
||||||
|
if (this.introComplete) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.introComplete = true;
|
||||||
|
this.introCompletedAt = completedAt;
|
||||||
|
this.hideDrawHint();
|
||||||
|
}
|
||||||
|
|
||||||
|
public markStartedDrawing(): void {
|
||||||
|
this.hasStartedDrawing = true;
|
||||||
|
this.hideDrawHint();
|
||||||
|
}
|
||||||
|
|
||||||
|
public destroy(): void {
|
||||||
|
this.hideDrawHint();
|
||||||
|
}
|
||||||
|
|
||||||
|
private showDrawHint(): void {
|
||||||
|
if (this.isDrawHintVisible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isDrawHintVisible = true;
|
||||||
|
this.prompt.classList.add(appConfig.simulation.intro.drawHintClass);
|
||||||
|
this.prompt.innerHTML = `
|
||||||
|
<svg class="draw-hint-mark" viewBox="0 0 128 72" aria-hidden="true" focusable="false">
|
||||||
|
<path class="draw-hint-shadow" d="M12 50 C34 18 52 62 70 36 S102 18 116 42" />
|
||||||
|
<path class="draw-hint-stroke" d="M12 50 C34 18 52 62 70 36 S102 18 116 42" />
|
||||||
|
<circle class="draw-hint-start" cx="12" cy="50" r="4" />
|
||||||
|
<circle class="draw-hint-end" cx="116" cy="42" r="7" />
|
||||||
|
</svg>
|
||||||
|
<span class="draw-hint-text">Draw on the screen</span>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private hideDrawHint(): void {
|
||||||
|
this.isDrawHintVisible = false;
|
||||||
|
this.prompt.classList.remove(appConfig.simulation.intro.drawHintClass);
|
||||||
|
this.prompt.replaceChildren();
|
||||||
|
}
|
||||||
|
}
|
||||||
354
src/game-loop/intro-title-agents.ts
Normal file
354
src/game-loop/intro-title-agents.ts
Normal file
|
|
@ -0,0 +1,354 @@
|
||||||
|
import { appConfig } from '../config';
|
||||||
|
import { AGENT_FLOAT_COUNT } from '../pipelines/agents/agent-generation/agent';
|
||||||
|
|
||||||
|
interface IntroTitlePoint {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
tangent: number | null;
|
||||||
|
colorIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IntroTitleAgentOptions {
|
||||||
|
count: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INTRO_TITLE = appConfig.simulation.intro.title;
|
||||||
|
|
||||||
|
export const createIntroTitleAgents = ({
|
||||||
|
count,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
}: IntroTitleAgentOptions): Float32Array => {
|
||||||
|
if (count <= 0) {
|
||||||
|
return new Float32Array();
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeWidth = Math.max(1, width);
|
||||||
|
const safeHeight = Math.max(1, height);
|
||||||
|
const points = createIntroTitlePoints(safeWidth, safeHeight);
|
||||||
|
if (points.length === 0) {
|
||||||
|
return new Float32Array();
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = new Float32Array(count * AGENT_FLOAT_COUNT);
|
||||||
|
const minSide = Math.min(safeWidth, safeHeight);
|
||||||
|
const targetJitter = Math.max(
|
||||||
|
appConfig.simulation.intro.minTargetJitterPx,
|
||||||
|
minSide * appConfig.simulation.intro.targetJitterSideRatio
|
||||||
|
);
|
||||||
|
const entryJitter = Math.max(
|
||||||
|
appConfig.simulation.intro.minEntryJitterPx,
|
||||||
|
minSide * appConfig.simulation.intro.entryJitterSideRatio
|
||||||
|
);
|
||||||
|
const titleRadius = points.reduce(
|
||||||
|
(radius, point) =>
|
||||||
|
Math.max(
|
||||||
|
radius,
|
||||||
|
Math.hypot(
|
||||||
|
point.x - safeWidth / 2,
|
||||||
|
point.y - safeHeight * appConfig.simulation.intro.verticalAnchor
|
||||||
|
)
|
||||||
|
),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const introCircleRadius = Math.min(
|
||||||
|
Math.max(
|
||||||
|
titleRadius * appConfig.simulation.intro.titleRadiusMultiplier,
|
||||||
|
minSide * appConfig.simulation.intro.circleMinSideRatio
|
||||||
|
),
|
||||||
|
minSide * appConfig.simulation.intro.circleMaxSideRatio
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const point = points[Math.floor(Math.random() * points.length)];
|
||||||
|
const targetX = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(safeWidth - 1, point.x + (Math.random() - 0.5) * targetJitter)
|
||||||
|
);
|
||||||
|
const targetY = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(safeHeight - 1, point.y + (Math.random() - 0.5) * targetJitter)
|
||||||
|
);
|
||||||
|
const [startX, startY] = getIntroRadialStart(
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
safeWidth,
|
||||||
|
safeHeight,
|
||||||
|
introCircleRadius,
|
||||||
|
entryJitter
|
||||||
|
);
|
||||||
|
const approachAngle = Math.atan2(targetY - startY, targetX - startX);
|
||||||
|
let targetAngle = point.tangent ?? approachAngle;
|
||||||
|
if (Math.cos(targetAngle - approachAngle) < 0) {
|
||||||
|
targetAngle += Math.PI;
|
||||||
|
}
|
||||||
|
|
||||||
|
const distanceFraction =
|
||||||
|
Math.hypot(targetX - startX, targetY - startY) / Math.hypot(safeWidth, safeHeight);
|
||||||
|
const base = i * AGENT_FLOAT_COUNT;
|
||||||
|
data[base] = startX;
|
||||||
|
data[base + 1] = startY;
|
||||||
|
data[base + 2] =
|
||||||
|
approachAngle +
|
||||||
|
(Math.random() - 0.5) * appConfig.simulation.intro.angleJitterRadians;
|
||||||
|
data[base + 3] = point.colorIndex;
|
||||||
|
data[base + 4] = targetX;
|
||||||
|
data[base + 5] = targetY;
|
||||||
|
data[base + 6] = targetAngle;
|
||||||
|
data[base + 7] = Math.min(
|
||||||
|
appConfig.simulation.intro.targetDelayMax,
|
||||||
|
distanceFraction * appConfig.simulation.intro.targetDelayDistanceMultiplier +
|
||||||
|
Math.random() * appConfig.simulation.intro.targetDelayRandomMultiplier
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIntroRadialStart = (
|
||||||
|
targetX: number,
|
||||||
|
targetY: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
radius: number,
|
||||||
|
jitter: number
|
||||||
|
): [number, number] => {
|
||||||
|
const centerX = width / 2;
|
||||||
|
const centerY = height * appConfig.simulation.intro.verticalAnchor;
|
||||||
|
const offsetX = targetX - centerX;
|
||||||
|
const offsetY = targetY - centerY;
|
||||||
|
const length = Math.hypot(offsetX, offsetY);
|
||||||
|
const angle =
|
||||||
|
length > 0.001 ? Math.atan2(offsetY, offsetX) : Math.random() * Math.PI * 2;
|
||||||
|
const directionX = Math.cos(angle);
|
||||||
|
const directionY = Math.sin(angle);
|
||||||
|
const tangentX = -directionY;
|
||||||
|
const tangentY = directionX;
|
||||||
|
const tangentJitter = (Math.random() - 0.5) * jitter;
|
||||||
|
const radialJitter =
|
||||||
|
(Math.random() - 0.5) * jitter * appConfig.simulation.intro.radialJitterRatio;
|
||||||
|
const startX =
|
||||||
|
centerX + directionX * (radius + radialJitter) + tangentX * tangentJitter;
|
||||||
|
const startY =
|
||||||
|
centerY + directionY * (radius + radialJitter) + tangentY * tangentJitter;
|
||||||
|
|
||||||
|
return [
|
||||||
|
Math.max(0, Math.min(width - 1, startX)),
|
||||||
|
Math.max(0, Math.min(height - 1, startY)),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const createIntroTitlePoints = (
|
||||||
|
width: number,
|
||||||
|
height: number
|
||||||
|
): Array<IntroTitlePoint> => {
|
||||||
|
const maskCanvas = document.createElement('canvas');
|
||||||
|
maskCanvas.width = width;
|
||||||
|
maskCanvas.height = height;
|
||||||
|
const context = maskCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
if (!context) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const fontSize = getIntroTitleFontSize(context, width, height);
|
||||||
|
context.clearRect(0, 0, width, height);
|
||||||
|
context.font = `${fontSize}px Comfortaa, "Open Sans", sans-serif`;
|
||||||
|
context.textAlign = 'center';
|
||||||
|
context.textBaseline = 'middle';
|
||||||
|
context.fillStyle = '#fff';
|
||||||
|
context.strokeStyle = '#fff';
|
||||||
|
context.lineJoin = 'round';
|
||||||
|
context.lineWidth = Math.max(
|
||||||
|
appConfig.simulation.intro.titleStrokeWidthMinPx,
|
||||||
|
fontSize * appConfig.simulation.intro.titleStrokeWidthRatio
|
||||||
|
);
|
||||||
|
const letterSpacing = fontSize * appConfig.simulation.intro.letterSpacingEm;
|
||||||
|
drawIntroTitleText(
|
||||||
|
context,
|
||||||
|
width / 2,
|
||||||
|
height * appConfig.simulation.intro.verticalAnchor,
|
||||||
|
letterSpacing,
|
||||||
|
'stroke'
|
||||||
|
);
|
||||||
|
drawIntroTitleText(
|
||||||
|
context,
|
||||||
|
width / 2,
|
||||||
|
height * appConfig.simulation.intro.verticalAnchor,
|
||||||
|
letterSpacing,
|
||||||
|
'fill'
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data } = context.getImageData(0, 0, width, height);
|
||||||
|
const step = Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor(Math.min(width, height) / appConfig.simulation.intro.maskSampleDensity)
|
||||||
|
);
|
||||||
|
const points: Array<IntroTitlePoint> = [];
|
||||||
|
const characterColorBoundaries = getIntroTitleColorBoundaries(
|
||||||
|
context,
|
||||||
|
width,
|
||||||
|
letterSpacing
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let y = 0; y < height; y += step) {
|
||||||
|
for (let x = 0; x < width; x += step) {
|
||||||
|
const alpha = getMaskAlpha(data, width, height, x, y);
|
||||||
|
if (alpha < appConfig.simulation.intro.maskAlphaThreshold) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
points.push({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
tangent: estimateMaskTangent(data, width, height, x, y),
|
||||||
|
colorIndex: getIntroTitleColorIndex(x, characterColorBoundaries),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return points;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIntroTitleColorBoundaries = (
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
width: number,
|
||||||
|
letterSpacing: number
|
||||||
|
): [number, number] => {
|
||||||
|
const letters = Array.from(INTRO_TITLE);
|
||||||
|
const totalWidth = measureIntroTitleText(context, letters, letterSpacing);
|
||||||
|
let x = width / 2 - totalWidth / 2;
|
||||||
|
const [firstCutLetter, secondCutLetter] =
|
||||||
|
appConfig.simulation.intro.titleColorCutLetters;
|
||||||
|
const letterBoxes = letters.map((letter, index) => {
|
||||||
|
const letterWidth = context.measureText(letter).width;
|
||||||
|
const box = {
|
||||||
|
left: x,
|
||||||
|
right: x + letterWidth,
|
||||||
|
};
|
||||||
|
x += letterWidth + (index === letters.length - 1 ? 0 : letterSpacing);
|
||||||
|
return box;
|
||||||
|
});
|
||||||
|
|
||||||
|
const getBoundaryBetweenLetters = (leftLetterIndex: number) =>
|
||||||
|
(letterBoxes[leftLetterIndex].right + letterBoxes[leftLetterIndex + 1].left) / 2;
|
||||||
|
|
||||||
|
return [
|
||||||
|
getBoundaryBetweenLetters(firstCutLetter - 1),
|
||||||
|
getBoundaryBetweenLetters(secondCutLetter - 1),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawIntroTitleText = (
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
centerX: number,
|
||||||
|
centerY: number,
|
||||||
|
letterSpacing: number,
|
||||||
|
mode: 'fill' | 'stroke'
|
||||||
|
): void => {
|
||||||
|
const letters = Array.from(INTRO_TITLE);
|
||||||
|
const totalWidth = measureIntroTitleText(context, letters, letterSpacing);
|
||||||
|
let x = centerX - totalWidth / 2;
|
||||||
|
|
||||||
|
letters.forEach((letter, index) => {
|
||||||
|
const letterWidth = context.measureText(letter).width;
|
||||||
|
const drawX = x + letterWidth / 2;
|
||||||
|
if (mode === 'fill') {
|
||||||
|
context.fillText(letter, drawX, centerY);
|
||||||
|
} else {
|
||||||
|
context.strokeText(letter, drawX, centerY);
|
||||||
|
}
|
||||||
|
x += letterWidth + (index === letters.length - 1 ? 0 : letterSpacing);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const measureIntroTitleText = (
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
letters: Array<string>,
|
||||||
|
letterSpacing: number
|
||||||
|
): number => {
|
||||||
|
const textWidth = letters.reduce(
|
||||||
|
(width, letter) => width + context.measureText(letter).width,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
return textWidth + Math.max(0, letters.length - 1) * letterSpacing;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIntroTitleColorIndex = (x: number, boundaries: [number, number]): number => {
|
||||||
|
if (x < boundaries[0]) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (x < boundaries[1]) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 2;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIntroTitleFontSize = (
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
width: number,
|
||||||
|
height: number
|
||||||
|
): number => {
|
||||||
|
const maxWidth = width * appConfig.simulation.intro.maxWidthRatio;
|
||||||
|
const maxHeight = height * appConfig.simulation.intro.maxHeightRatio;
|
||||||
|
let fontSize = Math.floor(
|
||||||
|
Math.min(
|
||||||
|
height * appConfig.simulation.intro.initialFontHeightRatio,
|
||||||
|
width * appConfig.simulation.intro.initialFontWidthRatio
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
while (fontSize > appConfig.simulation.intro.minFontSizePx) {
|
||||||
|
context.font = `${fontSize}px Comfortaa, "Open Sans", sans-serif`;
|
||||||
|
const metrics = context.measureText(INTRO_TITLE);
|
||||||
|
const measuredHeight =
|
||||||
|
metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent || fontSize;
|
||||||
|
|
||||||
|
if (metrics.width <= maxWidth && measuredHeight <= maxHeight) {
|
||||||
|
return fontSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
fontSize = Math.floor(fontSize * appConfig.simulation.intro.fontScaleDown);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fontSize;
|
||||||
|
};
|
||||||
|
|
||||||
|
const estimateMaskTangent = (
|
||||||
|
data: Uint8ClampedArray,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
x: number,
|
||||||
|
y: number
|
||||||
|
): number | null => {
|
||||||
|
const gradientX =
|
||||||
|
getMaskAlpha(data, width, height, x + 1, y) -
|
||||||
|
getMaskAlpha(data, width, height, x - 1, y);
|
||||||
|
const gradientY =
|
||||||
|
getMaskAlpha(data, width, height, x, y + 1) -
|
||||||
|
getMaskAlpha(data, width, height, x, y - 1);
|
||||||
|
|
||||||
|
if (
|
||||||
|
Math.abs(gradientX) + Math.abs(gradientY) <
|
||||||
|
appConfig.simulation.intro.maskGradientThreshold
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.atan2(gradientX, -gradientY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMaskAlpha = (
|
||||||
|
data: Uint8ClampedArray,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
x: number,
|
||||||
|
y: number
|
||||||
|
): number => {
|
||||||
|
const clampedX = Math.max(0, Math.min(width - 1, Math.round(x)));
|
||||||
|
const clampedY = Math.max(0, Math.min(height - 1, Math.round(y)));
|
||||||
|
return data[(clampedY * width + clampedX) * 4 + 3];
|
||||||
|
};
|
||||||
248
src/game-loop/pointer-input.ts
Normal file
248
src/game-loop/pointer-input.ts
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
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';
|
||||||
|
import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline';
|
||||||
|
import { activeVibe, settings } from '../settings';
|
||||||
|
import { EraserPreview } from './eraser-preview';
|
||||||
|
import { StrokeSegment } from './game-loop-types';
|
||||||
|
|
||||||
|
interface GardenPointerInputOptions {
|
||||||
|
canvas: HTMLCanvasElement;
|
||||||
|
audio: GardenAudio;
|
||||||
|
brushPipeline: BrushPipeline;
|
||||||
|
eraserAgentPipeline: EraserAgentPipeline;
|
||||||
|
eraserTexturePipeline: EraserTexturePipeline;
|
||||||
|
eraserPreview: EraserPreview;
|
||||||
|
getCanvasSize: () => vec2;
|
||||||
|
getDevicePixelRatio: () => number;
|
||||||
|
getMirrorSegmentCount: () => number;
|
||||||
|
onStartDrawing: () => void;
|
||||||
|
onEraseGestureEnded: () => void;
|
||||||
|
spawnStrokeAgents: (from: vec2, to: vec2) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GardenPointerInput {
|
||||||
|
private activePointerId: number | null = null;
|
||||||
|
private lastPointerPosition: vec2 | null = null;
|
||||||
|
private lastPointerEventTimeMs: number | null = null;
|
||||||
|
private lastPointerPressure = 0.5;
|
||||||
|
private isErasing = false;
|
||||||
|
|
||||||
|
public constructor(private readonly options: GardenPointerInputOptions) {}
|
||||||
|
|
||||||
|
public attach(): void {
|
||||||
|
this.canvas.addEventListener('pointerenter', this.onPointerEnter);
|
||||||
|
this.canvas.addEventListener('pointerleave', this.onPointerLeave);
|
||||||
|
this.canvas.addEventListener('pointerdown', this.onPointerDown);
|
||||||
|
this.canvas.addEventListener('pointermove', this.onPointerMove);
|
||||||
|
this.canvas.addEventListener('pointerup', this.onPointerUp);
|
||||||
|
this.canvas.addEventListener('pointercancel', this.onPointerUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
public detach(): void {
|
||||||
|
this.canvas.removeEventListener('pointerenter', this.onPointerEnter);
|
||||||
|
this.canvas.removeEventListener('pointerleave', this.onPointerLeave);
|
||||||
|
this.canvas.removeEventListener('pointerdown', this.onPointerDown);
|
||||||
|
this.canvas.removeEventListener('pointermove', this.onPointerMove);
|
||||||
|
this.canvas.removeEventListener('pointerup', this.onPointerUp);
|
||||||
|
this.canvas.removeEventListener('pointercancel', this.onPointerUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
public setEraseMode(isErasing: boolean): void {
|
||||||
|
this.isErasing = isErasing;
|
||||||
|
this.options.eraserPreview.setEraseMode(isErasing, this.isSwipeActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
public updateEraserPreview(event?: PointerEvent): void {
|
||||||
|
this.options.eraserPreview.update(event, this.isSwipeActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
public clearSwipesIfIdle(): void {
|
||||||
|
if (this.isSwipeActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.options.brushPipeline.clearSwipes();
|
||||||
|
this.options.eraserAgentPipeline.clearSwipes();
|
||||||
|
this.options.eraserTexturePipeline.clearSwipes();
|
||||||
|
}
|
||||||
|
|
||||||
|
public scaleLastPointerPosition(scale: vec2): void {
|
||||||
|
if (this.lastPointerPosition !== null) {
|
||||||
|
vec2.mul(this.lastPointerPosition, this.lastPointerPosition, scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public get isSwipeActive(): boolean {
|
||||||
|
return this.activePointerId !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get isEraseMode(): boolean {
|
||||||
|
return this.isErasing;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get canvas(): HTMLCanvasElement {
|
||||||
|
return this.options.canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly onPointerDown = (event: PointerEvent) => {
|
||||||
|
this.options.eraserPreview.setPointerHoveringCanvas(true);
|
||||||
|
this.updateEraserPreview(event);
|
||||||
|
if (this.activePointerId !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.options.audio.start(activeVibe, { userGesture: event.isTrusted });
|
||||||
|
this.options.audio.beginGesture();
|
||||||
|
this.options.onStartDrawing();
|
||||||
|
this.activePointerId = event.pointerId;
|
||||||
|
this.canvas.setPointerCapture(event.pointerId);
|
||||||
|
this.options.brushPipeline.clearSwipes();
|
||||||
|
this.options.eraserAgentPipeline.clearSwipes();
|
||||||
|
this.options.eraserTexturePipeline.clearSwipes();
|
||||||
|
this.lastPointerPosition = null;
|
||||||
|
this.lastPointerEventTimeMs = null;
|
||||||
|
this.lastPointerPressure = this.getPointerPressure(event);
|
||||||
|
this.addSwipeAt(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly onPointerMove = (event: PointerEvent) => {
|
||||||
|
this.updateEraserPreview(event);
|
||||||
|
if (event.pointerId !== this.activePointerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.addSwipeAt(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly onPointerUp = (event: PointerEvent) => {
|
||||||
|
if (event.pointerId !== this.activePointerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.addSwipeAt(event, { emitAudio: false });
|
||||||
|
this.options.audio.endGesture();
|
||||||
|
if (this.isErasing) {
|
||||||
|
this.options.onEraseGestureEnded();
|
||||||
|
}
|
||||||
|
this.canvas.releasePointerCapture(event.pointerId);
|
||||||
|
this.activePointerId = null;
|
||||||
|
this.lastPointerPosition = null;
|
||||||
|
this.lastPointerEventTimeMs = null;
|
||||||
|
this.options.eraserPreview.setPointerHoveringCanvas(
|
||||||
|
this.options.eraserPreview.isPointerInsideCanvas(event)
|
||||||
|
);
|
||||||
|
this.updateEraserPreview(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly onPointerEnter = (event: PointerEvent) => {
|
||||||
|
this.options.eraserPreview.setPointerHoveringCanvas(true);
|
||||||
|
this.updateEraserPreview(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly onPointerLeave = () => {
|
||||||
|
this.options.eraserPreview.setPointerHoveringCanvas(false);
|
||||||
|
this.updateEraserPreview();
|
||||||
|
};
|
||||||
|
|
||||||
|
private addSwipeAt(event: PointerEvent, options: { emitAudio?: boolean } = {}): void {
|
||||||
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
|
const devicePixelRatio = this.options.getDevicePixelRatio();
|
||||||
|
const position = vec2.fromValues(
|
||||||
|
(event.clientX - rect.left) * devicePixelRatio,
|
||||||
|
(event.clientY - rect.top) * devicePixelRatio
|
||||||
|
);
|
||||||
|
const previousPosition = this.lastPointerPosition ?? position;
|
||||||
|
const previousTimeMs = this.lastPointerEventTimeMs ?? event.timeStamp;
|
||||||
|
const elapsedSeconds = Math.max(
|
||||||
|
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 }]
|
||||||
|
: this.getMirroredStrokeSegments(previousPosition, position);
|
||||||
|
|
||||||
|
segments.forEach((segment) => {
|
||||||
|
if (this.isErasing) {
|
||||||
|
this.options.eraserAgentPipeline.addSwipeSegment(segment.from, segment.to);
|
||||||
|
this.options.eraserTexturePipeline.addSwipeSegment(segment.from, segment.to);
|
||||||
|
} else {
|
||||||
|
this.options.brushPipeline.addSwipeSegment(segment.from, segment.to);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!this.isErasing) {
|
||||||
|
segments.forEach((segment) => {
|
||||||
|
this.options.spawnStrokeAgents(segment.from, segment.to);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (options.emitAudio !== false) {
|
||||||
|
this.options.audio.stroke({
|
||||||
|
vibe: activeVibe,
|
||||||
|
from: previousPosition,
|
||||||
|
to: position,
|
||||||
|
canvasSize: this.options.getCanvasSize(),
|
||||||
|
colorIndex: settings.selectedColorIndex,
|
||||||
|
isErasing: this.isErasing,
|
||||||
|
pressure: pressure > 0 ? pressure : this.lastPointerPressure,
|
||||||
|
velocityPixelsPerSecond,
|
||||||
|
eraserSizePixels: settings.eraserSize * devicePixelRatio,
|
||||||
|
pointerType: event.pointerType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.lastPointerPosition = position;
|
||||||
|
this.lastPointerEventTimeMs = event.timeStamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMirroredStrokeSegments(from: vec2, to: vec2): Array<StrokeSegment> {
|
||||||
|
const segmentCount = this.options.getMirrorSegmentCount();
|
||||||
|
if (segmentCount <= 1) {
|
||||||
|
return [{ from, to }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const center = vec2.fromValues(this.canvas.width / 2, this.canvas.height / 2);
|
||||||
|
const angleStep = (Math.PI * 2) / segmentCount;
|
||||||
|
const segments: Array<StrokeSegment> = [];
|
||||||
|
for (let i = 0; i < segmentCount; i++) {
|
||||||
|
const angle = angleStep * i;
|
||||||
|
segments.push({
|
||||||
|
from: rotatePointAround(from, center, angle),
|
||||||
|
to: rotatePointAround(to, center, angle),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 event.buttons > 0 || event.type === 'pointerdown'
|
||||||
|
? gardenAudioConfig.input.pressureFallback
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rotatePointAround = (point: vec2, center: vec2, angle: number): vec2 => {
|
||||||
|
if (angle === 0) {
|
||||||
|
return point;
|
||||||
|
}
|
||||||
|
|
||||||
|
const offsetX = point[0] - center[0];
|
||||||
|
const offsetY = point[1] - center[1];
|
||||||
|
const cos = Math.cos(angle);
|
||||||
|
const sin = Math.sin(angle);
|
||||||
|
return vec2.fromValues(
|
||||||
|
center[0] + offsetX * cos - offsetY * sin,
|
||||||
|
center[1] + offsetX * sin + offsetY * cos
|
||||||
|
);
|
||||||
|
};
|
||||||
40
src/game-loop/render-input-cache.ts
Normal file
40
src/game-loop/render-input-cache.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { activeVibe } from '../settings';
|
||||||
|
import { hexToRgb } from '../vibes';
|
||||||
|
import { RenderInputs } from './game-loop-types';
|
||||||
|
|
||||||
|
export class RenderInputCache {
|
||||||
|
private cachedVibeId: string | null = null;
|
||||||
|
private cachedRenderInputs?: RenderInputs;
|
||||||
|
private previousAccentColor = '';
|
||||||
|
|
||||||
|
public invalidate(): void {
|
||||||
|
this.cachedVibeId = null;
|
||||||
|
this.cachedRenderInputs = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get(): RenderInputs {
|
||||||
|
if (this.cachedRenderInputs && this.cachedVibeId === activeVibe.id) {
|
||||||
|
return this.cachedRenderInputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cachedVibeId = activeVibe.id;
|
||||||
|
this.cachedRenderInputs = {
|
||||||
|
channelColors: activeVibe.colors.map(hexToRgb),
|
||||||
|
backgroundColor: hexToRgb(activeVibe.backgroundColor),
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.cachedRenderInputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public updateAccentColor(color: [number, number, number]): void {
|
||||||
|
const accentColor = `rgb(${Math.round(color[0] * 255)},${Math.round(
|
||||||
|
color[1] * 255
|
||||||
|
)},${Math.round(color[2] * 255)})`;
|
||||||
|
if (this.previousAccentColor === accentColor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.previousAccentColor = accentColor;
|
||||||
|
document.documentElement.style.setProperty('--accent-color', accentColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
99
src/game-loop/simulation-frame.ts
Normal file
99
src/game-loop/simulation-frame.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
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 { SimulationTextures } from './simulation-textures';
|
||||||
|
|
||||||
|
export interface SimulationFramePipelines {
|
||||||
|
copyPipeline: CopyPipeline;
|
||||||
|
agentPipeline: AgentPipeline;
|
||||||
|
brushPipeline: BrushPipeline;
|
||||||
|
eraserAgentPipeline: EraserAgentPipeline;
|
||||||
|
eraserTexturePipeline: EraserTexturePipeline;
|
||||||
|
diffusionPipeline: DiffusionPipeline;
|
||||||
|
brushEffectDiffusionPipeline: DiffusionPipeline;
|
||||||
|
renderPipeline: RenderPipeline;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SimulationFrameRenderer {
|
||||||
|
public constructor(
|
||||||
|
private readonly device: GPUDevice,
|
||||||
|
private readonly textures: SimulationTextures,
|
||||||
|
private readonly pipelines: SimulationFramePipelines
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public execute(renderSpeed: number, isErasing: boolean): void {
|
||||||
|
for (let i = 0; i < renderSpeed; i++) {
|
||||||
|
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(
|
||||||
|
commandEncoder,
|
||||||
|
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.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(
|
||||||
|
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()]);
|
||||||
|
this.textures.swapSourceMaps();
|
||||||
|
this.textures.swapInfluenceMaps();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public clearSwipes(): void {
|
||||||
|
this.pipelines.brushPipeline.clearSwipes();
|
||||||
|
this.pipelines.eraserAgentPipeline.clearSwipes();
|
||||||
|
this.pipelines.eraserTexturePipeline.clearSwipes();
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/game-loop/simulation-textures.ts
Normal file
58
src/game-loop/simulation-textures.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
|
import { ResizableTexture } from '../utils/graphics/resizable-texture';
|
||||||
|
|
||||||
|
export class SimulationTextures {
|
||||||
|
public readonly trailMapA: ResizableTexture;
|
||||||
|
public readonly trailMapB: ResizableTexture;
|
||||||
|
public sourceMapA: ResizableTexture;
|
||||||
|
public sourceMapB: ResizableTexture;
|
||||||
|
public influenceMapA: ResizableTexture;
|
||||||
|
public influenceMapB: ResizableTexture;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly device: GPUDevice,
|
||||||
|
canvasSize: vec2
|
||||||
|
) {
|
||||||
|
this.trailMapA = new ResizableTexture(this.device, canvasSize);
|
||||||
|
this.trailMapB = new ResizableTexture(this.device, canvasSize);
|
||||||
|
this.sourceMapA = new ResizableTexture(this.device, canvasSize);
|
||||||
|
this.sourceMapB = new ResizableTexture(this.device, canvasSize);
|
||||||
|
this.influenceMapA = new ResizableTexture(this.device, canvasSize);
|
||||||
|
this.influenceMapB = new ResizableTexture(this.device, canvasSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
public resizeTo(nextSize: vec2): vec2 | null {
|
||||||
|
const previousSize = this.trailMapA.getSize();
|
||||||
|
if (vec2.equals(previousSize, nextSize)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scale = vec2.div(vec2.create(), nextSize, previousSize);
|
||||||
|
this.trailMapA.resize(nextSize);
|
||||||
|
this.trailMapB.resize(nextSize);
|
||||||
|
this.sourceMapA.resize(nextSize);
|
||||||
|
this.sourceMapB.resize(nextSize);
|
||||||
|
this.influenceMapA.resize(nextSize);
|
||||||
|
this.influenceMapB.resize(nextSize);
|
||||||
|
|
||||||
|
return scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
public swapSourceMaps(): void {
|
||||||
|
[this.sourceMapA, this.sourceMapB] = [this.sourceMapB, this.sourceMapA];
|
||||||
|
}
|
||||||
|
|
||||||
|
public swapInfluenceMaps(): void {
|
||||||
|
[this.influenceMapA, this.influenceMapB] = [this.influenceMapB, this.influenceMapA];
|
||||||
|
}
|
||||||
|
|
||||||
|
public destroy(): void {
|
||||||
|
this.trailMapA.destroy();
|
||||||
|
this.trailMapB.destroy();
|
||||||
|
this.sourceMapA.destroy();
|
||||||
|
this.sourceMapB.destroy();
|
||||||
|
this.influenceMapA.destroy();
|
||||||
|
this.influenceMapB.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
315
src/index.ts
315
src/index.ts
|
|
@ -1,26 +1,71 @@
|
||||||
import { isProduction } from './constants';
|
|
||||||
import GameLoop from './game-loop/game-loop';
|
import GameLoop from './game-loop/game-loop';
|
||||||
import { GameRules } from './game-loop/game-rules';
|
|
||||||
|
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
|
|
||||||
|
import { appConfig } from './config';
|
||||||
import { CollapsiblePanelAnimator } from './page/collapsible-panel-animator';
|
import { CollapsiblePanelAnimator } from './page/collapsible-panel-animator';
|
||||||
|
import { ConfigPane } from './page/config-pane';
|
||||||
import { FullScreenHandler } from './page/full-screen-handler';
|
import { FullScreenHandler } from './page/full-screen-handler';
|
||||||
import { MenuHider } from './page/menu-hider';
|
import { MenuHider } from './page/menu-hider';
|
||||||
import { setUpSettingsPage } from './page/set-up-settings-page';
|
import { activeVibe, applyVibeSettings, resetSettings, settings } from './settings';
|
||||||
import { SettingsSlider } from './page/settings-slider';
|
|
||||||
import { resetSettings } from './settings';
|
|
||||||
import { DeltaTimeCalculator } from './utils/delta-time-calculator';
|
import { DeltaTimeCalculator } from './utils/delta-time-calculator';
|
||||||
import { ErrorHandler, Severity } from './utils/error-handler';
|
import { ErrorHandler, Severity } from './utils/error-handler';
|
||||||
import { initializeGpu } from './utils/graphics/initialize-gpu';
|
import { initializeGpu } from './utils/graphics/initialize-gpu';
|
||||||
|
import { VIBE_PRESETS } from './vibes';
|
||||||
|
|
||||||
|
const clampEraserSize = (value: number): number => {
|
||||||
|
const safeValue = Number.isFinite(value) ? value : appConfig.toolbar.eraser.default;
|
||||||
|
return Math.min(
|
||||||
|
appConfig.toolbar.eraser.max,
|
||||||
|
Math.max(appConfig.toolbar.eraser.min, Math.round(safeValue))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEraserSizeRatio = (size: number): number =>
|
||||||
|
(size - appConfig.toolbar.eraser.min) /
|
||||||
|
(appConfig.toolbar.eraser.max - appConfig.toolbar.eraser.min);
|
||||||
|
|
||||||
|
const clampMirrorSegmentCount = (value: number): number => {
|
||||||
|
const safeValue = Number.isFinite(value) ? value : appConfig.toolbar.mirror.default;
|
||||||
|
return Math.min(
|
||||||
|
appConfig.toolbar.mirror.max,
|
||||||
|
Math.max(appConfig.toolbar.mirror.min, Math.round(safeValue))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMirrorSegmentRatio = (count: number): number =>
|
||||||
|
(count - appConfig.toolbar.mirror.min) /
|
||||||
|
(appConfig.toolbar.mirror.max - appConfig.toolbar.mirror.min);
|
||||||
|
|
||||||
|
const formatMirrorSegmentCount = (count: number): string =>
|
||||||
|
count === appConfig.toolbar.mirror.default
|
||||||
|
? 'Mirror off'
|
||||||
|
: `${count} ${appConfig.toolbar.mirror.names[count] ?? 'slices'}`;
|
||||||
|
|
||||||
|
const renderRuntimeMessage = (
|
||||||
|
container: HTMLElement,
|
||||||
|
error: Parameters<Parameters<typeof ErrorHandler.addOnErrorListener>[0]>[0]
|
||||||
|
) => {
|
||||||
|
const message = document.createElement('pre');
|
||||||
|
message.className = error.severity;
|
||||||
|
message.textContent = error.code ? `${error.message}\n${error.code}` : error.message;
|
||||||
|
message.setAttribute('role', error.severity === Severity.ERROR ? 'alert' : 'status');
|
||||||
|
message.setAttribute(
|
||||||
|
'aria-live',
|
||||||
|
error.severity === Severity.ERROR ? 'assertive' : 'polite'
|
||||||
|
);
|
||||||
|
container.append(message);
|
||||||
|
|
||||||
|
if (error.severity === Severity.ERROR) {
|
||||||
|
message.tabIndex = -1;
|
||||||
|
message.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const elements = {
|
const elements = {
|
||||||
aside: document.querySelector('aside') as HTMLDivElement,
|
aside: document.querySelector('aside') as HTMLDivElement,
|
||||||
infoButton: document.querySelector('button.info') as HTMLButtonElement,
|
infoButton: document.querySelector('button.info') as HTMLButtonElement,
|
||||||
infoElement: document.querySelector('.info-page') as HTMLDivElement,
|
infoElement: document.querySelector('.info-page') as HTMLDivElement,
|
||||||
settingsPage: document.querySelector('.settings-page') as HTMLDivElement,
|
|
||||||
settingsContent: document.querySelector('.settings-content') as HTMLDivElement,
|
|
||||||
applyDefaults: document.querySelector('#apply-defaults') as HTMLButtonElement,
|
|
||||||
minimizeFullScreenButton: document.querySelector(
|
minimizeFullScreenButton: document.querySelector(
|
||||||
'button.minimize-full-screen'
|
'button.minimize-full-screen'
|
||||||
) as HTMLButtonElement,
|
) as HTMLButtonElement,
|
||||||
|
|
@ -28,9 +73,107 @@ const elements = {
|
||||||
'button.maximize-full-screen'
|
'button.maximize-full-screen'
|
||||||
) as HTMLButtonElement,
|
) as HTMLButtonElement,
|
||||||
settingsButton: document.querySelector('button.settings') as HTMLButtonElement,
|
settingsButton: document.querySelector('button.settings') as HTMLButtonElement,
|
||||||
|
soundButton: document.querySelector('button.sound') as HTMLButtonElement,
|
||||||
restartButton: document.querySelector('button.restart') as HTMLButtonElement,
|
restartButton: document.querySelector('button.restart') as HTMLButtonElement,
|
||||||
canvas: document.querySelector('canvas') as HTMLCanvasElement,
|
canvas: document.querySelector('canvas') as HTMLCanvasElement,
|
||||||
|
eraserPreview: document.querySelector('.eraser-preview') as HTMLDivElement,
|
||||||
errorContainer: document.querySelector('.errors-container') as HTMLDivElement,
|
errorContainer: document.querySelector('.errors-container') as HTMLDivElement,
|
||||||
|
previousVibe: document.querySelector('.previous-vibe') as HTMLButtonElement,
|
||||||
|
nextVibe: document.querySelector('.next-vibe') as HTMLButtonElement,
|
||||||
|
swatches: Array.from(
|
||||||
|
document.querySelectorAll('.color-swatch')
|
||||||
|
) as Array<HTMLButtonElement>,
|
||||||
|
eraserSizeControl: document.querySelector('.eraser-size-control') as HTMLLabelElement,
|
||||||
|
eraserSizeSlider: document.querySelector('.eraser-size-slider') as HTMLInputElement,
|
||||||
|
mirrorSegmentControl: document.querySelector(
|
||||||
|
'.mirror-segment-control'
|
||||||
|
) as HTMLLabelElement,
|
||||||
|
mirrorSegmentSlider: document.querySelector(
|
||||||
|
'.mirror-segment-slider'
|
||||||
|
) as HTMLInputElement,
|
||||||
|
export4k: document.querySelector('.export-4k') as HTMLButtonElement,
|
||||||
|
exportStatus: document.querySelector('.export-status') as HTMLSpanElement,
|
||||||
|
prompt: document.querySelector('.garden-prompt') as HTMLDivElement,
|
||||||
|
};
|
||||||
|
|
||||||
|
let isAudioMuted = localStorage.getItem(appConfig.storage.audioMutedKey) === '1';
|
||||||
|
|
||||||
|
const renderAudioUi = (game: GameLoop | null) => {
|
||||||
|
elements.soundButton.classList.toggle('muted', isAudioMuted);
|
||||||
|
elements.soundButton.setAttribute('aria-pressed', String(isAudioMuted));
|
||||||
|
elements.soundButton.setAttribute(
|
||||||
|
'aria-label',
|
||||||
|
isAudioMuted ? 'Unmute audio' : 'Mute audio'
|
||||||
|
);
|
||||||
|
elements.soundButton.title = isAudioMuted ? 'Unmute audio' : 'Mute audio';
|
||||||
|
game?.setAudioMuted(isAudioMuted);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderPaletteUi = (game: GameLoop | null) => {
|
||||||
|
const isErasing = elements.eraserSizeControl.dataset.active === '1';
|
||||||
|
elements.swatches.forEach((swatch, index) => {
|
||||||
|
swatch.style.backgroundColor = activeVibe.colors[index];
|
||||||
|
swatch.classList.toggle(
|
||||||
|
'active',
|
||||||
|
settings.selectedColorIndex === index && !isErasing
|
||||||
|
);
|
||||||
|
});
|
||||||
|
elements.eraserSizeControl.classList.toggle('active', isErasing);
|
||||||
|
game?.setEraseMode(isErasing);
|
||||||
|
document.documentElement.style.setProperty(
|
||||||
|
'--garden-background',
|
||||||
|
activeVibe.backgroundColor
|
||||||
|
);
|
||||||
|
game?.onVibeChanged();
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderEraserSizeUi = (game: GameLoop | null) => {
|
||||||
|
const size = clampEraserSize(settings.eraserSize);
|
||||||
|
if (settings.eraserSize !== size) {
|
||||||
|
settings.eraserSize = size;
|
||||||
|
}
|
||||||
|
|
||||||
|
elements.eraserSizeSlider.min = appConfig.toolbar.eraser.min.toString();
|
||||||
|
elements.eraserSizeSlider.max = appConfig.toolbar.eraser.max.toString();
|
||||||
|
elements.eraserSizeSlider.step = appConfig.toolbar.eraser.step.toString();
|
||||||
|
elements.eraserSizeSlider.value = size.toString();
|
||||||
|
elements.eraserSizeSlider.setAttribute('aria-valuetext', `${size}px`);
|
||||||
|
|
||||||
|
const ratio = getEraserSizeRatio(size);
|
||||||
|
const scale =
|
||||||
|
appConfig.toolbar.eraser.controlScaleMin +
|
||||||
|
(appConfig.toolbar.eraser.controlScaleMax -
|
||||||
|
appConfig.toolbar.eraser.controlScaleMin) *
|
||||||
|
ratio;
|
||||||
|
elements.eraserSizeControl.style.setProperty('--eraser-progress', `${ratio * 100}%`);
|
||||||
|
elements.eraserSizeControl.style.setProperty(
|
||||||
|
'--eraser-control-scale',
|
||||||
|
scale.toFixed(3)
|
||||||
|
);
|
||||||
|
game?.updateEraserPreview();
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderMirrorSegmentUi = () => {
|
||||||
|
const count = clampMirrorSegmentCount(settings.mirrorSegmentCount);
|
||||||
|
if (settings.mirrorSegmentCount !== count) {
|
||||||
|
settings.mirrorSegmentCount = count;
|
||||||
|
}
|
||||||
|
|
||||||
|
elements.mirrorSegmentSlider.min = appConfig.toolbar.mirror.min.toString();
|
||||||
|
elements.mirrorSegmentSlider.max = appConfig.toolbar.mirror.max.toString();
|
||||||
|
elements.mirrorSegmentSlider.step = appConfig.toolbar.mirror.step.toString();
|
||||||
|
elements.mirrorSegmentSlider.value = count.toString();
|
||||||
|
|
||||||
|
const label = formatMirrorSegmentCount(count);
|
||||||
|
const ratio = getMirrorSegmentRatio(count);
|
||||||
|
elements.mirrorSegmentSlider.setAttribute('aria-valuetext', label);
|
||||||
|
elements.mirrorSegmentControl.title = label;
|
||||||
|
elements.mirrorSegmentControl.classList.toggle('active', count > 1);
|
||||||
|
elements.mirrorSegmentControl.style.setProperty('--mirror-progress', `${ratio * 100}%`);
|
||||||
|
elements.mirrorSegmentControl.style.setProperty(
|
||||||
|
'--mirror-angle',
|
||||||
|
`${(360 / count).toFixed(3)}deg`
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const main = async () => {
|
const main = async () => {
|
||||||
|
|
@ -38,36 +181,48 @@ const main = async () => {
|
||||||
let shouldStop = false;
|
let shouldStop = false;
|
||||||
let game: GameLoop | null = null;
|
let game: GameLoop | null = null;
|
||||||
|
|
||||||
|
elements.errorContainer.setAttribute('aria-live', 'assertive');
|
||||||
ErrorHandler.addOnErrorListener((error, _metadata) => {
|
ErrorHandler.addOnErrorListener((error, _metadata) => {
|
||||||
elements.errorContainer.innerHTML += `
|
renderRuntimeMessage(elements.errorContainer, error);
|
||||||
<pre class="${error.severity}">${error.message}</div>
|
if (error.severity === Severity.ERROR) {
|
||||||
`;
|
game?.destroy();
|
||||||
game?.destroy();
|
shouldStop = true;
|
||||||
shouldStop = true;
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const syncRuntimeUi = () => {
|
||||||
|
renderEraserSizeUi(game);
|
||||||
|
renderMirrorSegmentUi();
|
||||||
|
renderPaletteUi(game);
|
||||||
|
};
|
||||||
|
|
||||||
const infoPageHandler = new CollapsiblePanelAnimator(
|
const infoPageHandler = new CollapsiblePanelAnimator(
|
||||||
elements.infoButton,
|
elements.infoButton,
|
||||||
elements.infoElement,
|
elements.infoElement,
|
||||||
elements.aside
|
elements.aside
|
||||||
);
|
);
|
||||||
const settingsPageHandler = new CollapsiblePanelAnimator(
|
const configPane = new ConfigPane({
|
||||||
elements.settingsButton,
|
settingsButton: elements.settingsButton,
|
||||||
elements.settingsPage,
|
onConfigChange: syncRuntimeUi,
|
||||||
elements.aside
|
onRuntimeChange: syncRuntimeUi,
|
||||||
);
|
onRuntimeReset: () => {
|
||||||
settingsPageHandler.onOpen = infoPageHandler.close.bind(infoPageHandler);
|
resetSettings();
|
||||||
infoPageHandler.onOpen = settingsPageHandler.close.bind(settingsPageHandler);
|
syncRuntimeUi();
|
||||||
|
},
|
||||||
if (isProduction) {
|
onRestart: () => game?.destroy(),
|
||||||
infoPageHandler.open();
|
onVibeChange: (vibeId) => {
|
||||||
}
|
applyVibeSettings(vibeId);
|
||||||
|
syncRuntimeUi();
|
||||||
|
game?.playVibeChangeAudio(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
infoPageHandler.onOpen = configPane.close.bind(configPane);
|
||||||
|
|
||||||
new MenuHider(
|
new MenuHider(
|
||||||
elements.aside,
|
elements.aside,
|
||||||
() =>
|
() =>
|
||||||
FullScreenHandler.isInFullScreenMode() &&
|
FullScreenHandler.isInFullScreenMode() &&
|
||||||
!settingsPageHandler.isOpen &&
|
!configPane.isOpen &&
|
||||||
!infoPageHandler.isOpen
|
!infoPageHandler.isOpen
|
||||||
);
|
);
|
||||||
new FullScreenHandler(
|
new FullScreenHandler(
|
||||||
|
|
@ -76,31 +231,113 @@ const main = async () => {
|
||||||
document.body
|
document.body
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const fontsReady = document.fonts.ready.catch(() => undefined);
|
||||||
const gpu = await initializeGpu();
|
const gpu = await initializeGpu();
|
||||||
|
await fontsReady;
|
||||||
|
|
||||||
elements.restartButton.addEventListener('click', () => game?.destroy());
|
elements.restartButton.addEventListener('click', () => game?.destroy());
|
||||||
|
elements.soundButton.addEventListener('click', (event) => {
|
||||||
const deltaTimeCalculator = new DeltaTimeCalculator();
|
isAudioMuted = !isAudioMuted;
|
||||||
let sliders: Array<SettingsSlider<any>> = [];
|
localStorage.setItem(appConfig.storage.audioMutedKey, isAudioMuted ? '1' : '0');
|
||||||
|
renderAudioUi(game);
|
||||||
elements.applyDefaults.addEventListener('click', () => {
|
if (!isAudioMuted) {
|
||||||
resetSettings();
|
game?.startAudio(event.isTrusted);
|
||||||
sliders.forEach((slider) => slider.updateSliderValueBasedOnSource());
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
while (!shouldStop) {
|
const deltaTimeCalculator = new DeltaTimeCalculator();
|
||||||
const gameRules = new GameRules(performance.now() / 1000);
|
|
||||||
game = new GameLoop(elements.canvas, gpu, deltaTimeCalculator, gameRules);
|
|
||||||
|
|
||||||
if (sliders.length === 0) {
|
elements.previousVibe.addEventListener('click', (event) => {
|
||||||
sliders = setUpSettingsPage(elements.settingsContent, game.maxAgentCount);
|
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);
|
||||||
|
configPane.refresh();
|
||||||
|
syncRuntimeUi();
|
||||||
|
game?.playVibeChangeAudio(event.isTrusted);
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.nextVibe.addEventListener('click', (event) => {
|
||||||
|
const current = VIBE_PRESETS.findIndex((vibe) => vibe.id === activeVibe.id);
|
||||||
|
const vibe = VIBE_PRESETS[(current + 1) % VIBE_PRESETS.length];
|
||||||
|
applyVibeSettings(vibe.id);
|
||||||
|
configPane.refresh();
|
||||||
|
syncRuntimeUi();
|
||||||
|
game?.playVibeChangeAudio(event.isTrusted);
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.swatches.forEach((swatch, index) => {
|
||||||
|
swatch.addEventListener('click', () => {
|
||||||
|
settings.selectedColorIndex = index;
|
||||||
|
elements.eraserSizeControl.dataset.active = '0';
|
||||||
|
game?.setEraseMode(false);
|
||||||
|
renderPaletteUi(game);
|
||||||
|
configPane.refresh();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const activateEraser = () => {
|
||||||
|
elements.eraserSizeControl.dataset.active = '1';
|
||||||
|
renderPaletteUi(game);
|
||||||
|
};
|
||||||
|
|
||||||
|
elements.eraserSizeControl.addEventListener('pointerdown', activateEraser);
|
||||||
|
elements.eraserSizeControl.addEventListener('click', activateEraser);
|
||||||
|
elements.eraserSizeSlider.addEventListener('focus', activateEraser);
|
||||||
|
|
||||||
|
elements.eraserSizeSlider.addEventListener('input', () => {
|
||||||
|
settings.eraserSize = clampEraserSize(Number(elements.eraserSizeSlider.value));
|
||||||
|
elements.eraserSizeControl.dataset.active = '1';
|
||||||
|
renderEraserSizeUi(game);
|
||||||
|
renderPaletteUi(game);
|
||||||
|
configPane.refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.mirrorSegmentSlider.addEventListener('input', () => {
|
||||||
|
settings.mirrorSegmentCount = clampMirrorSegmentCount(
|
||||||
|
Number(elements.mirrorSegmentSlider.value)
|
||||||
|
);
|
||||||
|
elements.eraserSizeControl.dataset.active = '0';
|
||||||
|
renderMirrorSegmentUi();
|
||||||
|
renderPaletteUi(game);
|
||||||
|
configPane.refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.export4k.addEventListener('click', async () => {
|
||||||
|
if (!game || elements.export4k.disabled) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
elements.export4k.disabled = true;
|
||||||
|
try {
|
||||||
|
await game.export4K();
|
||||||
|
} catch (error) {
|
||||||
|
ErrorHandler.addException(error, { severity: Severity.WARNING });
|
||||||
|
} finally {
|
||||||
|
elements.export4k.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
renderPaletteUi(game);
|
||||||
|
renderEraserSizeUi(game);
|
||||||
|
renderMirrorSegmentUi();
|
||||||
|
renderAudioUi(game);
|
||||||
|
|
||||||
|
while (!shouldStop) {
|
||||||
|
game = new GameLoop(elements.canvas, gpu, deltaTimeCalculator, {
|
||||||
|
prompt: elements.prompt,
|
||||||
|
eraserPreview: elements.eraserPreview,
|
||||||
|
exportStatus: elements.exportStatus,
|
||||||
|
});
|
||||||
|
renderPaletteUi(game);
|
||||||
|
renderEraserSizeUi(game);
|
||||||
|
renderMirrorSegmentUi();
|
||||||
|
renderAudioUi(game);
|
||||||
|
|
||||||
await game.start();
|
await game.start();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const message = e instanceof Error ? (e.stack ?? e.message) : String(e);
|
ErrorHandler.addException(e);
|
||||||
ErrorHandler.addError(Severity.ERROR, message);
|
|
||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
export class CollapsiblePanelAnimator {
|
export class CollapsiblePanelAnimator {
|
||||||
|
private static nextPanelId = 0;
|
||||||
|
|
||||||
private _isOpen = false;
|
private _isOpen = false;
|
||||||
|
private focusBeforeOpen: HTMLElement | null = null;
|
||||||
|
|
||||||
public onOpen: () => unknown = () => {};
|
public onOpen: () => unknown = () => {};
|
||||||
public onClose: () => unknown = () => {};
|
public onClose: () => unknown = () => {};
|
||||||
|
|
@ -9,25 +12,64 @@ export class CollapsiblePanelAnimator {
|
||||||
private readonly collapsibleContent: HTMLElement,
|
private readonly collapsibleContent: HTMLElement,
|
||||||
ignoreForCloseOnClick: HTMLElement
|
ignoreForCloseOnClick: HTMLElement
|
||||||
) {
|
) {
|
||||||
|
const panelId =
|
||||||
|
collapsibleContent.id ||
|
||||||
|
`collapsible-panel-${CollapsiblePanelAnimator.nextPanelId++}`;
|
||||||
|
collapsibleContent.id = panelId;
|
||||||
|
|
||||||
|
toggleButton.setAttribute('aria-controls', panelId);
|
||||||
|
if (!collapsibleContent.hasAttribute('role')) {
|
||||||
|
collapsibleContent.setAttribute('role', 'region');
|
||||||
|
}
|
||||||
|
if (!collapsibleContent.hasAttribute('aria-label')) {
|
||||||
|
const label =
|
||||||
|
toggleButton.getAttribute('aria-label') || toggleButton.textContent?.trim();
|
||||||
|
collapsibleContent.setAttribute('aria-label', `${label || 'Panel'} panel`);
|
||||||
|
}
|
||||||
|
if (!collapsibleContent.hasAttribute('tabindex')) {
|
||||||
|
collapsibleContent.tabIndex = -1;
|
||||||
|
}
|
||||||
|
|
||||||
toggleButton.addEventListener('click', this.toggle.bind(this));
|
toggleButton.addEventListener('click', this.toggle.bind(this));
|
||||||
window.addEventListener(
|
window.addEventListener(
|
||||||
'click',
|
'click',
|
||||||
(event) => !ignoreForCloseOnClick.contains(event.target as Node) && this.close()
|
(event) => !ignoreForCloseOnClick.contains(event.target as Node) && this.close()
|
||||||
);
|
);
|
||||||
|
window.addEventListener('keydown', (event) => {
|
||||||
|
if (this._isOpen && event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.syncAccessibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
public open() {
|
public open() {
|
||||||
|
if (this._isOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.focusBeforeOpen =
|
||||||
|
document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||||
this._isOpen = true;
|
this._isOpen = true;
|
||||||
this.collapsibleContent.classList.remove('hidden');
|
this.syncAccessibility();
|
||||||
this.toggleButton.classList.add('active');
|
|
||||||
this.onOpen();
|
this.onOpen();
|
||||||
|
this.focusPanel();
|
||||||
}
|
}
|
||||||
|
|
||||||
public close() {
|
public close() {
|
||||||
|
if (!this._isOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusWasInside = this.collapsibleContent.contains(document.activeElement);
|
||||||
this._isOpen = false;
|
this._isOpen = false;
|
||||||
this.collapsibleContent.classList.add('hidden');
|
this.syncAccessibility();
|
||||||
this.toggleButton.classList.remove('active');
|
|
||||||
this.onClose();
|
this.onClose();
|
||||||
|
|
||||||
|
if (focusWasInside) {
|
||||||
|
(this.focusBeforeOpen ?? this.toggleButton).focus({ preventScroll: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public toggle() {
|
public toggle() {
|
||||||
|
|
@ -41,4 +83,20 @@ export class CollapsiblePanelAnimator {
|
||||||
public get isOpen() {
|
public get isOpen() {
|
||||||
return this._isOpen;
|
return this._isOpen;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private syncAccessibility() {
|
||||||
|
this.collapsibleContent.classList.toggle('hidden', !this._isOpen);
|
||||||
|
this.toggleButton.classList.toggle('active', this._isOpen);
|
||||||
|
this.toggleButton.setAttribute('aria-expanded', String(this._isOpen));
|
||||||
|
this.collapsibleContent.setAttribute('aria-hidden', String(!this._isOpen));
|
||||||
|
this.collapsibleContent.inert = !this._isOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
private focusPanel() {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (this._isOpen) {
|
||||||
|
this.collapsibleContent.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
268
src/page/config-pane.ts
Normal file
268
src/page/config-pane.ts
Normal file
|
|
@ -0,0 +1,268 @@
|
||||||
|
import { Pane, type BindingParams, type FolderApi } from 'tweakpane';
|
||||||
|
|
||||||
|
import {
|
||||||
|
appConfig,
|
||||||
|
type GardenRuntimeSettings,
|
||||||
|
type NumberControlConfig,
|
||||||
|
} from '../config';
|
||||||
|
import { activeVibe, settings } from '../settings';
|
||||||
|
import { VIBE_PRESETS } from '../vibes';
|
||||||
|
|
||||||
|
type PaneContainer = Pick<FolderApi, 'addBinding' | 'addButton' | 'addFolder'>;
|
||||||
|
|
||||||
|
interface ConfigPaneOptions {
|
||||||
|
onConfigChange: () => void;
|
||||||
|
onRestart: () => void;
|
||||||
|
onRuntimeChange: () => void;
|
||||||
|
onRuntimeReset: () => void;
|
||||||
|
onVibeChange: (vibeId: string) => void;
|
||||||
|
settingsButton: HTMLButtonElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
typeof value === 'object' && value !== null;
|
||||||
|
|
||||||
|
const isBindablePrimitive = (value: unknown): value is boolean | number | string =>
|
||||||
|
['boolean', 'number', 'string'].includes(typeof value);
|
||||||
|
|
||||||
|
const isColorString = (value: unknown): value is string =>
|
||||||
|
typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value);
|
||||||
|
|
||||||
|
const toLabel = (value: string): string =>
|
||||||
|
value
|
||||||
|
.replace(/\[(\d+)\]/g, ' $1')
|
||||||
|
.replace(/([A-Z])/g, ' $1')
|
||||||
|
.replace(/[-_]/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const normalizeNumber = (value: number, config: NumberControlConfig): number => {
|
||||||
|
const finiteValue = Number.isFinite(value) ? value : config.min;
|
||||||
|
const clampedValue = Math.min(config.max, Math.max(config.min, finiteValue));
|
||||||
|
return config.integer ? Math.round(clampedValue) : clampedValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNumberBindingParams = (
|
||||||
|
key: keyof GardenRuntimeSettings & string,
|
||||||
|
config: NumberControlConfig
|
||||||
|
): BindingParams => ({
|
||||||
|
label: config.label ?? toLabel(key),
|
||||||
|
min: config.min,
|
||||||
|
max: config.max,
|
||||||
|
step: config.step,
|
||||||
|
});
|
||||||
|
|
||||||
|
export class ConfigPane {
|
||||||
|
private readonly container: HTMLDivElement;
|
||||||
|
private readonly pane: Pane;
|
||||||
|
private readonly state = {
|
||||||
|
activeVibeId: activeVibe.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
public constructor(private readonly options: ConfigPaneOptions) {
|
||||||
|
this.container = document.createElement('div');
|
||||||
|
this.container.className = 'config-pane-container';
|
||||||
|
Object.assign(this.container.style, {
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
maxHeight: 'calc(100vh - 24px)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
position: 'fixed',
|
||||||
|
right: 'max(12px, env(safe-area-inset-right, 0px))',
|
||||||
|
top: 'max(12px, env(safe-area-inset-top, 0px))',
|
||||||
|
width:
|
||||||
|
'min(420px, calc(100vw - 24px - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px)))',
|
||||||
|
zIndex: '20',
|
||||||
|
});
|
||||||
|
document.body.appendChild(this.container);
|
||||||
|
|
||||||
|
this.pane = new Pane({
|
||||||
|
container: this.container,
|
||||||
|
title: appConfig.tuningPane.title,
|
||||||
|
expanded: true,
|
||||||
|
});
|
||||||
|
this.pane.hidden = appConfig.tuningPane.startHidden;
|
||||||
|
this.pane.element.classList.add('config-pane');
|
||||||
|
this.pane.element.style.boxSizing = 'border-box';
|
||||||
|
this.pane.element.style.maxHeight = 'calc(100vh - 24px)';
|
||||||
|
this.pane.element.style.overflowY = 'auto';
|
||||||
|
this.pane.element.style.pointerEvents = 'auto';
|
||||||
|
this.pane.element.style.width = '100%';
|
||||||
|
|
||||||
|
this.options.settingsButton.addEventListener('click', this.toggle);
|
||||||
|
|
||||||
|
const tabs = this.pane.addTab({
|
||||||
|
pages: [{ title: 'Runtime' }, { title: 'Config' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
this.setUpRuntimeTab(tabs.pages[0]);
|
||||||
|
this.setUpConfigTab(tabs.pages[1]);
|
||||||
|
this.syncButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
public get isOpen(): boolean {
|
||||||
|
return !this.pane.hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
public refresh(): void {
|
||||||
|
this.state.activeVibeId = activeVibe.id;
|
||||||
|
this.pane.refresh();
|
||||||
|
this.syncButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly toggle = () => {
|
||||||
|
this.pane.hidden = !this.pane.hidden;
|
||||||
|
this.syncButton();
|
||||||
|
};
|
||||||
|
|
||||||
|
private setHidden(isHidden: boolean): void {
|
||||||
|
this.pane.hidden = isHidden;
|
||||||
|
this.syncButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setUpRuntimeTab(container: PaneContainer): void {
|
||||||
|
container
|
||||||
|
.addBinding(this.state, 'activeVibeId', {
|
||||||
|
label: 'active vibe',
|
||||||
|
options: Object.fromEntries(
|
||||||
|
VIBE_PRESETS.map((vibe) => [vibe.name, vibe.id])
|
||||||
|
) as Record<string, string>,
|
||||||
|
})
|
||||||
|
.on('change', ({ value }) => {
|
||||||
|
this.options.onVibeChange(value);
|
||||||
|
this.refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
container
|
||||||
|
.addButton({
|
||||||
|
title: 'Reset runtime settings',
|
||||||
|
})
|
||||||
|
.on('click', () => {
|
||||||
|
this.options.onRuntimeReset();
|
||||||
|
this.refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
container
|
||||||
|
.addButton({
|
||||||
|
title: 'Restart simulation',
|
||||||
|
})
|
||||||
|
.on('click', () => this.options.onRestart());
|
||||||
|
|
||||||
|
const folders = new Map<string, PaneContainer>();
|
||||||
|
Object.entries(appConfig.runtimeSettings.controls).forEach(([key, config]) => {
|
||||||
|
const folder =
|
||||||
|
folders.get(config.folder) ??
|
||||||
|
container.addFolder({
|
||||||
|
title: config.folder,
|
||||||
|
expanded: config.folder !== 'Runtime',
|
||||||
|
});
|
||||||
|
folders.set(config.folder, folder);
|
||||||
|
|
||||||
|
const settingKey = key as keyof GardenRuntimeSettings & string;
|
||||||
|
settings[settingKey] = normalizeNumber(settings[settingKey], config);
|
||||||
|
folder
|
||||||
|
.addBinding(settings, settingKey, getNumberBindingParams(settingKey, config))
|
||||||
|
.on('change', () => {
|
||||||
|
const nextValue = normalizeNumber(settings[settingKey], config);
|
||||||
|
if (nextValue !== settings[settingKey]) {
|
||||||
|
settings[settingKey] = nextValue;
|
||||||
|
this.pane.refresh();
|
||||||
|
}
|
||||||
|
this.options.onRuntimeChange();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private setUpConfigTab(container: PaneContainer): void {
|
||||||
|
this.addObjectBindings(
|
||||||
|
container,
|
||||||
|
appConfig as unknown as Record<string, unknown>,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private addObjectBindings(
|
||||||
|
container: PaneContainer,
|
||||||
|
source: Record<string, unknown>,
|
||||||
|
path: Array<string>
|
||||||
|
): void {
|
||||||
|
Object.entries(source).forEach(([key, value]) => {
|
||||||
|
if (isBindablePrimitive(value)) {
|
||||||
|
this.addPrimitiveBinding(container, source, key, value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const folder = container.addFolder({
|
||||||
|
title: toLabel(`${key}[]`),
|
||||||
|
expanded: path.length < appConfig.tuningPane.expandedDepth,
|
||||||
|
});
|
||||||
|
value.forEach((item, index) => {
|
||||||
|
if (isBindablePrimitive(item)) {
|
||||||
|
this.addPrimitiveBinding(
|
||||||
|
folder,
|
||||||
|
value as unknown as Record<string, unknown>,
|
||||||
|
`${index}`,
|
||||||
|
item
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPlainObject(item)) {
|
||||||
|
this.addObjectBindings(
|
||||||
|
folder.addFolder({
|
||||||
|
title: `[${index}]`,
|
||||||
|
expanded: false,
|
||||||
|
}),
|
||||||
|
item,
|
||||||
|
[...path, key, String(index)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPlainObject(value)) {
|
||||||
|
this.addObjectBindings(
|
||||||
|
container.addFolder({
|
||||||
|
title: toLabel(key),
|
||||||
|
expanded: path.length < appConfig.tuningPane.expandedDepth,
|
||||||
|
}),
|
||||||
|
value,
|
||||||
|
[...path, key]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private addPrimitiveBinding(
|
||||||
|
container: PaneContainer,
|
||||||
|
source: Record<string, unknown>,
|
||||||
|
key: string,
|
||||||
|
value: boolean | number | string
|
||||||
|
): void {
|
||||||
|
const params: BindingParams = {
|
||||||
|
label: toLabel(key),
|
||||||
|
...(isColorString(value) ? { color: { type: 'int' } } : {}),
|
||||||
|
...(key === 'quality' ? { options: { major: 'major', minor: 'minor' } } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
container
|
||||||
|
.addBinding(source, key, params)
|
||||||
|
.on('change', () => this.options.onConfigChange());
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncButton(): void {
|
||||||
|
this.options.settingsButton.setAttribute('aria-expanded', String(this.isOpen));
|
||||||
|
this.options.settingsButton.setAttribute(
|
||||||
|
'aria-label',
|
||||||
|
this.isOpen ? 'Hide config overlay' : 'Show config overlay'
|
||||||
|
);
|
||||||
|
this.options.settingsButton.title = this.isOpen
|
||||||
|
? 'Hide config overlay'
|
||||||
|
: 'Show config overlay';
|
||||||
|
}
|
||||||
|
|
||||||
|
public close(): void {
|
||||||
|
this.setHidden(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue