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'
run: |
apt update && apt install -y rsync
mkdir -p /pages
rsync -a --delete dist/ /pages/fleeting-garden

View file

@ -41,8 +41,8 @@
</canvas>
<p id="canvas-description" class="visually-hidden">
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
settings, export, restart, or open more information.
to paint coloured paths, then use the toolbar to change colours, erase, adjust the
config overlay, export, restart, or open more information.
</p>
<div class="eraser-preview" aria-hidden="true"></div>
<div class="garden-prompt" aria-live="polite"></div>
@ -74,18 +74,6 @@
</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">
<button
class="previous-vibe vibe-button"
@ -157,10 +145,9 @@
></button>
<button
class="settings"
aria-label="Settings"
aria-controls="settings-panel"
aria-label="Show config overlay"
aria-expanded="false"
title="Settings"
title="Show config overlay"
></button>
<button
class="sound"
@ -170,8 +157,8 @@
></button>
<button
class="export-4k"
aria-label="Download 4K image"
title="Download 4K image"
aria-label="Download 4K upscale image"
title="Download 4K upscale of the live simulation"
></button>
<span class="export-status" aria-live="polite"></span>
<button

35
package-lock.json generated
View file

@ -9,19 +9,22 @@
"version": "0.2.0",
"license": "Unlicense",
"dependencies": {
"gl-matrix": "^3.4.4"
"tweakpane": "^4.0.5"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@ianvs/prettier-plugin-sort-imports": "^4.7.1",
"@tweakpane/core": "^2.0.5",
"@types/node": "^25.6.0",
"@vite-pwa/assets-generator": "^1.0.2",
"@vitejs/plugin-basic-ssl": "^2.3.0",
"@webgpu/types": "^0.1.69",
"browserslist": "^4.28.2",
"eslint": "^10.3.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-unused-imports": "^4.4.1",
"gl-matrix": "^3.4.4",
"globals": "^17.6.0",
"lightningcss": "^1.32.0",
"npm-check-updates": "^22.1.0",
@ -1560,6 +1563,13 @@
"dev": true,
"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": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@ -1874,6 +1884,19 @@
"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": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
@ -2740,6 +2763,7 @@
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
"dev": true,
"license": "MIT"
},
"node_modules/glob-parent": {
@ -3831,6 +3855,15 @@
"license": "0BSD",
"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": {
"version": "0.4.0",
"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"
],
"devDependencies": {
"gl-matrix": "^3.4.4",
"@eslint/js": "^10.0.1",
"@ianvs/prettier-plugin-sort-imports": "^4.7.1",
"@tweakpane/core": "^2.0.5",
"@types/node": "^25.6.0",
"@vite-pwa/assets-generator": "^1.0.2",
"@vitejs/plugin-basic-ssl": "^2.3.0",
"@webgpu/types": "^0.1.69",
"browserslist": "^4.28.2",
"eslint": "^10.3.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-unused-imports": "^4.4.1",
"gl-matrix": "^3.4.4",
"globals": "^17.6.0",
"lightningcss": "^1.32.0",
"npm-check-updates": "^22.1.0",
@ -59,5 +61,8 @@
"vite": "^8.0.10",
"vite-plugin-singlefile": "^2.3.3",
"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,
distributed under CC BY 3.0.
Piano samples are Salamander Grand Piano V3 samples by Alexander Holm,
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 recording: https://archive.org/details/SalamanderGrandPianoV3

View file

@ -2,8 +2,8 @@
"name": "Fleeting Garden",
"short_name": "Garden",
"description": "A joyful WebGPU drawing garden where coloured paths grow into organic agent trails.",
"start_url": "/",
"scope": "/",
"start_url": "./",
"scope": "./",
"display": "fullscreen",
"display_override": ["fullscreen", "standalone", "minimal-ui"],
"orientation": "any",
@ -11,28 +11,28 @@
"theme_color": "#10151f",
"icons": [
{
"src": "/favicon.svg",
"src": "favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/pwa-64x64.png",
"src": "pwa-64x64.png",
"sizes": "64x64",
"type": "image/png"
},
{
"src": "/pwa-192x192.png",
"src": "pwa-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/pwa-512x512.png",
"src": "pwa-512x512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/maskable-icon-512x512.png",
"src": "maskable-icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"

View file

@ -1,3 +1,5 @@
import { appConfig } from '../config';
export type GardenAudioChordQuality = 'major' | 'minor';
export interface GardenAudioChord {
@ -7,7 +9,6 @@ export interface GardenAudioChord {
export interface GardenAudioColorVoice {
scaleDegreeOffset: number;
octaveOffset: number;
velocityMultiplier: number;
panOffset: number;
}
@ -21,19 +22,11 @@ export interface GardenAudioVibeProfile {
}
export interface GardenAudioConfig {
enabled: boolean;
masterVolume: number;
fadeInSeconds: number;
updateRampSeconds: number;
highPassFrequencyHz: number;
fallbackVibeId: string;
startup: {
calmDurationSeconds: number;
initialTempoMultiplier: number;
initialEnergyMultiplier: number;
initialActivityCeiling: number;
initialTapIntervalMultiplier: number;
};
compressor: {
thresholdDb: number;
kneeDb: number;
@ -42,7 +35,6 @@ export interface GardenAudioConfig {
releaseSeconds: number;
};
delay: {
enabled: boolean;
timeSeconds: number;
feedback: number;
wetGain: number;
@ -54,7 +46,6 @@ export interface GardenAudioConfig {
sustainLevel: number;
releaseSeconds: number;
lowpassHz: number;
preloadOnStart: boolean;
};
input: {
pressureFallback: number;
@ -64,25 +55,10 @@ export interface GardenAudioConfig {
stepsPerBeat: number;
stepsPerBar: number;
lookaheadSeconds: number;
swing: number;
minTailSeconds: number;
maxTailSeconds: number;
tailDistanceForMaxPixels: number;
tailDurationForMaxSeconds: number;
tailDecayPower: number;
minTapIntervalSeconds: number;
speedForFullEnergyPixelsPerSecond: number;
sparseActivity: number;
arpeggioActivity: number;
fullChordActivity: number;
bassActivity: number;
melodySteps: Array<number>;
chordSteps: Array<number>;
bassSteps: Array<number>;
melodyPattern: Array<number>;
};
eraser: {
enabled: boolean;
minIntervalSeconds: number;
noiseGain: number;
filterMinHz: number;
@ -92,163 +68,4 @@ export interface GardenAudioConfig {
vibes: Record<string, GardenAudioVibeProfile>;
}
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];
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' },
],
},
},
};
export const gardenAudioConfig: GardenAudioConfig = appConfig.audio;

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 { GardenAudioConfig } from './garden-audio-config';
interface GardenGestureState {
startedAt: number;
lastAt: number;
distancePixels: number;
peakEnergy: number;
isErasing: boolean;
}
interface GestureTail {
startedAt: number;
durationSeconds: number;
level: number;
}
type GardenAudioRhythmConfig = GardenAudioConfig['rhythm'];
const STROKE_IMMEDIATE_ACTIVITY_SCALE = 0.85;
export class GardenAudioEnergy {
private isGestureActive = false;
private energy = 0;
private targetEnergy = 0;
private lastEnergyUpdateAt = 0;
private currentGesture: GardenGestureState | null = null;
private gestureTails: Array<GestureTail> = [];
public constructor(private readonly rhythm: GardenAudioRhythmConfig) {}
public beginGesture(now: number): void {
this.isGestureActive = true;
this.currentGesture = createGesture(now, false);
}
public endGesture(now: number): void {
if (this.currentGesture && !this.currentGesture.isErasing) {
this.addGestureTail(this.currentGesture, now);
this.lastEnergyUpdateAt = now;
}
public endGesture(): void {
this.isGestureActive = false;
this.currentGesture = null;
this.targetEnergy = 0;
}
public recordStroke(distancePixels: number, strokeEnergy: number, now: number): void {
if (!this.currentGesture || this.currentGesture.isErasing) {
this.currentGesture = createGesture(now, false);
public recordStroke(strokeEnergy: number, now: number): void {
const energy = clamp01(strokeEnergy);
this.targetEnergy = Math.max(this.targetEnergy, energy);
if (this.isGestureActive) {
this.energy = Math.max(this.energy, energy * STROKE_IMMEDIATE_ACTIVITY_SCALE);
}
this.lastEnergyUpdateAt ||= now;
}
this.currentGesture.lastAt = now;
this.currentGesture.distancePixels += distancePixels;
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 recordEraserStroke(): void {
this.targetEnergy = 0;
}
public silence(): void {
this.targetEnergy = 0;
this.gestureTails = [];
this.energy = 0;
}
public update(now: number): void {
@ -76,28 +45,31 @@ export class GardenAudioEnergy {
const elapsedSeconds = Math.max(0, now - this.lastEnergyUpdateAt);
this.lastEnergyUpdateAt = now;
this.targetEnergy *= Math.exp(-elapsedSeconds / 0.75);
this.trimGestureTails(now);
this.targetEnergy *= Math.exp(
-elapsedSeconds / appConfig.audioEngine.energy.strokeDecaySeconds
);
const activeGestureFloor =
this.isGestureActive && this.currentGesture && !this.currentGesture.isErasing
? 0.04 + this.getGestureAmount(this.currentGesture, now) * 0.3
: 0;
const target = Math.max(activeGestureFloor, this.targetEnergy);
const timeConstant = target > this.energy ? 0.08 : 0.55;
const target = this.isGestureActive ? this.targetEnergy : 0;
let timeConstant = appConfig.audioEngine.energy.decaySeconds;
if (!this.isGestureActive) {
timeConstant = appConfig.audioEngine.energy.releaseSeconds;
} else if (target > this.energy) {
timeConstant = appConfig.audioEngine.energy.attackSeconds;
}
const amount = 1 - Math.exp(-elapsedSeconds / timeConstant);
this.energy += (target - this.energy) * amount;
}
public getActivityAt(time: number): number {
const activeGesture =
this.isGestureActive && this.currentGesture && !this.currentGesture.isErasing
? 0.08 + this.getGestureAmount(this.currentGesture, time) * 0.34
: 0;
public getActivity(): number {
if (!this.isGestureActive) {
return 0;
}
return clamp01(
this.energy * 0.58 + this.getTailActivityAt(time) * 0.72 + activeGesture
);
return this.getLevel();
}
public getLevel(): number {
return clamp01(this.energy);
}
public reset(): void {
@ -105,72 +77,5 @@ export class GardenAudioEnergy {
this.energy = 0;
this.targetEnergy = 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 { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
@ -11,6 +12,7 @@ export class GardenAudioGraph {
private delayNode: DelayNode | null = null;
private delayFeedback: GainNode | null = null;
private delayOutput: GainNode | null = null;
private hasUnlocked = false;
public constructor(private readonly config: GardenAudioConfig) {}
@ -50,6 +52,26 @@ export class GardenAudioGraph {
return context;
}
// iOS WebKit (Safari + Chrome iOS) only fully unlocks audio output once
// a buffer source has been started inside a user-gesture handler. Calling
// resume() alone leaves the context "running" but silent.
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 {
if (!this.context || !this.masterGain) {
return;
@ -70,7 +92,7 @@ export class GardenAudioGraph {
this.delayNode.delayTime.setTargetAtTime(
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
this.context.currentTime,
0.12
appConfig.audioEngine.graph.delayTimeRampSeconds
);
}
@ -83,17 +105,22 @@ export class GardenAudioGraph {
this.delayNode.delayTime.setTargetAtTime(
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
now,
0.12
appConfig.audioEngine.graph.delayTimeRampSeconds
);
this.delayFeedback.gain.setTargetAtTime(
this.config.delay.enabled
? clamp(this.config.delay.feedback + activity * 0.08, 0.04, 0.32)
: 0,
clamp(
this.config.delay.feedback +
activity * appConfig.audioEngine.graph.delayActivityFeedbackWeight,
appConfig.audioEngine.graph.delayFeedbackMin,
appConfig.audioEngine.graph.delayFeedbackMax
),
now,
this.config.updateRampSeconds
);
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,
this.config.updateRampSeconds
);
@ -106,7 +133,11 @@ export class GardenAudioGraph {
}
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();
@ -123,8 +154,8 @@ export class GardenAudioGraph {
const delayOutput = context.createGain();
delayNode.delayTime.value = this.config.delay.timeSeconds;
delayFeedback.gain.value = this.config.delay.enabled ? this.config.delay.feedback : 0;
delayOutput.gain.value = this.config.delay.enabled ? this.config.delay.wetGain : 0;
delayFeedback.gain.value = this.config.delay.feedback;
delayOutput.gain.value = this.config.delay.wetGain;
delayInput.connect(delayNode);
delayNode.connect(delayFeedback);
@ -140,7 +171,7 @@ export class GardenAudioGraph {
private createBuses(context: AudioContext, masterGain: GainNode): void {
this.eventBus = context.createGain();
this.eventBus.gain.value = 1;
this.eventBus.gain.value = appConfig.audioEngine.graph.eventBusGain;
this.eventBus.connect(masterGain);
}
@ -149,7 +180,10 @@ export class GardenAudioGraph {
const data = buffer.getChannelData(0);
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;
@ -164,5 +198,6 @@ export class GardenAudioGraph {
this.delayNode = null;
this.delayFeedback = null;
this.delayOutput = null;
this.hasUnlocked = false;
}
}

View file

@ -1,3 +1,4 @@
import { appConfig } from '../config';
import { clamp01 } from '../utils/clamp';
import { GardenAudioStroke } from './garden-audio-types';
@ -19,8 +20,16 @@ export const getStrokeMetrics = (
const speedPixelsPerSecond = getStrokeVelocity(stroke, distancePixels);
const pressure = getPressureAmount(stroke, fallbackPressure);
const speedAmount = clamp01(speedPixelsPerSecond / speedForFullEnergyPixelsPerSecond);
const strokeEnergy = clamp01(0.18 + speedAmount * 0.62 + pressure * 0.22);
const effectiveEnergy = strokeEnergy * (0.25 + clamp01(distancePixels / 140) * 0.75);
const strokeEnergy = clamp01(
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 {
distancePixels,
@ -39,7 +48,7 @@ const getStrokeVelocity = (stroke: GardenAudioStroke, distancePixels: number): n
return stroke.velocityPixelsPerSecond;
}
return distancePixels / (1 / 60);
return distancePixels / appConfig.audioEngine.input.fallbackFrameSeconds;
};
const getPressureAmount = (
@ -55,6 +64,6 @@ const getPressureAmount = (
}
return stroke.pointerType === 'pen'
? Math.max(0.56, clamp01(fallbackPressure))
? Math.max(appConfig.audioEngine.input.penMinPressure, 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 {
vibe: VibePreset;
activeAgentCount: number;
agentBudgetMax: number;
selectedColorIndex: number;
isErasing: boolean;
introProgress: number;
moveSpeed: number;
diffusionRateTrails: number;
decayRateTrails: number;
brushEffectDuration: number;
clarity: number;
}
export interface GardenAudioStroke {
@ -41,6 +33,7 @@ export interface LoadedPianoSample {
export interface ActivePianoVoice {
gain: GainNode;
source: AudioBufferSourceNode;
startAt: number;
stopAt: number;
}

View file

@ -82,6 +82,18 @@ class FakeAudioContext {
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> {
calls.resumed += 1;
contextState = 'running';
@ -90,10 +102,6 @@ class FakeAudioContext {
const makeConfig = (): GardenAudioConfig => ({
...gardenAudioConfig,
piano: {
...gardenAudioConfig.piano,
preloadOnStart: false,
},
});
describe('GardenAudio startup policy', () => {
@ -102,6 +110,7 @@ describe('GardenAudio startup policy', () => {
calls.resumed = 0;
contextState = 'suspended';
vi.stubGlobal('AudioContext', FakeAudioContext);
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('not loaded in tests')));
});
afterEach(() => {

View file

@ -1,3 +1,4 @@
import { appConfig } from '../config';
import { clamp, clamp01 } from '../utils/clamp';
import { VibePreset } from '../vibes';
import { GardenAudioConfig } from './garden-audio-config';
@ -5,13 +6,13 @@ import { GardenAudioEnergy } from './garden-audio-energy';
import { GardenAudioGraph } from './garden-audio-graph';
import { getStrokeMetrics } from './garden-audio-input';
import { getVibeProfile, normalizeColorIndex } from './garden-audio-music';
import { GardenAudioScore } from './garden-audio-score';
import type {
GardenAudioColorIndex,
GardenAudioSnapshot,
GardenAudioStartOptions,
GardenAudioStroke,
} from './garden-audio-types';
import { GenerativePianoEngine } from './generative-piano';
import { NoiseBurstPlayer } from './noise-burst-player';
import { PianoSampler } from './piano-sampler';
@ -26,18 +27,14 @@ export class GardenAudio {
private readonly piano: PianoSampler;
private readonly noise: NoiseBurstPlayer;
private readonly energy: GardenAudioEnergy;
private readonly score: GardenAudioScore;
private readonly pianoEngine: GenerativePianoEngine;
private currentVibeId: string | null = null;
private hasStarted = false;
private isDestroyed = false;
private isMuted = false;
private isGestureActive = false;
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 lastVibeStingerAt = Number.NEGATIVE_INFINITY;
@ -45,12 +42,12 @@ export class GardenAudio {
this.graph = new GardenAudioGraph(config);
this.piano = new PianoSampler(config, this.graph);
this.noise = new NoiseBurstPlayer(this.graph);
this.energy = new GardenAudioEnergy(config.rhythm);
this.score = new GardenAudioScore(config, (note) => this.piano.play(note));
this.energy = new GardenAudioEnergy();
this.pianoEngine = new GenerativePianoEngine(config, (note) => this.piano.play(note));
}
public start(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
if (!this.config.enabled || this.isDestroyed || this.isMuted) {
if (this.isDestroyed || this.isMuted) {
return;
}
@ -59,6 +56,10 @@ export class GardenAudio {
return;
}
if (options.userGesture === true) {
this.graph.unlock();
}
if (context.state === 'suspended') {
if (options.userGesture !== true) {
return;
@ -67,18 +68,12 @@ export class GardenAudio {
}
this.hasStarted = true;
this.rhythmAnchorTime ??= context.currentTime;
this.startedAt ??= context.currentTime;
this.applyVibe(vibe);
if (this.nextStepAt <= 0) {
this.nextStepAt = context.currentTime + 0.02;
}
this.pianoEngine.prime(context.currentTime);
this.graph.setMasterGain(this.config.masterVolume, this.config.fadeInSeconds);
if (this.config.piano.preloadOnStart) {
void this.piano.load(context);
}
}
public changeVibe(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
const previousVibeId = this.currentVibeId;
@ -100,8 +95,8 @@ export class GardenAudio {
public setMuted(isMuted: boolean): void {
this.isMuted = isMuted;
this.graph.setMasterGain(
isMuted ? 0.0001 : this.config.masterVolume,
isMuted ? 0.02 : this.config.fadeInSeconds
isMuted ? appConfig.audioEngine.muteGain : this.config.masterVolume,
isMuted ? appConfig.audioEngine.muteRampSeconds : this.config.fadeInSeconds
);
}
@ -111,20 +106,21 @@ export class GardenAudio {
return;
}
this.isGestureActive = true;
this.energy.beginGesture(context.currentTime);
this.pianoEngine.beginGesture(context.currentTime);
}
public endGesture(): void {
const context = this.graph.context;
this.isGestureActive = false;
this.energy.endGesture();
this.pianoEngine.endGesture();
if (!context) {
return;
}
this.energy.endGesture(context.currentTime);
}
public rememberColor(colorIndex: number): void {
this.selectedColorIndex = normalizeColorIndex(colorIndex);
this.piano.fadeActive(context.currentTime, appConfig.audioEngine.gestureFadeSeconds);
}
public update(snapshot: GardenAudioSnapshot): void {
@ -139,17 +135,27 @@ export class GardenAudio {
if (snapshot.isErasing) {
this.energy.silence();
this.piano.fadeActive(context.currentTime);
this.piano.fadeActive(
context.currentTime,
appConfig.audioEngine.gestureFadeSeconds
);
this.updateDelay(snapshot);
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);
}
public stroke(stroke: GardenAudioStroke): void {
if (!this.config.enabled || this.isDestroyed || this.isMuted) {
if (this.isDestroyed || this.isMuted) {
return;
}
@ -158,6 +164,9 @@ export class GardenAudio {
if (!context) {
return;
}
if (!this.isGestureActive) {
return;
}
const metrics = getStrokeMetrics(
stroke,
@ -169,32 +178,14 @@ export class GardenAudio {
this.selectedColorIndex = normalizeColorIndex(stroke.colorIndex);
if (stroke.isErasing) {
this.energy.recordEraserStroke(now);
this.energy.recordEraserStroke();
this.playEraser(stroke, metrics.speedAmount, metrics.pressure, now);
return;
}
const strokeEnergy = metrics.effectiveEnergy * this.getStartupEnergyScale(now);
this.energy.recordStroke(metrics.distancePixels, strokeEnergy, now);
if (metrics.distancePixels >= 2.5) {
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),
});
}
const strokeEnergy = metrics.effectiveEnergy;
this.energy.recordStroke(strokeEnergy, now);
this.pianoEngine.wake(now);
}
public async destroy(): Promise<void> {
@ -203,50 +194,15 @@ export class GardenAudio {
this.piano.reset();
this.energy.reset();
this.pianoEngine.reset();
this.currentVibeId = null;
this.hasStarted = false;
this.isGestureActive = false;
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.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 {
const context = this.graph.context;
if (!context) {
@ -254,12 +210,15 @@ export class GardenAudio {
}
const now = context.currentTime;
if (now - this.lastVibeStingerAt < 0.45) {
if (
now - this.lastVibeStingerAt <
appConfig.audioEngine.vibeChangeStingerMinIntervalSeconds
) {
return;
}
this.lastVibeStingerAt = now;
this.score.playVibeChangeStinger(vibe, now);
this.pianoEngine.playVibeChangeStinger(vibe, now);
}
private playEraser(
@ -268,27 +227,38 @@ export class GardenAudio {
pressure: number,
now: number
): void {
if (!this.config.eraser.enabled || !this.graph.context) {
if (!this.graph.context) {
return;
}
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 filterHz =
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) {
this.lastEraserAt = now;
this.noise.play({
startTime: now,
durationSeconds: 0.08,
durationSeconds: appConfig.audioEngine.eraser.durationSeconds,
gain:
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,
pan: clamp(x * 2 - 1, -1, 1),
});
@ -303,11 +273,8 @@ export class GardenAudio {
const profile = getVibeProfile(this.config, snapshot.vibe);
const activity = snapshot.isErasing
? 0.12
: this.getSettledActivity(
this.energy.getActivityAt(context.currentTime),
context.currentTime
);
? appConfig.audioEngine.delay.erasingActivity
: this.energy.getLevel();
this.graph.updateDelay(profile, activity);
}
@ -319,68 +286,4 @@ export class GardenAudio {
this.currentVibeId = vibe.id;
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 { NoiseBurst } from './garden-audio-types';
@ -10,7 +11,10 @@ export class NoiseBurstPlayer {
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 filter = context.createBiquadFilter();
const envelope = context.createGain();
@ -20,20 +24,29 @@ export class NoiseBurstPlayer {
source.buffer = noiseBuffer;
filter.type = 'bandpass';
filter.frequency.setValueAtTime(filterHz, scheduledStart);
filter.Q.value = 1.4;
envelope.gain.setValueAtTime(0.0001, scheduledStart);
envelope.gain.exponentialRampToValueAtTime(
Math.max(0.0001, gain),
scheduledStart + 0.004
filter.Q.value = appConfig.audioEngine.noiseBurst.filterQ;
envelope.gain.setValueAtTime(
appConfig.audioEngine.noiseBurst.silentGain,
scheduledStart
);
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);
source.connect(filter);
filter.connect(envelope);
envelope.connect(panner);
panner.connect(eventBus);
source.start(scheduledStart, Math.random() * 0.4);
source.start(
scheduledStart,
Math.random() * appConfig.audioEngine.noiseBurst.offsetRandomSeconds
);
source.stop(stopAt);
source.addEventListener(
'ended',

View file

@ -1,3 +1,4 @@
import { appConfig } from '../config';
import { clamp, clamp01 } from '../utils/clamp';
import { GardenAudioConfig } from './garden-audio-config';
import { GardenAudioGraph } from './garden-audio-graph';
@ -22,6 +23,9 @@ export class PianoSampler {
this.sampleLoadPromise = Promise.all(
pianoSampleDefinitions.map(async (sample) => {
const response = await fetch(sample.url);
if (!response.ok) {
throw new Error(`Unable to load piano sample ${sample.url}`);
}
const audioData = await response.arrayBuffer();
const buffer = await context.decodeAudioData(audioData);
return { midi: sample.midi, buffer };
@ -56,12 +60,22 @@ export class PianoSampler {
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 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 =
this.config.piano.sustainSeconds * (0.45 + noteVelocity * 0.55);
const sustainAt = scheduledStart + Math.max(0.08, durationSeconds);
this.config.piano.sustainSeconds *
(appConfig.audioEngine.piano.sustainBase +
noteVelocity * appConfig.audioEngine.piano.sustainVelocityRange);
const sustainAt =
scheduledStart +
Math.max(appConfig.audioEngine.piano.minDurationSeconds, durationSeconds);
const releaseAt = sustainAt + sustainSeconds;
const releaseSeconds = this.config.piano.releaseSeconds;
const stopAt = releaseAt + releaseSeconds;
@ -75,26 +89,55 @@ export class PianoSampler {
while (this.activeVoices.length >= this.config.piano.maxVoices) {
const oldest = this.activeVoices.shift();
oldest?.gain.gain.cancelScheduledValues(scheduledStart);
oldest?.gain.gain.setTargetAtTime(0.0001, scheduledStart, 0.025);
oldest?.source.stop(scheduledStart + 0.05);
oldest?.gain.gain.setTargetAtTime(
appConfig.audioEngine.piano.minGain,
scheduledStart,
appConfig.audioEngine.piano.voiceStealFadeSeconds
);
oldest?.source.stop(
scheduledStart + appConfig.audioEngine.piano.voiceStealStopSeconds
);
}
source.buffer = sample.buffer;
source.playbackRate.setValueAtTime(
Math.pow(2, (midi - sample.midi) / 12),
Math.pow(
2,
(midi - sample.midi) / appConfig.audioEngine.piano.pitchSemitonesPerOctave
),
scheduledStart
);
filter.type = 'lowpass';
filter.frequency.setValueAtTime(clamp(lowpassHz, 1400, 12000), scheduledStart);
filter.Q.value = 0.7;
gain.gain.setValueAtTime(0.0001, scheduledStart);
gain.gain.exponentialRampToValueAtTime(noteGainValue, scheduledStart + 0.006);
gain.gain.setTargetAtTime(
Math.max(0.0001, noteGainValue * this.config.piano.sustainLevel),
sustainAt,
Math.max(0.04, sustainSeconds * 0.45)
filter.frequency.setValueAtTime(
clamp(
lowpassHz,
appConfig.audioEngine.piano.lowpassMinHz,
appConfig.audioEngine.piano.lowpassMaxHz
),
scheduledStart
);
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);
source.connect(filter);
@ -102,7 +145,7 @@ export class PianoSampler {
gain.connect(panner);
panner.connect(eventBus);
if (delayInput && this.config.delay.enabled && delaySend > 0) {
if (delayInput && delaySend > 0) {
sendGain = context.createGain();
sendGain.gain.value = delaySend;
panner.connect(sendGain);
@ -110,8 +153,8 @@ export class PianoSampler {
}
source.start(scheduledStart);
source.stop(stopAt + 0.05);
this.activeVoices.push({ gain, source, stopAt });
source.stop(stopAt + appConfig.audioEngine.piano.tailStopExtraSeconds);
this.activeVoices.push({ gain, source, startAt: scheduledStart, stopAt });
source.addEventListener(
'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) => {
voice.gain.gain.cancelScheduledValues(now);
voice.gain.gain.setTargetAtTime(0.0001, now, 0.035);
voice.stopAt = Math.min(voice.stopAt, now + 0.28);
if (voice.startAt > now) {
voice.gain.gain.setValueAtTime(appConfig.audioEngine.piano.minGain, now);
voice.stopAt = now;
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 {
// 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 sampleFiles: Array<[fileName: string, midi: number]> = [
['A0v12.ogg', 21],
['C1v12.ogg', 24],
['Dsharp1v12.ogg', 27],
['Fsharp1v12.ogg', 30],
['A1v12.ogg', 33],
['C2v12.ogg', 36],
['Dsharp2v12.ogg', 39],
['Fsharp2v12.ogg', 42],
['A2v12.ogg', 45],
['C3v12.ogg', 48],
['Dsharp3v12.ogg', 51],
['Fsharp3v12.ogg', 54],
['A3v12.ogg', 57],
['C4v12.ogg', 60],
['Dsharp4v12.ogg', 63],
['Fsharp4v12.ogg', 66],
['A4v12.ogg', 69],
['C5v12.ogg', 72],
['Dsharp5v12.ogg', 75],
['Fsharp5v12.ogg', 78],
['A5v12.ogg', 81],
['C6v12.ogg', 84],
['Dsharp6v12.ogg', 87],
['Fsharp6v12.ogg', 90],
['A6v12.ogg', 93],
['C7v12.ogg', 96],
['Dsharp7v12.ogg', 99],
['Fsharp7v12.ogg', 102],
['A7v12.ogg', 105],
['C8v12.ogg', 108],
['A0v12.m4a', 21],
['C1v12.m4a', 24],
['Dsharp1v12.m4a', 27],
['Fsharp1v12.m4a', 30],
['A1v12.m4a', 33],
['C2v12.m4a', 36],
['Dsharp2v12.m4a', 39],
['Fsharp2v12.m4a', 42],
['A2v12.m4a', 45],
['C3v12.m4a', 48],
['Dsharp3v12.m4a', 51],
['Fsharp3v12.m4a', 54],
['A3v12.m4a', 57],
['C4v12.m4a', 60],
['Dsharp4v12.m4a', 63],
['Fsharp4v12.m4a', 66],
['A4v12.m4a', 69],
['C5v12.m4a', 72],
['Dsharp5v12.m4a', 75],
['Fsharp5v12.m4a', 78],
['A5v12.m4a', 81],
['C6v12.m4a', 84],
['Dsharp6v12.m4a', 87],
['Fsharp6v12.m4a', 90],
['A6v12.m4a', 93],
['C7v12.m4a', 96],
['Dsharp7v12.m4a', 99],
['Fsharp7v12.m4a', 102],
['A7v12.m4a', 105],
['C8v12.m4a', 108],
];
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 { 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 { RenderPipeline } from '../pipelines/render/render-pipeline';
import { settings } from '../settings';
import { GardenAudio } from '../audio/garden-audio';
import { gardenAudioConfig } from '../audio/garden-audio-config';
import { appConfig } from '../config';
import { activeVibe, settings } from '../settings';
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 { GamePresentation } from './game-presentation';
import { GameRules } from './game-rules';
import { AgentPopulation } from './agent-population';
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 {
private readonly trailMapA: ResizableTexture;
private readonly trailMapB: ResizableTexture;
private static readonly MAX_MIRROR_SEGMENT_COUNT =
appConfig.simulation.maxMirrorSegmentCount;
private readonly commonState: CommonState;
private readonly copyPipeline: CopyPipeline;
private readonly agentGenerationPipeline: AgentGenerationPipeline;
private readonly agentPipeline: AgentPipeline;
private readonly renderPipeline: RenderPipeline;
private readonly brushPipeline: BrushPipeline;
private readonly diffusionPipeline: DiffusionPipeline;
private readonly resources: GameLoopResources;
private readonly audio = new GardenAudio(gardenAudioConfig);
private readonly renderInputs = new RenderInputCache();
private readonly introPrompt: IntroPrompt;
private readonly eraserPreview: EraserPreview;
private readonly pointerInput: GardenPointerInput;
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 readonly finished = Promise.withResolvers<void>();
private activePointerId: number | null = null;
public constructor(
private readonly canvas: HTMLCanvasElement,
private readonly device: GPUDevice,
device: GPUDevice,
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.copyPipeline = new CopyPipeline(this.device);
this.commonState = new CommonState(this.device);
this.commonState.setParameters({
canvasSize: this.canvasSize,
time: 0,
deltaTime: 0,
this.resources = new GameLoopResources(canvas, device, this.canvasSize);
this.introPrompt = new IntroPrompt(ui.prompt);
this.eraserPreview = new EraserPreview(canvas, ui.eraserPreview);
this.agentPopulation = new AgentPopulation(this.resources.agentGenerationPipeline);
this.agentPopulation.initializeIntroAgents(this.canvasSize);
this.pointerInput = new GardenPointerInput({
canvas,
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(
this.device,
this.commonState,
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));
window.addEventListener('resize', this.resizeListener);
window.addEventListener('keydown', this.keydownListener, { once: true });
this.pointerInput.attach();
}
private onPointerDown(event: PointerEvent) {
if (this.activePointerId !== null) {
return;
}
this.activePointerId = event.pointerId;
this.canvas.setPointerCapture(event.pointerId);
this.brushPipeline.clearSwipes();
this.addSwipeAt(event);
public setEraseMode(isErasing: boolean): void {
this.pointerInput.setEraseMode(isErasing);
}
private onPointerMove(event: PointerEvent) {
if (event.pointerId !== this.activePointerId) {
return;
}
this.addSwipeAt(event);
public updateEraserPreview(event?: PointerEvent): void {
this.pointerInput.updateEraserPreview(event);
}
private onPointerUp(event: PointerEvent) {
if (event.pointerId !== this.activePointerId) {
return;
}
this.addSwipeAt(event);
this.canvas.releasePointerCapture(event.pointerId);
this.activePointerId = null;
public onVibeChanged(): void {
this.agentPopulation.onVibeChanged();
this.renderInputs.invalidate();
}
private addSwipeAt(event: PointerEvent) {
const position = vec2.fromValues(
event.clientX * this.devicePixelRatio,
this.canvas.height - event.clientY * this.devicePixelRatio
);
this.brushPipeline.addSwipe(position);
public setAudioMuted(isMuted: boolean): void {
this.audio.setMuted(isMuted);
}
private get isSwipeActive(): boolean {
return this.activePointerId !== null;
public startAudio(userGesture = false): void {
this.audio.start(activeVibe, { userGesture });
}
public playVibeChangeAudio(userGesture = false): void {
this.audio.changeVibe(activeVibe, { userGesture });
}
public async start(): Promise<void> {
requestAnimationFrame(this.render.bind(this));
requestAnimationFrame(this.updateCounts.bind(this));
requestAnimationFrame(this.render);
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 {
return this.agentGenerationPipeline.maxAgentCount;
return this.agentPopulation.maxAgentCount;
}
private resize() {
this.canvas.width = this.canvas.clientWidth * this.devicePixelRatio;
this.canvas.height = this.canvas.clientHeight * this.devicePixelRatio;
public async export4K(): Promise<void> {
return this.export4KRenderer.export();
}
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) {
this.finished.resolve();
return;
}
const accentColor = GamePresentation.getGenerationColor(
this.gameRules.nextGenerationId - 1
);
document.documentElement.style.setProperty(
'--accent-color',
`rgb(${accentColor[0] * 255},${accentColor[1] * 255},${accentColor[2] * 255})`
);
const frameCpuStartedAt = this.framePerformance.markCpuStart();
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 timeInSeconds = time / 1000;
const spawnAction = this.gameRules.getSpawnAction(timeInSeconds, this.canvasSize);
const scaledTime = time * settings.renderSpeed;
const { channelColors, backgroundColor } = this.renderInputs.get();
const introProgress = this.introPrompt.progress;
const cameraZoom = 1 + (1 - introProgress) * appConfig.simulation.introCameraZoom;
const cameraCenter: [number, number] = [
this.canvas.width / 2,
this.canvas.height / 2,
];
const eraserPixelSize = settings.eraserSize * this.devicePixelRatio;
const isErasing = this.pointerInput.isEraseMode;
const accentColor = channelColors[settings.selectedColorIndex] ?? channelColors[0];
this.renderInputs.updateAccentColor(accentColor);
this.audio.update({
vibe: activeVibe,
selectedColorIndex: settings.selectedColorIndex,
isErasing,
});
[
this.commonState,
this.agentPipeline,
this.brushPipeline,
this.diffusionPipeline,
this.renderPipeline,
].forEach((pipeline) =>
pipeline.setParameters({
time,
isNextGenerationOdd: this.gameRules.nextGenerationId % 2,
nextGenerationSensorOffsetDistance: this.gameRules.getSensorOffset(),
nextGenerationSpeed: this.gameRules.getNextGenerationMoveSpeed(),
infectionProbability: this.gameRules.getInfectionProbability(),
this.resources.setFrameParameters({
time: scaledTime,
deltaTime,
canvasSize: this.canvasSize,
brushColor: GamePresentation.getGenerationColor(
this.gameRules.nextGenerationId - 1
),
evenGenerationColor: GamePresentation.getGenerationColor(
this.gameRules.nextGenerationId % 2 == 0
? this.gameRules.nextGenerationId
: this.gameRules.nextGenerationId - 1
),
oddGenerationColor: GamePresentation.getGenerationColor(
this.gameRules.nextGenerationId % 2 == 1
? this.gameRules.nextGenerationId
: this.gameRules.nextGenerationId - 1
),
...settings,
center: spawnAction.position,
radius: spawnAction.radius,
})
);
activeAgentCount: this.agentPopulation.activeAgentCount,
introProgress,
selectedColorIndex: settings.selectedColorIndex,
isErasing,
channelColors,
backgroundColor,
cameraCenter,
cameraZoom,
eraserPixelSize,
});
for (let i = 0; i < settings.renderSpeed; i++) {
const commandEncoder = this.device.createCommandEncoder();
const encodeCpuStartedAt = this.framePerformance.markCpuStart();
this.resources.executeFrame(settings.renderSpeed, isErasing);
const encodeCpuMs = this.framePerformance.measureSince(encodeCpuStartedAt);
this.copyPipeline.execute(
commandEncoder,
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.pointerInput.clearSwipesIfIdle();
await this.agentPopulation.compactAfterErase(this.pointerInput.isSwipeActive);
this.device.queue.submit([commandEncoder.finish()]);
}
if (!this.isSwipeActive) {
this.brushPipeline.clearSwipes();
}
this.framePerformance.renderTelemetry({
frameCpuStartedAt,
encodeCpuMs,
activeAgentCount: this.agentPopulation.activeAgentCount,
targetAgentBudget: this.agentPopulation.targetAgentBudget,
canvas: this.canvas,
devicePixelRatio: this.devicePixelRatio,
renderSpeed: settings.renderSpeed,
});
if (settings.simulatedDelayMs > 0) {
await sleep(settings.simulatedDelayMs);
}
// avoid resizing during rendering
this.trailMapA.resize(this.canvasSize);
this.trailMapB.resize(this.canvasSize);
requestAnimationFrame(this.render);
};
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.hasFinished = true;
await this.finished.promise;
this.canvas.width = width;
this.canvas.height = height;
}
this.copyPipeline?.destroy();
this.agentGenerationPipeline?.destroy();
this.agentPipeline?.destroy();
this.brushPipeline?.destroy();
this.diffusionPipeline?.destroy();
this.renderPipeline?.destroy();
this.commonState?.destroy();
this.trailMapA?.destroy();
this.trailMapB?.destroy();
private resizeSimulationToCanvas(): void {
const scale = this.resources.resizeSimulationTo(this.canvasSize);
if (!scale) {
return;
}
this.agentPopulation.resizeAgents(scale);
this.pointerInput.scaleLastPointerPosition(scale);
}
private get canvasSize(): vec2 {
@ -260,6 +246,14 @@ export default class GameLoop {
}
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 { GameRules } from './game-loop/game-rules';
import './index.scss';
import { appConfig } from './config';
import { CollapsiblePanelAnimator } from './page/collapsible-panel-animator';
import { ConfigPane } from './page/config-pane';
import { FullScreenHandler } from './page/full-screen-handler';
import { MenuHider } from './page/menu-hider';
import { setUpSettingsPage } from './page/set-up-settings-page';
import { SettingsSlider } from './page/settings-slider';
import { resetSettings } from './settings';
import { activeVibe, applyVibeSettings, resetSettings, settings } from './settings';
import { DeltaTimeCalculator } from './utils/delta-time-calculator';
import { ErrorHandler, Severity } from './utils/error-handler';
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 = {
aside: document.querySelector('aside') as HTMLDivElement,
infoButton: document.querySelector('button.info') as HTMLButtonElement,
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(
'button.minimize-full-screen'
) as HTMLButtonElement,
@ -28,9 +73,107 @@ const elements = {
'button.maximize-full-screen'
) as HTMLButtonElement,
settingsButton: document.querySelector('button.settings') as HTMLButtonElement,
soundButton: document.querySelector('button.sound') as HTMLButtonElement,
restartButton: document.querySelector('button.restart') as HTMLButtonElement,
canvas: document.querySelector('canvas') as HTMLCanvasElement,
eraserPreview: document.querySelector('.eraser-preview') 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 () => {
@ -38,36 +181,48 @@ const main = async () => {
let shouldStop = false;
let game: GameLoop | null = null;
elements.errorContainer.setAttribute('aria-live', 'assertive');
ErrorHandler.addOnErrorListener((error, _metadata) => {
elements.errorContainer.innerHTML += `
<pre class="${error.severity}">${error.message}</div>
`;
renderRuntimeMessage(elements.errorContainer, error);
if (error.severity === Severity.ERROR) {
game?.destroy();
shouldStop = true;
}
});
const syncRuntimeUi = () => {
renderEraserSizeUi(game);
renderMirrorSegmentUi();
renderPaletteUi(game);
};
const infoPageHandler = new CollapsiblePanelAnimator(
elements.infoButton,
elements.infoElement,
elements.aside
);
const settingsPageHandler = new CollapsiblePanelAnimator(
elements.settingsButton,
elements.settingsPage,
elements.aside
);
settingsPageHandler.onOpen = infoPageHandler.close.bind(infoPageHandler);
infoPageHandler.onOpen = settingsPageHandler.close.bind(settingsPageHandler);
if (isProduction) {
infoPageHandler.open();
}
const configPane = new ConfigPane({
settingsButton: elements.settingsButton,
onConfigChange: syncRuntimeUi,
onRuntimeChange: syncRuntimeUi,
onRuntimeReset: () => {
resetSettings();
syncRuntimeUi();
},
onRestart: () => game?.destroy(),
onVibeChange: (vibeId) => {
applyVibeSettings(vibeId);
syncRuntimeUi();
game?.playVibeChangeAudio(false);
},
});
infoPageHandler.onOpen = configPane.close.bind(configPane);
new MenuHider(
elements.aside,
() =>
FullScreenHandler.isInFullScreenMode() &&
!settingsPageHandler.isOpen &&
!configPane.isOpen &&
!infoPageHandler.isOpen
);
new FullScreenHandler(
@ -76,31 +231,113 @@ const main = async () => {
document.body
);
const fontsReady = document.fonts.ready.catch(() => undefined);
const gpu = await initializeGpu();
await fontsReady;
elements.restartButton.addEventListener('click', () => game?.destroy());
const deltaTimeCalculator = new DeltaTimeCalculator();
let sliders: Array<SettingsSlider<any>> = [];
elements.applyDefaults.addEventListener('click', () => {
resetSettings();
sliders.forEach((slider) => slider.updateSliderValueBasedOnSource());
elements.soundButton.addEventListener('click', (event) => {
isAudioMuted = !isAudioMuted;
localStorage.setItem(appConfig.storage.audioMutedKey, isAudioMuted ? '1' : '0');
renderAudioUi(game);
if (!isAudioMuted) {
game?.startAudio(event.isTrusted);
}
});
while (!shouldStop) {
const gameRules = new GameRules(performance.now() / 1000);
game = new GameLoop(elements.canvas, gpu, deltaTimeCalculator, gameRules);
const deltaTimeCalculator = new DeltaTimeCalculator();
if (sliders.length === 0) {
sliders = setUpSettingsPage(elements.settingsContent, game.maxAgentCount);
elements.previousVibe.addEventListener('click', (event) => {
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();
}
} catch (e) {
const message = e instanceof Error ? (e.stack ?? e.message) : String(e);
ErrorHandler.addError(Severity.ERROR, message);
ErrorHandler.addException(e);
console.error(e);
}
};

View file

@ -1,5 +1,8 @@
export class CollapsiblePanelAnimator {
private static nextPanelId = 0;
private _isOpen = false;
private focusBeforeOpen: HTMLElement | null = null;
public onOpen: () => unknown = () => {};
public onClose: () => unknown = () => {};
@ -9,25 +12,64 @@ export class CollapsiblePanelAnimator {
private readonly collapsibleContent: 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));
window.addEventListener(
'click',
(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() {
if (this._isOpen) {
return;
}
this.focusBeforeOpen =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
this._isOpen = true;
this.collapsibleContent.classList.remove('hidden');
this.toggleButton.classList.add('active');
this.syncAccessibility();
this.onOpen();
this.focusPanel();
}
public close() {
if (!this._isOpen) {
return;
}
const focusWasInside = this.collapsibleContent.contains(document.activeElement);
this._isOpen = false;
this.collapsibleContent.classList.add('hidden');
this.toggleButton.classList.remove('active');
this.syncAccessibility();
this.onClose();
if (focusWasInside) {
(this.focusBeforeOpen ?? this.toggleButton).focus({ preventScroll: true });
}
}
public toggle() {
@ -41,4 +83,20 @@ export class CollapsiblePanelAnimator {
public get 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