This commit is contained in:
Andras Schmelczer 2026-05-13 21:07:10 +01:00
parent 34ac200437
commit 39b0160064
136 changed files with 7144 additions and 1965 deletions

View file

@ -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

View file

@ -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
View file

@ -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",

View file

@ -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"
} }
} }

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -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

View file

@ -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"

View file

@ -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' },
],
},
},
};

View 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);
});
});

View file

@ -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 {
if (this.currentGesture && !this.currentGesture.isErasing) {
this.addGestureTail(this.currentGesture, now);
} }
public endGesture(): void {
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; public recordEraserStroke(): void {
this.currentGesture.distancePixels += distancePixels; this.targetEnergy = 0;
this.currentGesture.peakEnergy = Math.max(
this.currentGesture.peakEnergy,
strokeEnergy
);
}
public recordEraserStroke(now: number): void {
this.currentGesture = this.currentGesture ?? createGesture(now, true);
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,
});

View file

@ -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;
} }
} }

View file

@ -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);
}; };

View file

@ -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;
}
}

View file

@ -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;
} }

View file

@ -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(() => {

View file

@ -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,18 +68,12 @@ 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 {
const previousVibeId = this.currentVibeId; const previousVibeId = this.currentVibeId;
@ -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;
}
} }

View 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);
});
});

View 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;
};

View file

@ -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',

View file

@ -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 { try {
voice.source.stop(now + 0.28); 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 {
voice.source.stop(stopAt);
} catch { } catch {
// The source may already have a stop time scheduled. // The source may already have a stop time scheduled.
} }

View file

@ -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
View 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,
},
};

View 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;
}
}
}

View 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);
}
}

View 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;
};

View 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
View 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;
};

View 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,
});
}
}

View 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];'
);
});
});

View 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,
});
}
}

View 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;
}

View file

@ -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);
this.agentPopulation.growBudget(
deltaTime,
this.framePerformance.smoothedFps,
this.framePerformance.refreshTargetFps
);
this.introPrompt.update();
this.resize();
this.resizeSimulationToCanvas();
time *= settings.renderSpeed; const scaledTime = time * settings.renderSpeed;
const timeInSeconds = time / 1000; const { channelColors, backgroundColor } = this.renderInputs.get();
const spawnAction = this.gameRules.getSpawnAction(timeInSeconds, this.canvasSize); 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.resources.setFrameParameters({
this.commonState, time: scaledTime,
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, deltaTime,
canvasSize: this.canvasSize, canvasSize: this.canvasSize,
brushColor: GamePresentation.getGenerationColor( activeAgentCount: this.agentPopulation.activeAgentCount,
this.gameRules.nextGenerationId - 1 introProgress,
), selectedColorIndex: settings.selectedColorIndex,
evenGenerationColor: GamePresentation.getGenerationColor( isErasing,
this.gameRules.nextGenerationId % 2 == 0 channelColors,
? this.gameRules.nextGenerationId backgroundColor,
: this.gameRules.nextGenerationId - 1 cameraCenter,
), cameraZoom,
oddGenerationColor: GamePresentation.getGenerationColor( eraserPixelSize,
this.gameRules.nextGenerationId % 2 == 1 });
? this.gameRules.nextGenerationId
: this.gameRules.nextGenerationId - 1
),
...settings,
center: spawnAction.position,
radius: spawnAction.radius,
})
);
for (let i = 0; i < settings.renderSpeed; i++) { const encodeCpuStartedAt = this.framePerformance.markCpuStart();
const commandEncoder = this.device.createCommandEncoder(); this.resources.executeFrame(settings.renderSpeed, isErasing);
const encodeCpuMs = this.framePerformance.measureSince(encodeCpuStartedAt);
this.copyPipeline.execute( this.pointerInput.clearSwipesIfIdle();
commandEncoder, await this.agentPopulation.compactAfterErase(this.pointerInput.isSwipeActive);
this.trailMapA.getTextureView(),
this.trailMapB.getTextureView()
);
this.brushPipeline.execute(commandEncoder, this.trailMapB.getTextureView());
this.agentPipeline.execute(
commandEncoder,
this.trailMapA.getTextureView(),
this.trailMapB.getTextureView()
);
this.diffusionPipeline.execute(
commandEncoder,
this.trailMapB.getTextureView(),
this.trailMapA.getTextureView()
);
this.renderPipeline.execute(commandEncoder, this.trailMapA.getTextureView());
this.device.queue.submit([commandEncoder.finish()]); this.framePerformance.renderTelemetry({
} frameCpuStartedAt,
encodeCpuMs,
if (!this.isSwipeActive) { activeAgentCount: this.agentPopulation.activeAgentCount,
this.brushPipeline.clearSwipes(); 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;
} }
public async destroy() { this.canvas.width = width;
this.hasFinished = true; this.canvas.height = height;
await this.finished.promise; }
this.copyPipeline?.destroy(); private resizeSimulationToCanvas(): void {
this.agentGenerationPipeline?.destroy(); const scale = this.resources.resizeSimulationTo(this.canvasSize);
this.agentPipeline?.destroy(); if (!scale) {
this.brushPipeline?.destroy(); return;
this.diffusionPipeline?.destroy(); }
this.renderPipeline?.destroy();
this.commonState?.destroy(); this.agentPopulation.resizeAgents(scale);
this.trailMapA?.destroy(); this.pointerInput.scaleLastPointerPosition(scale);
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)));
} }
} }

View 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();
}
}

View 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];
};

View 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
);
};

View 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);
}
}

View 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();
}
}

View 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();
}
}

View file

@ -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);
} }
}; };

View file

@ -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
View 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