diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml
index 710c622..0c52ecd 100644
--- a/.forgejo/workflows/deploy.yml
+++ b/.forgejo/workflows/deploy.yml
@@ -57,4 +57,4 @@ jobs:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
apt update && apt install -y rsync
- rsync -a --delete dist/ /pages/fleeting-garden
+ rsync -a --delete dist/ /pages/fleeting
diff --git a/.gitignore b/.gitignore
index 916a63e..f06235c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,47 +1,2 @@
-# Dependency directory
node_modules
-modules/
-ts-node--*/
-rss.xml
-
dist
-playwright-report
-test-results
-
-# Logs
-logs
-*.log
-npm-debug.log*
-
-# Runtime data
-pids
-*.pid
-*.seed
-*.ssh
-*.ppk
-v8-compile-cache-0/
-Thumbs.db
-
-# node-waf configuration
-.lock-wscript
-
-# Compiled binary addons (http://nodejs.org/api/addons.html)
-build/Release
-bin
-ts-node
-
-# Personal Scripts
-*.bat
-*.ssh
-*.sh
-!system.min.js
-
-# Editors
-.vscode
-.markdownlint.json
-
-# Build Files
-temp
-*.js
-*.map
-!webpack.*
diff --git a/definitions.d.ts b/definitions.d.ts
index 9b4e880..c90ad44 100644
--- a/definitions.d.ts
+++ b/definitions.d.ts
@@ -6,5 +6,3 @@ declare module '*.wgsl?raw' {
interface HTMLCanvasElement {
getContext(contextId: 'webgpu'): GPUCanvasContext | null;
}
-
-declare var webkitOfflineAudioContext: typeof OfflineAudioContext | undefined;
diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts
index 86a6c02..ef4cfc3 100644
--- a/e2e/app.spec.ts
+++ b/e2e/app.spec.ts
@@ -133,3 +133,37 @@ test('shows a clear fallback when WebGPU is unavailable', async ({ page }) => {
await expect(fallback).toContainText('webgpu-unsupported');
expect(browserFailures).toEqual([]);
});
+
+test('keeps audio focus outlines scoped to the active control', async ({ page }) => {
+ await disableWebGpu(page);
+ await page.goto('/');
+ await expect(page.locator('body')).not.toHaveClass(/is-loading/);
+
+ const audioControl = page.locator('.audio-control');
+ const soundButton = page.locator('button.sound');
+ const volumeSlider = page.locator('.volume-slider');
+
+ await soundButton.click();
+ await expect(audioControl).toHaveCSS('outline-style', 'none');
+ await expect(soundButton).toHaveCSS('outline-style', 'none');
+
+ await page.mouse.click(10, 10);
+ for (let tabIndex = 0; tabIndex < 12; tabIndex += 1) {
+ await page.keyboard.press('Tab');
+ const activeClass = await page.evaluate(() =>
+ String(document.activeElement?.className ?? '')
+ );
+ if (activeClass.includes('sound')) {
+ break;
+ }
+ }
+
+ await expect(soundButton).toBeFocused();
+ await expect(soundButton).toHaveCSS('outline-style', 'solid');
+ await expect(soundButton).toHaveCSS('outline-offset', '-4px');
+
+ await page.keyboard.press('Tab');
+ await expect(volumeSlider).toBeFocused();
+ await expect(volumeSlider).toHaveCSS('outline-style', 'solid');
+ await expect(volumeSlider).toHaveCSS('outline-offset', '-4px');
+});
diff --git a/index.html b/index.html
index 884b053..16f5c13 100644
--- a/index.html
+++ b/index.html
@@ -47,20 +47,24 @@
-
-
-
-
+
+
Fleeting Garden
+
+ Draw coloured paths and watch them bloom into a living WebGPU garden.
+
+
+
+
-
Starting up…
-
-
-
+
+
diff --git a/package-lock.json b/package-lock.json
index 8d4a2d5..89cdaff 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -22,6 +22,7 @@
"@vitejs/plugin-basic-ssl": "^2.3.0",
"@webgpu/types": "^0.1.69",
"browserslist": "^4.28.2",
+ "browserslist-to-esbuild": "^2.1.1",
"eslint": "^10.3.0",
"eslint-config-prettier": "^10.1.8",
"gl-matrix": "^3.4.4",
@@ -2832,6 +2833,25 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
+ "node_modules/browserslist-to-esbuild": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/browserslist-to-esbuild/-/browserslist-to-esbuild-2.1.1.tgz",
+ "integrity": "sha512-KN+mty6C3e9AN8Z5dI1xeN15ExcRNeISoC3g7V0Kax/MMF9MSoYA2G7lkTTcVUFntiEjkpI0HNgqJC1NjdyNUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "meow": "^13.0.0"
+ },
+ "bin": {
+ "browserslist-to-esbuild": "cli/index.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "browserslist": "*"
+ }
+ },
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
@@ -3954,6 +3974,19 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/meow": {
+ "version": "13.2.0",
+ "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz",
+ "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
diff --git a/package.json b/package.json
index ba2e8c3..dbc6789 100644
--- a/package.json
+++ b/package.json
@@ -54,6 +54,7 @@
"@vitejs/plugin-basic-ssl": "^2.3.0",
"@webgpu/types": "^0.1.69",
"browserslist": "^4.28.2",
+ "browserslist-to-esbuild": "^2.1.1",
"eslint": "^10.3.0",
"eslint-config-prettier": "^10.1.8",
"gl-matrix": "^3.4.4",
diff --git a/src/analytics.ts b/src/analytics.ts
index f025389..78731cd 100644
--- a/src/analytics.ts
+++ b/src/analytics.ts
@@ -52,6 +52,10 @@ export const trackVibeChange = ({
});
};
+export const trackStart = () => {
+ track('Start');
+};
+
export const trackExport = ({ vibeId }: { vibeId: VibeId }) => {
track('Export', {
props: {
diff --git a/src/audio/garden-audio-config.ts b/src/audio/garden-audio-config.ts
index 78abfd5..b6722a5 100644
--- a/src/audio/garden-audio-config.ts
+++ b/src/audio/garden-audio-config.ts
@@ -8,201 +8,6 @@ export interface GardenAudioChord {
quality: GardenAudioChordQuality;
}
-interface GardenAudioStyleVoice {
- scaleDegreeOffset: number;
- velocityMultiplier: number;
- panOffset: number;
-}
-
-export interface GardenAudioRegister {
- midiMin: number;
- midiMax: number;
- preferredMidi: number;
- pan: number;
-}
-
-export interface GardenAudioStylePool extends GardenAudioRegister {
- scaleDegrees: Array;
-}
-
-interface GardenAudioGenerativePianoConfig {
- stylePools: [GardenAudioStylePool, GardenAudioStylePool, GardenAudioStylePool];
- padRegisters: [GardenAudioRegister, GardenAudioRegister, GardenAudioRegister];
- chordVoicings: {
- majorOpen: Array;
- minorOpen: Array;
- majorClosed: Array;
- minorClosed: Array;
- };
- vibeChangeStinger: {
- velocities: [number, number, number];
- pans: [number, number, number];
- delaySends: [number, number, number];
- lowpassExpression: number;
- };
- highActivityExtra: {
- barOffset: number;
- expressionMultiplier: number;
- };
- padChord: {
- velocities: [number, number, number];
- expressionVelocityWeight: number;
- delaySend: number;
- lowpassExpressionWeight: number;
- };
- supportNote: {
- velocityBase: number;
- velocityExpressionWeight: number;
- durationBaseSeconds: number;
- durationExpressionSeconds: number;
- delaySendBase: number;
- delaySendExpressionWeight: number;
- lowpassExpressionWeight: number;
- expressionThreshold: number;
- offsetsByStyle: [Array, Array, Array];
- };
- textureNote: {
- velocityBase: number;
- velocityExpressionWeight: number;
- durationBaseSeconds: number;
- durationExpressionSeconds: number;
- delaySendBase: number;
- delaySendExpressionWeight: number;
- idleExpressionThreshold: number;
- mediumExpressionThreshold: number;
- intenseSpacing: number;
- idlePhase: number;
- };
- gestureAccent: {
- rotationStrengthMultiplier: number;
- quantizeStepLookahead: number;
- velocityBase: number;
- velocityStrengthWeight: number;
- durationBaseSeconds: number;
- durationStrengthSeconds: number;
- delaySend: number;
- };
- touchNote: {
- registerBiasManiaAmount: number;
- velocityBase: number;
- velocityStrengthWeight: number;
- durationBaseSeconds: number;
- durationStrengthSeconds: number;
- delaySend: number;
- lowpassBaseExpression: number;
- lowpassStrengthWeight: number;
- };
- brushPhrase: {
- initialMotifOffset: number;
- energyDecaySeconds: number;
- maniaDecaySeconds: number;
- fadeMinimumLifetimeSeconds: number;
- layerIntensityBase: number;
- layerIntensityManiaWeight: number;
- frameActivityWeight: number;
- frameManiaWeight: number;
- };
- brushStream: {
- inferredManiaThreshold: number;
- inferredManiaRange: number;
- registerManiaShift: number;
- chordToneEverySteps: number;
- durationBaseSeconds: number;
- durationIntensitySeconds: number;
- durationManiaSeconds: number;
- durationMinSeconds: number;
- durationMaxSeconds: number;
- delaySendBase: number;
- delaySendIntensityWeight: number;
- delaySendManiaWeight: number;
- delaySendMin: number;
- delaySendMax: number;
- velocityBase: number;
- velocityIntensityWeight: number;
- lowpassBaseExpression: number;
- lowpassIntensityWeight: number;
- lowpassManiaWeight: number;
- manicThreshold: number;
- intenseThreshold: number;
- activeThreshold: number;
- };
- brushStreamEcho: {
- maniaThreshold: number;
- stepModulo: number;
- stepRemainder: number;
- intensityThreshold: number;
- octaveSemitones: number;
- maxMidi: number;
- velocityBase: number;
- velocityIntensityWeight: number;
- durationMinSeconds: number;
- durationScale: number;
- panScale: number;
- delaySendMin: number;
- delaySendScale: number;
- lowpassBaseExpression: number;
- lowpassManiaWeight: number;
- };
- brushMotif: {
- highThreshold: number;
- mediumThreshold: number;
- highOffset: number;
- mediumOffset: number;
- lowOffset: number;
- minOffset: number;
- maxOffset: number;
- };
- registerBias: {
- maniaShiftSemitones: number;
- midiMin: number;
- midiMaxForMin: number;
- minimumSpan: number;
- midiMax: number;
- };
- candidateOctaveSearch: {
- min: number;
- max: number;
- };
- stylePanOffsetScale: number;
- lowpass: {
- midiBase: number;
- midiRange: number;
- midiLiftHz: number;
- expressionBase: number;
- expressionWeight: number;
- };
- styleRotationBars: number;
- chordBars: number;
- supportBarSpacing: number;
- supportBarOffset: number;
- idleTextureBarSpacing: number;
- mediumTextureBarSpacing: number;
- textureBeat: number;
- highActivityExtraBeat: number;
- highActivityExtraThreshold: number;
- noteScorePreferenceWeight: number;
- noteScoreRegisterWeight: number;
- noteScoreChordToneWeight: number;
- noteScoreRepeatPenalty: number;
- gestureAccentMinIntervalSeconds: number;
- strokeAccentMinSteps: number;
- strokeAccentThreshold: number;
- stingerDurationSeconds: number;
- stingerSpacingSeconds: number;
- maxBrushPhraseLayers: number;
- maxBrushStreamNotesPerBar: number;
- brushLayerBaseSeconds: number;
- brushLayerEnergySeconds: number;
- brushLayerMinIntensity: number;
- brushStreamIdleIntervalBeats: number;
- brushStreamActiveIntervalBeats: number;
- brushStreamIntenseIntervalBeats: number;
- brushStreamManicIntervalBeats: number;
- brushMotifMaxSteps: number;
- brushMotifCanonDelaySeconds: number;
- padDurationBarScale: number;
-}
-
export interface GardenAudioVibeProfile {
rootMidi: number;
scale: Array;
@@ -215,7 +20,6 @@ export interface GardenAudioConfig {
masterVolume: number;
fadeInSeconds: number;
updateRampSeconds: number;
- highPassFrequencyHz: number;
delay: {
timeSeconds: number;
feedback: number;
@@ -228,44 +32,24 @@ export interface GardenAudioConfig {
outputBase: number;
outputActivityDuck: number;
timeRampSeconds: number;
- feedbackHighPassHz: number;
- feedbackLowPassHz: number;
- returnLowPassHz: number;
};
piano: {
maxVoices: number;
- filterType: BiquadFilterType;
gain: number;
sustainSeconds: number;
sustainLevel: number;
releaseSeconds: number;
lowpassHz: number;
- filterQ: number;
gainAttackSeconds: number;
lowpassMaxHz: number;
lowpassMinHz: number;
- minDurationSeconds: number;
- minFadeSeconds: number;
- minGain: number;
- pitchSemitonesPerOctave: number;
- scheduleAheadSeconds: number;
sustainBase: number;
sustainVelocityRange: number;
- tailStopExtraSeconds: number;
- voiceStealFadeSeconds: number;
- voiceStealStopSeconds: number;
- sampleBaseUrl: string;
- preloadDecode: {
- channels: number;
- frames: number;
- sampleRateHz: number;
- };
};
rhythm: {
bpm: number;
stepsPerBeat: number;
stepsPerBar: number;
- lookaheadSeconds: number;
sparseActivity: number;
};
eraser: {
@@ -285,32 +69,11 @@ export interface GardenAudioConfig {
strokeDecaySeconds: number;
};
graph: {
- closeGain: number;
- closeRampSeconds: number;
- delayMaxSeconds: number;
- eventBusGain: number;
- noiseMax: number;
- noiseMin: number;
- unlockTickFrequencyHz: number;
- unlockTickSeconds: number;
- unlockTickType: OscillatorType;
- latencyHint: AudioContextLatencyCategory;
- outputFilterType: BiquadFilterType;
- noiseBufferChannels: number;
- noiseBufferDurationSeconds: number;
pianoBusGains: Record;
pianoBusActivityDucking: Record;
noiseBusGain: number;
- compressor: {
- thresholdDb: number;
- kneeDb: number;
- ratio: number;
- attackSeconds: number;
- releaseSeconds: number;
- };
};
input: {
- fallbackFrameSeconds: number;
fullActivitySpeed: number;
activityNoiseFloorSpeed: number;
activityCurve: number;
@@ -321,22 +84,7 @@ export interface GardenAudioConfig {
manicActivityThreshold: number;
manicReleaseThreshold: number;
maniaSmoothingSeconds: number;
- minElapsedSeconds: number;
};
- muteGain: number;
- muteRampSeconds: number;
- noiseBurst: {
- attackSeconds: number;
- filterQ: number;
- offsetRandomSeconds: number;
- scheduleAheadSeconds: number;
- silentGain: number;
- filterType: BiquadFilterType;
- };
- startDelaySeconds: number;
- vibeChangeStingerMinIntervalSeconds: number;
- generativePiano: GardenAudioGenerativePianoConfig;
- styleVoices: [GardenAudioStyleVoice, GardenAudioStyleVoice, GardenAudioStyleVoice];
}
export const gardenAudioConfig: GardenAudioConfig = appConfig.audio;
diff --git a/src/audio/garden-audio-energy.test.ts b/src/audio/garden-audio-energy.test.ts
deleted file mode 100644
index ac6a6be..0000000
--- a/src/audio/garden-audio-energy.test.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import { describe, expect, it } from 'vitest';
-
-import { gardenAudioConfig } from './garden-audio-config';
-import { GardenAudioEnergy } from './garden-audio-energy';
-
-describe('GardenAudioEnergy', () => {
- it('suspends activity but keeps a fading level when the gesture ends', () => {
- const energy = new GardenAudioEnergy(gardenAudioConfig);
-
- 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(gardenAudioConfig);
-
- 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(gardenAudioConfig);
-
- energy.beginGesture(0);
- energy.recordStroke(0.12, 0.05);
-
- expect(energy.getActivity()).toBeGreaterThan(0.09);
- });
-});
diff --git a/src/audio/garden-audio-energy.ts b/src/audio/garden-audio-energy.ts
index 67640c5..f6e689d 100644
--- a/src/audio/garden-audio-energy.ts
+++ b/src/audio/garden-audio-energy.ts
@@ -1,4 +1,4 @@
-import { clamp01 } from '../utils/clamp';
+import { approach, clamp01 } from '../utils/math';
import type { GardenAudioConfig } from './garden-audio-config';
export class GardenAudioEnergy {
@@ -59,8 +59,7 @@ export class GardenAudioEnergy {
} else if (target > this.energy) {
timeConstant = this.config.energy.attackSeconds;
}
- const amount = 1 - Math.exp(-elapsedSeconds / timeConstant);
- this.energy += (target - this.energy) * amount;
+ this.energy = approach(this.energy, target, elapsedSeconds, timeConstant);
}
public getActivity(): number {
diff --git a/src/audio/garden-audio-gesture-state.test.ts b/src/audio/garden-audio-gesture-state.test.ts
deleted file mode 100644
index 20ee038..0000000
--- a/src/audio/garden-audio-gesture-state.test.ts
+++ /dev/null
@@ -1,95 +0,0 @@
-import { describe, expect, it } from 'vitest';
-
-import { gardenAudioConfig } from './garden-audio-config';
-import { GardenAudioGestureState } from './garden-audio-gesture-state';
-import type { GardenAudioStrokeMetrics } from './garden-audio-input';
-
-const makeMetrics = ({
- elapsedSeconds,
- normalizedDistance,
-}: {
- elapsedSeconds: number;
- normalizedDistance: number;
-}): GardenAudioStrokeMetrics => ({
- distancePixels: normalizedDistance * 1000,
- elapsedSeconds,
- normalizedDistance,
- normalizedSpeed: normalizedDistance / elapsedSeconds,
-});
-
-describe('GardenAudioGestureState', () => {
- it('ignores tiny jitter below the activity speed floor', () => {
- const state = new GardenAudioGestureState(gardenAudioConfig.input);
-
- state.beginGesture();
-
- expect(
- state.recordStroke({
- metrics: makeMetrics({
- elapsedSeconds: 0.1,
- normalizedDistance: 0.001,
- }),
- }).activity
- ).toBe(0);
- });
-
- it('normalizes equal drawing speeds across pointer sample rates', () => {
- const lowRate = new GardenAudioGestureState(gardenAudioConfig.input);
- const highRate = new GardenAudioGestureState(gardenAudioConfig.input);
-
- lowRate.beginGesture();
- highRate.beginGesture();
-
- const lowRateFrame = lowRate.recordStroke({
- metrics: makeMetrics({
- elapsedSeconds: 0.1,
- normalizedDistance: 0.07,
- }),
- });
-
- let highRateFrame = { activity: 0, maniaAmount: 0 };
- for (let index = 0; index < 5; index += 1) {
- highRateFrame = highRate.recordStroke({
- metrics: makeMetrics({
- elapsedSeconds: 0.02,
- normalizedDistance: 0.014,
- }),
- });
- }
-
- expect(highRateFrame.activity).toBeCloseTo(lowRateFrame.activity, 5);
- });
-
- it('holds mania with hysteresis before releasing', () => {
- const state = new GardenAudioGestureState(gardenAudioConfig.input);
-
- state.beginGesture();
-
- const manicFrame = state.recordStroke({
- metrics: makeMetrics({
- elapsedSeconds: 0.25,
- normalizedDistance: 0.3,
- }),
- });
-
- expect(manicFrame.maniaAmount).toBeGreaterThan(0);
-
- const heldFrame = state.recordStroke({
- metrics: makeMetrics({
- elapsedSeconds: 0.06,
- normalizedDistance: 0.04,
- }),
- });
-
- expect(heldFrame.maniaAmount).toBeGreaterThan(0);
-
- const releasedFrame = state.recordStroke({
- metrics: makeMetrics({
- elapsedSeconds: 0.7,
- normalizedDistance: 0,
- }),
- });
-
- expect(releasedFrame.maniaAmount).toBeLessThan(heldFrame.maniaAmount);
- });
-});
diff --git a/src/audio/garden-audio-gesture-state.ts b/src/audio/garden-audio-gesture-state.ts
index 4551adb..7d85364 100644
--- a/src/audio/garden-audio-gesture-state.ts
+++ b/src/audio/garden-audio-gesture-state.ts
@@ -1,4 +1,4 @@
-import { clamp, clamp01 } from '../utils/clamp';
+import { approach, clamp, clamp01, smoothstep } from '../utils/math';
import type { GardenAudioConfig } from './garden-audio-config';
import type { GardenAudioStrokeMetrics } from './garden-audio-input';
@@ -92,18 +92,3 @@ export class GardenAudioGestureState {
return clamp(activity * distanceAmount, 0, this.inputConfig.activitySoftCeiling);
}
}
-
-const approach = (
- current: number,
- target: number,
- elapsedSeconds: number,
- timeConstantSeconds: number
-): number => {
- const amount = 1 - Math.exp(-elapsedSeconds / Math.max(0.001, timeConstantSeconds));
- return current + (target - current) * amount;
-};
-
-const smoothstep = (edge0: number, edge1: number, value: number): number => {
- const amount = clamp01((value - edge0) / (edge1 - edge0));
- return amount * amount * (3 - 2 * amount);
-};
diff --git a/src/audio/garden-audio-graph.test.ts b/src/audio/garden-audio-graph.test.ts
deleted file mode 100644
index 8d5031d..0000000
--- a/src/audio/garden-audio-graph.test.ts
+++ /dev/null
@@ -1,92 +0,0 @@
-import { afterEach, describe, expect, it, vi } from 'vitest';
-
-import { VIBE_PRESETS } from '../vibes';
-import { gardenAudioConfig } from './garden-audio-config';
-import { GardenAudioGraph } from './garden-audio-graph';
-
-class FakeAudioParam {
- public value = 0;
- public setTargetAtTime = vi.fn();
-}
-
-class FakeAudioNode {
- public readonly gain = new FakeAudioParam();
- public readonly frequency = new FakeAudioParam();
- public readonly threshold = new FakeAudioParam();
- public readonly knee = new FakeAudioParam();
- public readonly ratio = new FakeAudioParam();
- public readonly attack = new FakeAudioParam();
- public readonly release = new FakeAudioParam();
- public readonly delayTime = new FakeAudioParam();
- public readonly connections: Array = [];
- public type = '';
-
- public connect(target: unknown): unknown {
- this.connections.push(target);
- return target;
- }
-}
-
-class FakeAudioBuffer {
- private readonly data: Float32Array;
-
- public constructor(length: number) {
- this.data = new Float32Array(length);
- }
-
- public getChannelData(): Float32Array {
- return this.data;
- }
-}
-
-class FakeAudioContext {
- public readonly currentTime = 1;
- public readonly destination = new FakeAudioNode() as unknown as AudioDestinationNode;
- public readonly sampleRate = 16;
- public readonly state = 'running';
- public readonly compressors: Array = [];
-
- public createGain(): GainNode {
- return new FakeAudioNode() as unknown as GainNode;
- }
-
- public createBiquadFilter(): BiquadFilterNode {
- return new FakeAudioNode() as unknown as BiquadFilterNode;
- }
-
- public createDelay(): DelayNode {
- return new FakeAudioNode() as unknown as DelayNode;
- }
-
- public createDynamicsCompressor(): DynamicsCompressorNode {
- const node = new FakeAudioNode();
- this.compressors.push(node);
- return node as unknown as DynamicsCompressorNode;
- }
-
- public createBuffer(_channels: number, length: number): AudioBuffer {
- return new FakeAudioBuffer(length) as unknown as AudioBuffer;
- }
-}
-
-describe('GardenAudioGraph', () => {
- afterEach(() => {
- vi.unstubAllGlobals();
- });
-
- it('builds controlled output, role buses, and delay automation', () => {
- vi.stubGlobal('AudioContext', FakeAudioContext);
- const graph = new GardenAudioGraph(gardenAudioConfig);
- const context = graph.ensureContext(true) as unknown as FakeAudioContext;
-
- expect(context.compressors).toHaveLength(1);
- expect(graph.getPianoBus('pad')).not.toBeNull();
- expect(graph.getPianoBus('pad')).not.toBe(graph.getPianoBus('gesture'));
- expect(graph.noiseBus).not.toBeNull();
-
- graph.updateDelay(VIBE_PRESETS[0].audio, 1);
-
- expect(graph.getPianoBus('pad')?.gain.setTargetAtTime).toHaveBeenCalled();
- expect(graph.getPianoBus('brush')?.gain.setTargetAtTime).toHaveBeenCalled();
- });
-});
diff --git a/src/audio/garden-audio-graph.ts b/src/audio/garden-audio-graph.ts
index cd6c4bb..2862e5a 100644
--- a/src/audio/garden-audio-graph.ts
+++ b/src/audio/garden-audio-graph.ts
@@ -1,7 +1,38 @@
-import { clamp } from '../utils/clamp';
-import { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
+import { clamp } from '../utils/math';
+import { isIosLike } from './audio-platform';
+import type { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
import type { PianoNoteRole } from './garden-audio-types';
+const outputHighPassFrequencyHz = 45;
+const graphTuning = {
+ closeGain: 0.0001,
+ closeRampSeconds: 0.015,
+ delayMaxSeconds: 2,
+ eventBusGain: 1,
+ noiseMax: 1,
+ noiseMin: -1,
+ latencyHint: 'interactive' as AudioContextLatencyCategory,
+ outputFilterType: 'highpass' as BiquadFilterType,
+ noiseBufferChannels: 1,
+ noiseBufferDurationSeconds: 1,
+ unlockTickFrequencyHz: 440,
+ unlockTickGain: 0.0001,
+ unlockTickSeconds: 0.025,
+ unlockTickType: 'sine' as OscillatorType,
+ compressor: {
+ thresholdDb: -18,
+ kneeDb: 18,
+ ratio: 2.1,
+ attackSeconds: 0.018,
+ releaseSeconds: 0.18,
+ },
+};
+const delayFilterTuning = {
+ feedbackHighPassHz: 180,
+ feedbackLowPassHz: 5200,
+ returnLowPassHz: 6200,
+};
+
export class GardenAudioGraph {
public context: AudioContext | null = null;
public eventBus: GainNode | null = null;
@@ -13,6 +44,8 @@ export class GardenAudioGraph {
private delayNode: DelayNode | null = null;
private delayFeedback: GainNode | null = null;
private delayOutput: GainNode | null = null;
+ private mediaStreamDestination: MediaStreamAudioDestinationNode | null = null;
+ private mediaStreamElement: HTMLAudioElement | null = null;
private readonly pianoBuses = new Map();
public constructor(private readonly config: GardenAudioConfig) {}
@@ -34,7 +67,7 @@ export class GardenAudioGraph {
let context: AudioContext;
try {
context = new AudioContextConstructor({
- latencyHint: this.config.graph.latencyHint,
+ latencyHint: graphTuning.latencyHint,
});
} catch {
context = new AudioContextConstructor();
@@ -42,22 +75,30 @@ export class GardenAudioGraph {
const masterGain = context.createGain();
const highPass = context.createBiquadFilter();
const compressor = context.createDynamicsCompressor();
+ const mediaStreamDestination = isIosLike()
+ ? context.createMediaStreamDestination()
+ : null;
masterGain.gain.value = 0;
- highPass.type = this.config.graph.outputFilterType;
- highPass.frequency.value = this.config.highPassFrequencyHz;
- compressor.threshold.value = this.config.graph.compressor.thresholdDb;
- compressor.knee.value = this.config.graph.compressor.kneeDb;
- compressor.ratio.value = this.config.graph.compressor.ratio;
- compressor.attack.value = this.config.graph.compressor.attackSeconds;
- compressor.release.value = this.config.graph.compressor.releaseSeconds;
+ highPass.type = graphTuning.outputFilterType;
+ highPass.frequency.value = outputHighPassFrequencyHz;
+ compressor.threshold.value = graphTuning.compressor.thresholdDb;
+ compressor.knee.value = graphTuning.compressor.kneeDb;
+ compressor.ratio.value = graphTuning.compressor.ratio;
+ compressor.attack.value = graphTuning.compressor.attackSeconds;
+ compressor.release.value = graphTuning.compressor.releaseSeconds;
masterGain.connect(highPass);
highPass.connect(compressor);
- compressor.connect(context.destination);
+ if (mediaStreamDestination) {
+ compressor.connect(mediaStreamDestination);
+ } else {
+ compressor.connect(context.destination);
+ }
this.context = context;
this.masterGain = masterGain;
+ this.mediaStreamDestination = mediaStreamDestination;
this.noiseBuffer = this.createNoiseBuffer(context);
this.createDelay(context, masterGain);
this.createBuses(context, masterGain);
@@ -77,17 +118,13 @@ export class GardenAudioGraph {
const source = this.context.createOscillator();
const gain = this.context.createGain();
- source.type = this.config.graph.unlockTickType;
- source.frequency.setValueAtTime(this.config.graph.unlockTickFrequencyHz, now);
- gain.gain.setValueAtTime(this.config.piano.minGain, now);
- gain.gain.exponentialRampToValueAtTime(
- this.config.piano.minGain,
- now + this.config.graph.unlockTickSeconds
- );
+ source.type = graphTuning.unlockTickType;
+ source.frequency.setValueAtTime(graphTuning.unlockTickFrequencyHz, now);
+ gain.gain.setValueAtTime(graphTuning.unlockTickGain, now);
source.connect(gain);
gain.connect(this.context.destination);
source.start(now);
- source.stop(now + this.config.graph.unlockTickSeconds);
+ source.stop(now + graphTuning.unlockTickSeconds);
source.addEventListener(
'ended',
() => {
@@ -110,6 +147,38 @@ export class GardenAudioGraph {
);
}
+ public startMediaElementOutput(): void {
+ if (!this.mediaStreamDestination) {
+ return;
+ }
+
+ const mediaElement = this.ensureMediaStreamElement();
+ const playPromise = mediaElement.play();
+ void playPromise?.catch(() => undefined);
+ }
+
+ private ensureMediaStreamElement(): HTMLAudioElement {
+ if (this.mediaStreamElement) {
+ return this.mediaStreamElement;
+ }
+
+ const mediaElement = document.createElement('audio');
+ mediaElement.autoplay = true;
+ mediaElement.volume = 1;
+ mediaElement.setAttribute('playsinline', '');
+ mediaElement.setAttribute('aria-hidden', 'true');
+ mediaElement.style.position = 'fixed';
+ mediaElement.style.width = '1px';
+ mediaElement.style.height = '1px';
+ mediaElement.style.opacity = '0';
+ mediaElement.style.pointerEvents = 'none';
+ mediaElement.style.left = '-9999px';
+ mediaElement.srcObject = this.mediaStreamDestination?.stream ?? null;
+ document.body.append(mediaElement);
+ this.mediaStreamElement = mediaElement;
+ return mediaElement;
+ }
+
public applyDelayProfile(profile: GardenAudioVibeProfile): void {
if (!this.context || !this.delayNode) {
return;
@@ -167,9 +236,9 @@ export class GardenAudioGraph {
if (this.masterGain && context.state !== 'closed') {
this.masterGain.gain.setTargetAtTime(
- this.config.graph.closeGain,
+ graphTuning.closeGain,
context.currentTime,
- this.config.graph.closeRampSeconds
+ graphTuning.closeRampSeconds
);
}
@@ -182,7 +251,7 @@ export class GardenAudioGraph {
private createDelay(context: AudioContext, masterGain: GainNode): void {
const delayInput = context.createGain();
- const delayNode = context.createDelay(this.config.graph.delayMaxSeconds);
+ const delayNode = context.createDelay(graphTuning.delayMaxSeconds);
const delayFeedback = context.createGain();
const delayOutput = context.createGain();
const feedbackHighPass = context.createBiquadFilter();
@@ -193,11 +262,11 @@ export class GardenAudioGraph {
delayFeedback.gain.value = this.config.delay.feedback;
delayOutput.gain.value = this.config.delay.wetGain;
feedbackHighPass.type = 'highpass';
- feedbackHighPass.frequency.value = this.config.delay.feedbackHighPassHz;
+ feedbackHighPass.frequency.value = delayFilterTuning.feedbackHighPassHz;
feedbackLowPass.type = 'lowpass';
- feedbackLowPass.frequency.value = this.config.delay.feedbackLowPassHz;
+ feedbackLowPass.frequency.value = delayFilterTuning.feedbackLowPassHz;
returnLowPass.type = 'lowpass';
- returnLowPass.frequency.value = this.config.delay.returnLowPassHz;
+ returnLowPass.frequency.value = delayFilterTuning.returnLowPassHz;
delayInput.connect(delayNode);
delayNode.connect(feedbackHighPass);
@@ -216,7 +285,7 @@ export class GardenAudioGraph {
private createBuses(context: AudioContext, masterGain: GainNode): void {
const eventBus = context.createGain();
- eventBus.gain.value = this.config.graph.eventBusGain;
+ eventBus.gain.value = graphTuning.eventBusGain;
eventBus.connect(masterGain);
this.eventBus = eventBus;
this.pianoBuses.clear();
@@ -249,12 +318,11 @@ export class GardenAudioGraph {
private createNoiseBuffer(context: AudioContext): AudioBuffer {
const buffer = context.createBuffer(
- appPositiveInteger(this.config.graph.noiseBufferChannels),
+ appPositiveInteger(graphTuning.noiseBufferChannels),
Math.max(
1,
Math.floor(
- context.sampleRate *
- Math.max(0.001, this.config.graph.noiseBufferDurationSeconds)
+ context.sampleRate * Math.max(0.001, graphTuning.noiseBufferDurationSeconds)
)
),
context.sampleRate
@@ -263,8 +331,8 @@ export class GardenAudioGraph {
for (let index = 0; index < data.length; index++) {
data[index] =
- this.config.graph.noiseMin +
- Math.random() * (this.config.graph.noiseMax - this.config.graph.noiseMin);
+ graphTuning.noiseMin +
+ Math.random() * (graphTuning.noiseMax - graphTuning.noiseMin);
}
return buffer;
@@ -280,6 +348,13 @@ export class GardenAudioGraph {
this.delayNode = null;
this.delayFeedback = null;
this.delayOutput = null;
+ this.mediaStreamDestination = null;
+ if (this.mediaStreamElement) {
+ this.mediaStreamElement.pause();
+ this.mediaStreamElement.srcObject = null;
+ this.mediaStreamElement.remove();
+ this.mediaStreamElement = null;
+ }
this.pianoBuses.clear();
}
}
diff --git a/src/audio/garden-audio-input.test.ts b/src/audio/garden-audio-input.test.ts
deleted file mode 100644
index 32cc1f6..0000000
--- a/src/audio/garden-audio-input.test.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-import { describe, expect, it } from 'vitest';
-
-import { gardenAudioConfig } from './garden-audio-config';
-import { getStrokeMetrics } from './garden-audio-input';
-
-describe('getStrokeMetrics', () => {
- it('normalizes stroke distance against canvas size', () => {
- const standardDensity = getStrokeMetrics(
- {
- vibe: {} as never,
- from: [0, 0],
- to: [100, 0],
- canvasSize: [1000, 500],
- elapsedSeconds: 0.1,
- isErasing: false,
- },
- gardenAudioConfig.input
- );
- const highDensity = getStrokeMetrics(
- {
- vibe: {} as never,
- from: [0, 0],
- to: [200, 0],
- canvasSize: [2000, 1000],
- elapsedSeconds: 0.1,
- isErasing: false,
- },
- gardenAudioConfig.input
- );
-
- expect(highDensity.normalizedDistance).toBeCloseTo(
- standardDensity.normalizedDistance
- );
- expect(highDensity.normalizedSpeed).toBeCloseTo(standardDensity.normalizedSpeed);
- });
-
- it('uses configured elapsed-time floors for missing or invalid samples', () => {
- const metrics = getStrokeMetrics(
- {
- vibe: {} as never,
- from: [0, 0],
- to: [10, 0],
- elapsedSeconds: 0,
- isErasing: false,
- },
- gardenAudioConfig.input
- );
-
- expect(metrics.elapsedSeconds).toBe(gardenAudioConfig.input.fallbackFrameSeconds);
- expect(Number.isFinite(metrics.normalizedSpeed)).toBe(true);
- });
-});
diff --git a/src/audio/garden-audio-input.ts b/src/audio/garden-audio-input.ts
index 75522fd..ffe4bef 100644
--- a/src/audio/garden-audio-input.ts
+++ b/src/audio/garden-audio-input.ts
@@ -1,7 +1,8 @@
-import type { GardenAudioConfig } from './garden-audio-config';
import type { GardenAudioStroke } from './garden-audio-types';
const fallbackNormalizationPixels = 1000;
+const fallbackFrameSeconds = 1 / 60;
+const minElapsedSeconds = 0.001;
export interface GardenAudioStrokeMetrics {
distancePixels: number;
@@ -10,14 +11,11 @@ export interface GardenAudioStrokeMetrics {
normalizedSpeed: number;
}
-export const getStrokeMetrics = (
- stroke: GardenAudioStroke,
- inputConfig: GardenAudioConfig['input']
-): GardenAudioStrokeMetrics => {
+export const getStrokeMetrics = (stroke: GardenAudioStroke): GardenAudioStrokeMetrics => {
const dx = stroke.to[0] - stroke.from[0];
const dy = stroke.to[1] - stroke.from[1];
const distancePixels = Math.hypot(dx, dy);
- const elapsedSeconds = getElapsedSeconds(stroke, inputConfig);
+ const elapsedSeconds = getElapsedSeconds(stroke);
const normalizedDistance = distancePixels / getStrokeNormalizationPixels(stroke);
return {
@@ -28,19 +26,16 @@ export const getStrokeMetrics = (
};
};
-const getElapsedSeconds = (
- stroke: GardenAudioStroke,
- inputConfig: GardenAudioConfig['input']
-): number => {
+const getElapsedSeconds = (stroke: GardenAudioStroke): number => {
if (
stroke.elapsedSeconds !== undefined &&
Number.isFinite(stroke.elapsedSeconds) &&
stroke.elapsedSeconds > 0
) {
- return Math.max(inputConfig.minElapsedSeconds, stroke.elapsedSeconds);
+ return Math.max(minElapsedSeconds, stroke.elapsedSeconds);
}
- return inputConfig.fallbackFrameSeconds;
+ return fallbackFrameSeconds;
};
const getStrokeNormalizationPixels = (stroke: GardenAudioStroke): number => {
diff --git a/src/audio/garden-audio-music.ts b/src/audio/garden-audio-music.ts
index 417065c..8b4ee2d 100644
--- a/src/audio/garden-audio-music.ts
+++ b/src/audio/garden-audio-music.ts
@@ -1,9 +1,14 @@
-import { VibePreset } from '../vibes';
-import {
- GardenAudioChord,
- gardenAudioConfig,
- GardenAudioVibeProfile,
-} from './garden-audio-config';
+import type { VibePreset } from '../vibes';
+import type { GardenAudioChord, GardenAudioVibeProfile } from './garden-audio-config';
+
+export const PITCH_SEMITONES_PER_OCTAVE = 12;
+
+const chordVoicings = {
+ majorOpen: [0, 7, 12, 16],
+ minorOpen: [0, 7, 12, 15],
+ majorClosed: [0, 4, 7, 12, 16],
+ minorClosed: [0, 3, 7, 12, 15],
+};
export const getVibeProfile = (vibe: VibePreset): GardenAudioVibeProfile => vibe.audio;
@@ -12,14 +17,12 @@ export const getChordIntervals = (
openVoicing: boolean
): Array => {
if (openVoicing) {
- return chord.quality === 'major'
- ? gardenAudioConfig.generativePiano.chordVoicings.majorOpen
- : gardenAudioConfig.generativePiano.chordVoicings.minorOpen;
+ return chord.quality === 'major' ? chordVoicings.majorOpen : chordVoicings.minorOpen;
}
return chord.quality === 'major'
- ? gardenAudioConfig.generativePiano.chordVoicings.majorClosed
- : gardenAudioConfig.generativePiano.chordVoicings.minorClosed;
+ ? chordVoicings.majorClosed
+ : chordVoicings.minorClosed;
};
export const degreeToSemitone = (
@@ -29,7 +32,5 @@ export const degreeToSemitone = (
const scaleIndex =
((degree % profile.scale.length) + profile.scale.length) % profile.scale.length;
const octave = Math.floor(degree / profile.scale.length);
- return (
- profile.scale[scaleIndex] + octave * gardenAudioConfig.piano.pitchSemitonesPerOctave
- );
+ return profile.scale[scaleIndex] + octave * PITCH_SEMITONES_PER_OCTAVE;
};
diff --git a/src/audio/garden-audio.test.ts b/src/audio/garden-audio.test.ts
deleted file mode 100644
index ba15c57..0000000
--- a/src/audio/garden-audio.test.ts
+++ /dev/null
@@ -1,326 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-
-import { appConfig } from '../config';
-import { VIBE_PRESETS } from '../vibes';
-import { gardenAudioConfig, GardenAudioConfig } from './garden-audio-config';
-
-type FakeScheduledSourceNode = {
- start: ReturnType;
- stop: ReturnType;
-};
-
-const calls = {
- constructed: 0,
- resumed: 0,
- sourcesStarted: 0,
- sources: [] as Array,
- gains: [] as Array,
-};
-
-let contextState: AudioContextState = 'suspended';
-let resumeError: Error | null = null;
-let ErrorHandler: typeof import('../utils/error-handler').ErrorHandler;
-let GardenAudio: typeof import('./garden-audio').GardenAudio;
-let loadPianoSamples: typeof import('./piano-samples').loadPianoSamples;
-let Severity: typeof import('../utils/error-handler').Severity;
-
-class FakeAudioParam {
- public value = 0;
- public setTargetAtTime = vi.fn();
- public setValueAtTime = vi.fn();
- public exponentialRampToValueAtTime = vi.fn();
- public cancelScheduledValues = vi.fn();
-}
-
-class FakeAudioNode {
- public readonly gain = new FakeAudioParam();
- public readonly frequency = new FakeAudioParam();
- public readonly playbackRate = new FakeAudioParam();
- public readonly Q = new FakeAudioParam();
- public readonly threshold = new FakeAudioParam();
- public readonly knee = new FakeAudioParam();
- public readonly ratio = new FakeAudioParam();
- public readonly attack = new FakeAudioParam();
- public readonly release = new FakeAudioParam();
- public readonly delayTime = new FakeAudioParam();
- public readonly pan = new FakeAudioParam();
- public type = '';
- public addEventListener = vi.fn();
- public connect = vi.fn();
- public disconnect = vi.fn();
-}
-
-class FakeAudioBuffer {
- private readonly data: Float32Array;
-
- public constructor(length: number) {
- this.data = new Float32Array(length);
- }
-
- public getChannelData(): Float32Array {
- return this.data;
- }
-}
-
-class FakeAudioContext {
- public readonly currentTime = 1;
- public readonly sampleRate = 16;
- public readonly destination = new FakeAudioNode() as unknown as AudioDestinationNode;
- public readonly decodeAudioData = vi.fn(async () => ({}) as AudioBuffer);
-
- public constructor() {
- calls.constructed += 1;
- }
-
- public get state(): AudioContextState {
- return contextState;
- }
-
- public set state(state: AudioContextState) {
- contextState = state;
- }
-
- public createGain(): GainNode {
- const node = new FakeAudioNode();
- calls.gains.push(node);
- return node as unknown as GainNode;
- }
-
- public createBiquadFilter(): BiquadFilterNode {
- return new FakeAudioNode() as unknown as BiquadFilterNode;
- }
-
- public createDelay(): DelayNode {
- return new FakeAudioNode() as unknown as DelayNode;
- }
-
- public createDynamicsCompressor(): DynamicsCompressorNode {
- return new FakeAudioNode() as unknown as DynamicsCompressorNode;
- }
-
- public createStereoPanner(): StereoPannerNode {
- return new FakeAudioNode() as unknown as StereoPannerNode;
- }
-
- public createBuffer(_channels: number, length: number): AudioBuffer {
- return new FakeAudioBuffer(length) as unknown as AudioBuffer;
- }
-
- public createBufferSource(): AudioBufferSourceNode {
- const node = new FakeAudioNode() as unknown as AudioBufferSourceNode & {
- buffer: AudioBuffer | null;
- start: () => void;
- stop: () => void;
- };
- node.buffer = null;
- node.start = vi.fn(() => {
- calls.sourcesStarted += 1;
- }) as unknown as typeof node.start;
- node.stop = vi.fn() as unknown as typeof node.stop;
- calls.sources.push(node as unknown as FakeScheduledSourceNode);
- return node;
- }
-
- public createOscillator(): OscillatorNode {
- const node = new FakeAudioNode() as unknown as OscillatorNode & {
- start: () => void;
- stop: () => void;
- };
- node.start = vi.fn(() => {
- calls.sourcesStarted += 1;
- }) as unknown as typeof node.start;
- node.stop = vi.fn() as unknown as typeof node.stop;
- calls.sources.push(node as unknown as FakeScheduledSourceNode);
- return node;
- }
-
- public async resume(): Promise {
- calls.resumed += 1;
- if (resumeError) {
- throw resumeError;
- }
- contextState = 'running';
- }
-}
-
-const makeConfig = (): GardenAudioConfig => ({
- ...gardenAudioConfig,
-});
-
-describe('GardenAudio startup policy', () => {
- beforeEach(async () => {
- vi.resetModules();
- ({ ErrorHandler, Severity } = await import('../utils/error-handler'));
- ({ GardenAudio } = await import('./garden-audio'));
- ({ loadPianoSamples } = await import('./piano-samples'));
-
- calls.constructed = 0;
- calls.resumed = 0;
- calls.sourcesStarted = 0;
- calls.sources = [];
- calls.gains = [];
- contextState = 'suspended';
- resumeError = null;
- vi.stubGlobal('AudioContext', FakeAudioContext);
- vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('not loaded in tests')));
- });
-
- afterEach(() => {
- vi.restoreAllMocks();
- vi.unstubAllGlobals();
- });
-
- it('does not create an AudioContext from passive audio paths', () => {
- const audio = new GardenAudio(makeConfig());
- const vibe = VIBE_PRESETS[0];
-
- audio.start(vibe);
- audio.stroke({
- vibe,
- from: [0, 0],
- to: [12, 0],
- isErasing: false,
- });
-
- expect(calls.constructed).toBe(0);
- });
-
- it('only resumes a suspended context from a user gesture start', () => {
- const audio = new GardenAudio(makeConfig());
- const vibe = VIBE_PRESETS[0];
-
- audio.start(vibe, { userGesture: true });
-
- expect(calls.constructed).toBe(1);
- expect(calls.resumed).toBe(1);
- expect(contextState).toBe('running');
-
- contextState = 'suspended';
- audio.start(vibe);
- audio.setMuted(false);
-
- expect(calls.resumed).toBe(1);
- });
-
- it('reports AudioContext resume failures as warnings', async () => {
- const audio = new GardenAudio(makeConfig());
- const vibe = VIBE_PRESETS[0];
- resumeError = new Error('resume rejected');
- const addException = vi.spyOn(ErrorHandler, 'addException');
-
- audio.start(vibe, { userGesture: true });
- await Promise.resolve();
- await Promise.resolve();
-
- expect(addException).toHaveBeenCalledWith(resumeError, {
- fallbackMessage: 'Could not resume audio playback.',
- severity: Severity.WARNING,
- });
- });
-
- it('updates live master gain from adjustable volume', () => {
- const config = makeConfig();
- const audio = new GardenAudio(config);
- const vibe = VIBE_PRESETS[0];
-
- audio.start(vibe, { userGesture: true });
- const masterGain = calls.gains[0]?.gain;
- if (!masterGain) {
- throw new Error('Missing fake master gain');
- }
-
- audio.setMasterVolume(0.2);
-
- expect(masterGain.setTargetAtTime).toHaveBeenLastCalledWith(
- 0.2,
- 1,
- config.updateRampSeconds
- );
-
- audio.setMuted(true);
- const mutedCallCount = masterGain.setTargetAtTime.mock.calls.length;
- audio.setMasterVolume(0.8);
-
- expect(masterGain.setTargetAtTime).toHaveBeenCalledTimes(mutedCallCount);
-
- audio.setMuted(false);
-
- expect(masterGain.setTargetAtTime).toHaveBeenLastCalledWith(
- 0.8,
- 1,
- config.fadeInSeconds
- );
- });
-
- it('stays silent without piano samples while preserving eraser noise', () => {
- const audio = new GardenAudio(makeConfig());
- const vibe = VIBE_PRESETS[0];
-
- audio.start(vibe, { userGesture: true });
- expect(calls.sourcesStarted).toBe(1);
-
- audio.beginGesture();
- audio.stroke({
- vibe,
- from: [30, 40],
- to: [60, 60],
- isErasing: false,
- elapsedSeconds: 0.05,
- });
-
- expect(calls.sourcesStarted).toBe(1);
-
- audio.stroke({
- vibe,
- from: [60, 60],
- to: [75, 80],
- isErasing: true,
- elapsedSeconds: 0.05,
- });
-
- expect(calls.sourcesStarted).toBe(2);
- });
-
- it('quickly stops active piano voices when the vibe changes', async () => {
- vi.stubGlobal(
- 'fetch',
- vi.fn(async () => ({
- arrayBuffer: async () => new ArrayBuffer(8),
- ok: true,
- }))
- );
- await loadPianoSamples(new FakeAudioContext() as unknown as AudioContext);
-
- const audio = new GardenAudio(makeConfig());
- const vibe = VIBE_PRESETS[0];
-
- audio.start(vibe, { userGesture: true });
- audio.beginGesture();
- audio.stroke({
- vibe,
- from: [30, 40],
- to: [90, 40],
- elapsedSeconds: 0.05,
- isErasing: false,
- });
-
- const activePianoSources = calls.sources.filter(
- (source) => source.stop.mock.calls.length === 1
- );
- expect(activePianoSources.length).toBeGreaterThan(0);
-
- const stopCounts = activePianoSources.map((source) => source.stop.mock.calls.length);
- audio.changeVibe(VIBE_PRESETS[1], { userGesture: true });
-
- const stoppedVoices = activePianoSources.filter(
- (source, index) => source.stop.mock.calls.length === stopCounts[index] + 1
- );
- expect(stoppedVoices.length).toBeGreaterThan(0);
- stoppedVoices.forEach((source) => {
- expect(source.stop.mock.calls.at(-1)?.[0]).toBeCloseTo(
- 1 + appConfig.audio.piano.voiceStealStopSeconds,
- 3
- );
- });
- });
-});
diff --git a/src/audio/garden-audio.ts b/src/audio/garden-audio.ts
index ff8e6bd..024a6cb 100644
--- a/src/audio/garden-audio.ts
+++ b/src/audio/garden-audio.ts
@@ -1,7 +1,7 @@
-import { clamp01 } from '../utils/clamp';
import { ErrorHandler, Severity } from '../utils/error-handler';
+import { clamp01 } from '../utils/math';
import type { VibeId, VibePreset } from '../vibes';
-import { GardenAudioConfig } from './garden-audio-config';
+import type { GardenAudioConfig } from './garden-audio-config';
import { GardenAudioEnergy } from './garden-audio-energy';
import { GardenAudioGestureState } from './garden-audio-gesture-state';
import { GardenAudioGraph } from './garden-audio-graph';
@@ -24,6 +24,12 @@ export type {
type AudioLifecycle = 'idle' | 'started' | 'destroyed';
+const muteGain = 0.0001;
+const muteRampSeconds = 0.02;
+const brushUpPianoFinishSeconds = 1.2;
+const brushUpPianoFadeSeconds = 1.1;
+const vibeChangeStingerMinIntervalSeconds = 0.45;
+
export class GardenAudio {
private readonly graph: GardenAudioGraph;
private readonly piano: PianoSampler;
@@ -36,7 +42,10 @@ export class GardenAudio {
private lifecycle: AudioLifecycle = 'idle';
private isMuted = false;
private isGestureActive = false;
+ private isPianoStoppedAfterGesture = false;
+ private fadePianoAfter: number | null = null;
private masterVolume: number;
+ private stopPianoAfter: number | null = null;
private lastEraserAt = Number.NEGATIVE_INFINITY;
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
@@ -44,50 +53,55 @@ export class GardenAudio {
this.masterVolume = clamp01(config.masterVolume);
this.graph = new GardenAudioGraph(config);
this.piano = new PianoSampler(config, this.graph);
- this.noise = new NoiseBurstPlayer(config, this.graph);
+ this.noise = new NoiseBurstPlayer(this.graph);
this.energy = new GardenAudioEnergy(config);
this.gestureState = new GardenAudioGestureState(config.input);
this.pianoEngine = new GenerativePianoEngine(config, (note) => this.piano.play(note));
}
public start(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
- if (this.lifecycle === 'destroyed' || this.isMuted) {
+ const isUserGesture = options.userGesture === true;
+
+ if (this.lifecycle === 'destroyed') {
return;
}
- const context = this.graph.ensureContext(options.userGesture === true);
+ const context = this.graph.ensureContext(isUserGesture);
if (!context) {
return;
}
- const startupRampSeconds =
- options.userGesture === true
- ? this.config.muteRampSeconds
- : this.config.fadeInSeconds;
+ const startupRampSeconds = isUserGesture
+ ? muteRampSeconds
+ : this.config.fadeInSeconds;
const needsResume = context.state !== 'running' && context.state !== 'closed';
let resumePromise: Promise | null = null;
+ if (isUserGesture) {
+ this.graph.startMediaElementOutput();
+ this.graph.unlock();
+ }
+
if (needsResume) {
- if (options.userGesture !== true) {
+ if (!isUserGesture) {
return;
}
resumePromise = context.resume();
}
- if (options.userGesture === true) {
+ if (isUserGesture) {
this.graph.unlock();
}
if (resumePromise) {
void resumePromise
.then(() => {
- if (
- this.graph.context === context &&
- this.lifecycle !== 'destroyed' &&
- !this.isMuted
- ) {
+ if (this.graph.context === context && this.lifecycle !== 'destroyed') {
this.graph.unlock();
- this.graph.setMasterGain(this.masterVolume, startupRampSeconds);
+ this.completeStart(vibe, {
+ context,
+ startupRampSeconds,
+ });
}
})
.catch((error) => {
@@ -96,6 +110,32 @@ export class GardenAudio {
severity: Severity.WARNING,
});
});
+ return;
+ }
+
+ this.completeStart(vibe, {
+ context,
+ startupRampSeconds,
+ });
+ }
+
+ private completeStart(
+ vibe: VibePreset,
+ {
+ context,
+ startupRampSeconds,
+ }: {
+ context: AudioContext;
+ startupRampSeconds: number;
+ }
+ ): void {
+ if (this.graph.context !== context || this.lifecycle === 'destroyed') {
+ return;
+ }
+
+ if (this.isMuted) {
+ this.graph.setMasterGain(muteGain, muteRampSeconds);
+ return;
}
this.lifecycle = 'started';
@@ -111,7 +151,12 @@ export class GardenAudio {
this.pianoEngine.cue(context.currentTime);
}
})
- .catch(() => undefined);
+ .catch((error) => {
+ ErrorHandler.addException(error, {
+ fallbackMessage: 'Could not load piano samples. Using synthesized audio.',
+ severity: Severity.WARNING,
+ });
+ });
}
}
@@ -143,8 +188,8 @@ export class GardenAudio {
this.isMuted = isMuted;
this.graph.setMasterGain(
- isMuted ? this.config.muteGain : this.masterVolume,
- isMuted ? this.config.muteRampSeconds : this.config.fadeInSeconds
+ isMuted ? muteGain : this.masterVolume,
+ isMuted ? muteRampSeconds : this.config.fadeInSeconds
);
}
@@ -162,6 +207,9 @@ export class GardenAudio {
}
this.isGestureActive = true;
+ this.isPianoStoppedAfterGesture = false;
+ this.fadePianoAfter = null;
+ this.stopPianoAfter = null;
this.gestureState.beginGesture();
this.energy.beginGesture(context.currentTime);
this.pianoEngine.beginGesture();
@@ -170,6 +218,12 @@ export class GardenAudio {
public endGesture(): void {
this.gestureState.endGesture();
this.isGestureActive = false;
+ const context = this.graph.context;
+ this.isPianoStoppedAfterGesture = true;
+ this.fadePianoAfter = context
+ ? context.currentTime + brushUpPianoFinishSeconds
+ : null;
+ this.stopPianoAfter = null;
this.energy.endGesture();
this.pianoEngine.endGesture();
}
@@ -187,6 +241,21 @@ export class GardenAudio {
this.energy.silence();
}
+ if (!this.isGestureActive && this.isPianoStoppedAfterGesture) {
+ if (this.fadePianoAfter !== null && context.currentTime >= this.fadePianoAfter) {
+ this.piano.fadeAll(brushUpPianoFadeSeconds);
+ this.fadePianoAfter = null;
+ this.stopPianoAfter = context.currentTime + brushUpPianoFadeSeconds;
+ }
+ if (this.stopPianoAfter !== null && context.currentTime >= this.stopPianoAfter) {
+ this.piano.stopAll();
+ this.pianoEngine.reset();
+ this.stopPianoAfter = null;
+ }
+ this.updateDelay(snapshot);
+ return;
+ }
+
this.pianoEngine.renderLookahead({
vibe: snapshot.vibe,
now: context.currentTime,
@@ -211,7 +280,7 @@ export class GardenAudio {
return;
}
- const metrics = getStrokeMetrics(stroke, this.config.input);
+ const metrics = getStrokeMetrics(stroke);
const now = context.currentTime;
const frame = this.gestureState.recordStroke({ metrics });
@@ -242,6 +311,9 @@ export class GardenAudio {
this.pianoEngine.reset();
this.currentVibeId = null;
this.isGestureActive = false;
+ this.isPianoStoppedAfterGesture = false;
+ this.fadePianoAfter = null;
+ this.stopPianoAfter = null;
this.lastEraserAt = Number.NEGATIVE_INFINITY;
this.lastVibeStingerAt = Number.NEGATIVE_INFINITY;
}
@@ -253,7 +325,7 @@ export class GardenAudio {
}
const now = context.currentTime;
- if (now - this.lastVibeStingerAt < this.config.vibeChangeStingerMinIntervalSeconds) {
+ if (now - this.lastVibeStingerAt < vibeChangeStingerMinIntervalSeconds) {
return;
}
diff --git a/src/audio/generative-piano.test.ts b/src/audio/generative-piano.test.ts
deleted file mode 100644
index 59b4135..0000000
--- a/src/audio/generative-piano.test.ts
+++ /dev/null
@@ -1,249 +0,0 @@
-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 = [];
- const engine = new GenerativePianoEngine(gardenAudioConfig, (note) => {
- notes.push(note);
- });
-
- return { engine, notes };
-};
-
-const getBeatSeconds = (): number => 60 / gardenAudioConfig.rhythm.bpm;
-
-const getBeatsPerBar = (): number =>
- Math.round(
- gardenAudioConfig.rhythm.stepsPerBar / gardenAudioConfig.rhythm.stepsPerBeat
- );
-
-const renderBars = (
- engine: GenerativePianoEngine,
- activity: number,
- bars = 8,
- now = 0
-) => {
- engine.renderLookahead({
- vibe: VIBE_PRESETS[0],
- now,
- activity,
- lookaheadSeconds: getBeatSeconds() * getBeatsPerBar() * bars,
- });
-};
-
-const average = (values: Array): number =>
- values.reduce((sum, value) => sum + value, 0) / values.length;
-
-const uniqueStartTimes = (notes: Array): Array =>
- Array.from(new Set(notes.map((note) => note.startTime.toFixed(3))));
-
-const countNotesBetween = (
- notes: Array,
- startSeconds: number,
- endSeconds: number
-): number =>
- notes.filter((note) => note.startTime >= startSeconds && note.startTime < endSeconds)
- .length;
-
-const getNoteKey = (note: PianoNote): string =>
- [
- note.startTime.toFixed(3),
- note.midi,
- note.role ?? 'none',
- note.pan.toFixed(3),
- ].join(':');
-
-describe('GenerativePianoEngine', () => {
- it('plays quiet background music even when the garden is idle', () => {
- const { engine, notes } = makeEngine();
-
- renderBars(engine, 0);
-
- expect(notes.length).toBeGreaterThan(0);
- expect(notes.some((note) => note.durationSeconds > getBeatSeconds() * 6)).toBe(true);
- expect(Math.max(...notes.map((note) => note.velocity))).toBeLessThan(0.12);
- });
-
- it('keeps the background sparse instead of filling every beat', () => {
- const { engine, notes } = makeEngine();
-
- renderBars(engine, 0, 4);
-
- expect(uniqueStartTimes(notes).length).toBeLessThan(8);
- });
-
- it('lets activity add density without changing the beat grid', () => {
- const idle = makeEngine();
- const active = makeEngine();
- const startDelaySeconds = 0.02;
-
- renderBars(idle.engine, 0, 8);
- renderBars(active.engine, 1, 8);
-
- expect(active.notes.length).toBeGreaterThan(idle.notes.length);
- active.notes.forEach((note) => {
- const beatsFromStart = (note.startTime - startDelaySeconds) / getBeatSeconds();
- expect(Math.abs(beatsFromStart - Math.round(beatsFromStart))).toBeLessThan(0.001);
- });
- });
-
- it('uses style pools with multiple notes instead of one repeating key', () => {
- const { engine, notes } = makeEngine();
-
- renderBars(engine, 1, 16);
-
- expect(new Set(notes.map((note) => note.midi)).size).toBeGreaterThan(3);
- });
-
- it('changes musical style over time without a color change', () => {
- const { engine, notes } = makeEngine();
-
- renderBars(engine, 1, 32);
-
- const styleWindows = [
- notes.filter((note) => note.startTime >= 0 && note.startTime < 8),
- notes.filter((note) => note.startTime >= 8 && note.startTime < 16),
- notes.filter((note) => note.startTime >= 16 && note.startTime < 24),
- ];
- const averageMidiByWindow = styleWindows.map((windowNotes) =>
- Math.round(average(windowNotes.map((note) => note.midi)))
- );
- const averagePanByWindow = styleWindows.map((windowNotes) =>
- Number(average(windowNotes.map((note) => note.pan)).toFixed(2))
- );
-
- expect(styleWindows.every((windowNotes) => windowNotes.length > 0)).toBe(true);
- expect(new Set(averageMidiByWindow).size).toBeGreaterThan(1);
- expect(new Set(averagePanByWindow).size).toBeGreaterThan(1);
- });
-
- it('starts a fading brush phrase layer with each new brush gesture', () => {
- const baseline = makeEngine();
- const layered = makeEngine();
- const now = 4;
-
- baseline.engine.renderLookahead({
- vibe: VIBE_PRESETS[0],
- now,
- activity: 0.35,
- lookaheadSeconds: 12,
- });
-
- layered.engine.beginGesture();
- layered.engine.recordStroke({
- vibe: VIBE_PRESETS[0],
- now,
- activity: 0.85,
- });
- layered.engine.renderLookahead({
- vibe: VIBE_PRESETS[0],
- now,
- activity: 0.35,
- lookaheadSeconds: 12,
- });
-
- const earlyExtra =
- countNotesBetween(layered.notes, now + 1, now + 5) -
- countNotesBetween(baseline.notes, now + 1, now + 5);
- const lateExtra =
- countNotesBetween(layered.notes, now + 10.5, now + 12) -
- countNotesBetween(baseline.notes, now + 10.5, now + 12);
-
- expect(earlyExtra).toBeGreaterThan(2);
- expect(lateExtra).toBe(0);
- });
-
- it('plays one immediate touch note and throttles later stroke accents', () => {
- const { engine, notes } = makeEngine();
- const now = 4;
-
- engine.beginGesture();
- engine.recordStroke({
- vibe: VIBE_PRESETS[0],
- now,
- activity: 0.9,
- });
- engine.recordStroke({
- vibe: VIBE_PRESETS[0],
- now: now + 1,
- activity: 0.95,
- });
-
- expect(notes).toHaveLength(1);
- expect(notes[0].startTime).toBe(now);
-
- engine.recordStroke({
- vibe: VIBE_PRESETS[0],
- now: now + 6,
- activity: 0.95,
- });
-
- expect(notes).toHaveLength(2);
- expect(new Set(notes.map((note) => note.midi)).size).toBeGreaterThan(1);
- });
-
- it('is deterministic for the same musical inputs', () => {
- const first = makeEngine();
- const second = makeEngine();
-
- renderBars(first.engine, 0.78, 16);
- renderBars(second.engine, 0.78, 16);
-
- expect(second.notes).toEqual(first.notes);
- });
-
- it('does not duplicate notes across overlapping lookahead windows', () => {
- const { engine, notes } = makeEngine();
-
- engine.renderLookahead({
- vibe: VIBE_PRESETS[0],
- now: 0,
- activity: 0.72,
- lookaheadSeconds: getBeatSeconds() * 2,
- });
- engine.renderLookahead({
- vibe: VIBE_PRESETS[0],
- now: getBeatSeconds() * 0.5,
- activity: 0.72,
- lookaheadSeconds: getBeatSeconds() * 2,
- });
-
- const noteKeys = notes.map(getNoteKey);
- expect(new Set(noteKeys).size).toBe(noteKeys.length);
- });
-
- it('keeps generated notes inside the audio contract', () => {
- const { engine, notes } = makeEngine();
-
- VIBE_PRESETS.forEach((vibe) => {
- engine.cue(0);
- engine.renderLookahead({
- vibe,
- now: 0,
- activity: 1,
- lookaheadSeconds: getBeatSeconds() * getBeatsPerBar() * 4,
- });
- });
-
- notes.forEach((note) => {
- expect(Number.isFinite(note.startTime)).toBe(true);
- expect(note.midi).toBeGreaterThanOrEqual(21);
- expect(note.midi).toBeLessThanOrEqual(108);
- expect(note.velocity).toBeGreaterThan(0);
- expect(note.velocity).toBeLessThanOrEqual(0.4);
- expect(note.pan).toBeGreaterThanOrEqual(-1);
- expect(note.pan).toBeLessThanOrEqual(1);
- expect(note.durationSeconds).toBeGreaterThan(0);
- expect(note.lowpassHz ?? gardenAudioConfig.piano.lowpassHz).toBeGreaterThanOrEqual(
- gardenAudioConfig.piano.lowpassMinHz
- );
- expect(note.lowpassHz ?? gardenAudioConfig.piano.lowpassHz).toBeLessThanOrEqual(
- gardenAudioConfig.piano.lowpassMaxHz
- );
- });
- });
-});
diff --git a/src/audio/generative-piano.ts b/src/audio/generative-piano.ts
index c0971fe..482a4fd 100644
--- a/src/audio/generative-piano.ts
+++ b/src/audio/generative-piano.ts
@@ -1,18 +1,28 @@
-import { clamp, clamp01 } from '../utils/clamp';
-import { VibePreset } from '../vibes';
-import {
+import { clamp, clamp01 } from '../utils/math';
+import type { VibePreset } from '../vibes';
+import type {
GardenAudioChord,
GardenAudioConfig,
- GardenAudioRegister,
- GardenAudioStylePool,
GardenAudioVibeProfile,
} from './garden-audio-config';
import {
degreeToSemitone,
getChordIntervals,
getVibeProfile,
+ PITCH_SEMITONES_PER_OCTAVE,
} from './garden-audio-music';
-import { PianoNote } from './garden-audio-types';
+import {
+ GENERATIVE_LOOKAHEAD_SECONDS,
+ GENERATIVE_START_DELAY_SECONDS,
+ PIANO_SCHEDULE_AHEAD_SECONDS,
+} from './garden-audio-scheduling';
+import type { PianoNote } from './garden-audio-types';
+import {
+ generativePianoTuning,
+ styleVoices,
+ type GardenAudioRegister,
+ type GardenAudioStylePool,
+} from './generative-piano-tuning';
type GardenAudioStyleIndex = 0 | 1 | 2;
@@ -87,8 +97,8 @@ export class GenerativePianoEngine {
private readonly playNote: (note: PianoNote) => void
) {}
- private get generation(): GardenAudioConfig['generativePiano'] {
- return this.config.generativePiano;
+ private get generation(): typeof generativePianoTuning {
+ return generativePianoTuning;
}
public prime(now: number): void {
@@ -190,7 +200,7 @@ export class GenerativePianoEngine {
vibe,
now,
activity,
- lookaheadSeconds = this.config.rhythm.lookaheadSeconds,
+ lookaheadSeconds = GENERATIVE_LOOKAHEAD_SECONDS,
}: RenderLookaheadRequest): void {
this.prime(now);
this.skipLateBeats(now);
@@ -418,7 +428,7 @@ export class GenerativePianoEngine {
velocity:
(this.generation.supportNote.velocityBase +
expression * this.generation.supportNote.velocityExpressionWeight) *
- this.config.styleVoices[styleIndex].velocityMultiplier,
+ styleVoices[styleIndex].velocityMultiplier,
startTime,
durationSeconds:
this.generation.supportNote.durationBaseSeconds +
@@ -464,7 +474,7 @@ export class GenerativePianoEngine {
velocity:
(this.generation.textureNote.velocityBase +
expression * this.generation.textureNote.velocityExpressionWeight) *
- this.config.styleVoices[styleIndex].velocityMultiplier,
+ styleVoices[styleIndex].velocityMultiplier,
startTime,
durationSeconds:
this.generation.textureNote.durationBaseSeconds +
@@ -511,7 +521,7 @@ export class GenerativePianoEngine {
velocity:
(this.generation.gestureAccent.velocityBase +
strength * this.generation.gestureAccent.velocityStrengthWeight) *
- this.config.styleVoices[styleIndex].velocityMultiplier,
+ styleVoices[styleIndex].velocityMultiplier,
startTime,
durationSeconds:
this.generation.gestureAccent.durationBaseSeconds +
@@ -560,7 +570,7 @@ export class GenerativePianoEngine {
velocity:
(this.generation.touchNote.velocityBase +
strength * this.generation.touchNote.velocityStrengthWeight) *
- this.config.styleVoices[styleIndex].velocityMultiplier,
+ styleVoices[styleIndex].velocityMultiplier,
startTime: now,
durationSeconds:
this.generation.touchNote.durationBaseSeconds +
@@ -661,7 +671,7 @@ export class GenerativePianoEngine {
lookaheadEnd: number;
activity: number;
}): void {
- const earliestStart = now + this.config.piano.scheduleAheadSeconds;
+ const earliestStart = now + PIANO_SCHEDULE_AHEAD_SECONDS;
this.nextBrushStreamStep ??= 0;
this.pruneBrushStreamNoteCounts(this.getGlobalBarIndex(now) - 1);
@@ -770,7 +780,7 @@ export class GenerativePianoEngine {
velocity:
(this.generation.brushStream.velocityBase +
intensity * this.generation.brushStream.velocityIntensityWeight) *
- this.config.styleVoices[styleIndex].velocityMultiplier,
+ styleVoices[styleIndex].velocityMultiplier,
startTime,
durationSeconds,
pan,
@@ -803,7 +813,7 @@ export class GenerativePianoEngine {
velocity:
(this.generation.brushStreamEcho.velocityBase +
intensity * this.generation.brushStreamEcho.velocityIntensityWeight) *
- this.config.styleVoices[styleIndex].velocityMultiplier,
+ styleVoices[styleIndex].velocityMultiplier,
startTime: startTime + this.generation.brushMotifCanonDelaySeconds,
durationSeconds: Math.max(
this.generation.brushStreamEcho.durationMinSeconds,
@@ -872,8 +882,8 @@ export class GenerativePianoEngine {
: intensity >= this.generation.brushStream.intenseThreshold
? this.generation.brushStreamIntenseIntervalBeats
: intensity >= this.generation.brushStream.activeThreshold
- ? this.generation.brushStreamActiveIntervalBeats
- : this.generation.brushStreamIdleIntervalBeats;
+ ? this.generation.brushStreamActiveIntervalBeats
+ : this.generation.brushStreamIdleIntervalBeats;
return Math.max(1, Math.round(intervalBeats * this.config.rhythm.stepsPerBeat));
}
@@ -913,7 +923,7 @@ export class GenerativePianoEngine {
pool: GardenAudioStylePool;
styleIndex: GardenAudioStyleIndex;
}): Array {
- const styleOffset = this.config.styleVoices[styleIndex].scaleDegreeOffset;
+ const styleOffset = styleVoices[styleIndex].scaleDegreeOffset;
if (!layer || layer.motifOffsets.length === 0) {
return this.rotate(pool.scaleDegrees, this.brushStreamNoteIndex + styleOffset);
}
@@ -990,10 +1000,7 @@ export class GenerativePianoEngine {
octave <= this.generation.candidateOctaveSearch.max;
octave += 1
) {
- const midi =
- pitchSource.baseMidi +
- offset +
- octave * this.config.piano.pitchSemitonesPerOctave;
+ const midi = pitchSource.baseMidi + offset + octave * PITCH_SEMITONES_PER_OCTAVE;
if (midi >= register.midiMin && midi <= register.midiMax) {
const roundedMidi = Math.round(midi);
candidates.push({
@@ -1080,7 +1087,7 @@ export class GenerativePianoEngine {
private getStylePan(styleIndex: GardenAudioStyleIndex): number {
const pool = this.generation.stylePools[styleIndex];
- const styleVoice = this.config.styleVoices[styleIndex];
+ const styleVoice = styleVoices[styleIndex];
return clamp(
pool.pan + styleVoice.panOffset * this.generation.stylePanOffsetScale,
-1,
@@ -1113,7 +1120,7 @@ export class GenerativePianoEngine {
return;
}
- const earliestStart = now + this.config.piano.scheduleAheadSeconds;
+ const earliestStart = now + PIANO_SCHEDULE_AHEAD_SECONDS;
if (this.getTimeForStep(this.nextBeatStep) >= earliestStart) {
return;
}
@@ -1152,7 +1159,7 @@ export class GenerativePianoEngine {
private getTimeForStep(stepIndex: number): number {
return (
(this.timelineStartedAt ?? 0) +
- this.config.startDelaySeconds +
+ GENERATIVE_START_DELAY_SECONDS +
stepIndex * this.getStepDurationSeconds()
);
}
@@ -1161,7 +1168,7 @@ export class GenerativePianoEngine {
const timelineStartedAt = this.timelineStartedAt ?? startTime;
const elapsedSeconds = Math.max(
0,
- startTime - timelineStartedAt - this.config.startDelaySeconds
+ startTime - timelineStartedAt - GENERATIVE_START_DELAY_SECONDS
);
return Math.floor(elapsedSeconds / this.getStepDurationSeconds());
}
@@ -1170,7 +1177,7 @@ export class GenerativePianoEngine {
const timelineStartedAt = this.timelineStartedAt ?? startTime;
const elapsedSeconds = Math.max(
0,
- startTime - timelineStartedAt - this.config.startDelaySeconds
+ startTime - timelineStartedAt - GENERATIVE_START_DELAY_SECONDS
);
return Math.max(
0,
diff --git a/src/audio/noise-burst-player.ts b/src/audio/noise-burst-player.ts
index f9c7f6b..3ba5b62 100644
--- a/src/audio/noise-burst-player.ts
+++ b/src/audio/noise-burst-player.ts
@@ -1,12 +1,18 @@
-import type { GardenAudioConfig } from './garden-audio-config';
-import { GardenAudioGraph } from './garden-audio-graph';
-import { NoiseBurst } from './garden-audio-types';
+import { createAudioPanNode } from './audio-pan-node';
+import type { GardenAudioGraph } from './garden-audio-graph';
+import type { NoiseBurst } from './garden-audio-types';
+
+const noiseBurstTuning = {
+ attackSeconds: 0.004,
+ filterQ: 1.4,
+ offsetRandomSeconds: 0.4,
+ scheduleAheadSeconds: 0.002,
+ silentGain: 0.0001,
+ filterType: 'bandpass' as BiquadFilterType,
+};
export class NoiseBurstPlayer {
- public constructor(
- private readonly config: GardenAudioConfig,
- private readonly graph: GardenAudioGraph
- ) {}
+ public constructor(private readonly graph: GardenAudioGraph) {}
public play({ startTime, durationSeconds, gain, filterHz, pan }: NoiseBurst): void {
const { context, eventBus, noiseBus, noiseBuffer } = this.graph;
@@ -16,35 +22,30 @@ export class NoiseBurstPlayer {
}
const scheduledStart = Math.max(
- context.currentTime + this.config.noiseBurst.scheduleAheadSeconds,
+ context.currentTime + noiseBurstTuning.scheduleAheadSeconds,
startTime
);
const source = context.createBufferSource();
const filter = context.createBiquadFilter();
const envelope = context.createGain();
- const panner = context.createStereoPanner();
+ const panNode = createAudioPanNode(context, pan, scheduledStart);
const stopAt = scheduledStart + durationSeconds;
source.buffer = noiseBuffer;
- filter.type = this.config.noiseBurst.filterType;
+ filter.type = noiseBurstTuning.filterType;
filter.frequency.setValueAtTime(filterHz, scheduledStart);
- filter.Q.value = this.config.noiseBurst.filterQ;
- envelope.gain.setValueAtTime(this.config.noiseBurst.silentGain, scheduledStart);
+ filter.Q.value = noiseBurstTuning.filterQ;
+ envelope.gain.setValueAtTime(noiseBurstTuning.silentGain, scheduledStart);
envelope.gain.exponentialRampToValueAtTime(
- Math.max(this.config.noiseBurst.silentGain, gain),
- scheduledStart + this.config.noiseBurst.attackSeconds
+ Math.max(noiseBurstTuning.silentGain, gain),
+ scheduledStart + noiseBurstTuning.attackSeconds
);
- envelope.gain.exponentialRampToValueAtTime(this.config.noiseBurst.silentGain, stopAt);
- panner.pan.setValueAtTime(pan, scheduledStart);
-
+ envelope.gain.exponentialRampToValueAtTime(noiseBurstTuning.silentGain, stopAt);
source.connect(filter);
filter.connect(envelope);
- envelope.connect(panner);
- panner.connect(outputBus);
- source.start(
- scheduledStart,
- Math.random() * this.config.noiseBurst.offsetRandomSeconds
- );
+ envelope.connect(panNode.input);
+ panNode.output.connect(outputBus);
+ source.start(scheduledStart, Math.random() * noiseBurstTuning.offsetRandomSeconds);
source.stop(stopAt);
source.addEventListener(
'ended',
@@ -52,7 +53,7 @@ export class NoiseBurstPlayer {
source.disconnect();
filter.disconnect();
envelope.disconnect();
- panner.disconnect();
+ panNode.disconnect();
},
{ once: true }
);
diff --git a/src/audio/piano-sampler.test.ts b/src/audio/piano-sampler.test.ts
deleted file mode 100644
index 6e61743..0000000
--- a/src/audio/piano-sampler.test.ts
+++ /dev/null
@@ -1,167 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-
-import { gardenAudioConfig } from './garden-audio-config';
-import type { GardenAudioGraph } from './garden-audio-graph';
-import type { PianoSampler } from './piano-sampler';
-
-const calls = {
- bufferSourcesStarted: 0,
-};
-const sampleCount = 30;
-
-class FakeAudioParam {
- public value = 0;
- public setTargetAtTime = vi.fn();
- public setValueAtTime = vi.fn();
- public exponentialRampToValueAtTime = vi.fn();
- public cancelScheduledValues = vi.fn();
-}
-
-class FakeAudioNode {
- public readonly gain = new FakeAudioParam();
- public readonly frequency = new FakeAudioParam();
- public readonly playbackRate = new FakeAudioParam();
- public readonly Q = new FakeAudioParam();
- public readonly pan = new FakeAudioParam();
- public buffer: AudioBuffer | null = null;
- public type = '';
- public addEventListener = vi.fn();
- public connect = vi.fn();
- public disconnect = vi.fn();
- public start = vi.fn();
- public stop = vi.fn();
-}
-
-class FakeAudioContext {
- public readonly currentTime = 1;
- public readonly decodeAudioData = vi.fn(async () => ({}) as AudioBuffer);
-
- public createGain(): GainNode {
- return new FakeAudioNode() as unknown as GainNode;
- }
-
- public createBiquadFilter(): BiquadFilterNode {
- return new FakeAudioNode() as unknown as BiquadFilterNode;
- }
-
- public createStereoPanner(): StereoPannerNode {
- return new FakeAudioNode() as unknown as StereoPannerNode;
- }
-
- public createBufferSource(): AudioBufferSourceNode {
- const node = new FakeAudioNode() as unknown as AudioBufferSourceNode & {
- start: () => void;
- stop: () => void;
- };
- node.start = vi.fn(() => {
- calls.bufferSourcesStarted += 1;
- });
- node.stop = vi.fn();
- return node;
- }
-}
-
-const makeSampler = async (context: AudioContext): Promise => {
- const { PianoSampler } = await import('./piano-sampler');
- const eventBus = new FakeAudioNode() as unknown as GainNode;
- const graph = {
- context,
- delayInput: null,
- eventBus,
- getPianoBus: vi.fn(() => eventBus),
- } as unknown as GardenAudioGraph;
-
- return new PianoSampler(gardenAudioConfig, graph);
-};
-
-describe('PianoSampler', () => {
- beforeEach(() => {
- calls.bufferSourcesStarted = 0;
- vi.resetModules();
- });
-
- afterEach(() => {
- vi.unstubAllGlobals();
- });
-
- it('loads every piano sample before playback', async () => {
- const context = new FakeAudioContext() as unknown as AudioContext;
- const sampler = await makeSampler(context);
- const fetch = vi.fn(async () => {
- return {
- arrayBuffer: async () => new ArrayBuffer(8),
- ok: true,
- } as Response;
- });
- vi.stubGlobal('fetch', fetch);
-
- await sampler.load(context);
- sampler.play({
- durationSeconds: 0.2,
- midi: 60,
- pan: 0,
- startTime: context.currentTime,
- velocity: 0.5,
- });
-
- expect(fetch).toHaveBeenCalledTimes(sampleCount);
- expect(context.decodeAudioData).toHaveBeenCalledTimes(sampleCount);
- expect(calls.bufferSourcesStarted).toBe(1);
- });
-
- it('only queues a piano load when the sampler is idle', async () => {
- const context = new FakeAudioContext() as unknown as AudioContext;
- const sampler = await makeSampler(context);
- const fetch = vi.fn(async () => {
- return {
- arrayBuffer: async () => new ArrayBuffer(8),
- ok: true,
- } as Response;
- });
- vi.stubGlobal('fetch', fetch);
-
- const firstLoad = sampler.loadIfIdle(context);
- const secondLoad = sampler.loadIfIdle(context);
-
- expect(firstLoad).toBeInstanceOf(Promise);
- expect(secondLoad).toBeNull();
-
- await firstLoad;
-
- expect(sampler.loadIfIdle(context)).toBeNull();
- expect(fetch).toHaveBeenCalledTimes(sampleCount);
- });
-
- it('allows loading to be retried after a load failure', async () => {
- const context = new FakeAudioContext() as unknown as AudioContext;
- const sampler = await makeSampler(context);
- const fetch = vi
- .fn()
- .mockRejectedValueOnce(new Error('load failed'))
- .mockResolvedValue({
- arrayBuffer: async () => new ArrayBuffer(8),
- ok: true,
- } as Response);
- vi.stubGlobal('fetch', fetch);
-
- await expect(sampler.loadIfIdle(context)).rejects.toThrow('load failed');
- await expect(sampler.loadIfIdle(context)).resolves.toBeUndefined();
-
- expect(fetch).toHaveBeenCalledTimes(sampleCount * 2);
- });
-
- it('stays silent when no decoded sample is available', () => {
- const context = new FakeAudioContext() as unknown as AudioContext;
- return makeSampler(context).then((sampler) => {
- sampler.play({
- durationSeconds: 0.2,
- midi: 60,
- pan: 0,
- startTime: context.currentTime,
- velocity: 0.5,
- });
-
- expect(calls.bufferSourcesStarted).toBe(0);
- });
- });
-});
diff --git a/src/audio/piano-sampler.ts b/src/audio/piano-sampler.ts
index e8314c5..c3a5007 100644
--- a/src/audio/piano-sampler.ts
+++ b/src/audio/piano-sampler.ts
@@ -1,11 +1,32 @@
-import { clamp, clamp01 } from '../utils/clamp';
-import { GardenAudioConfig } from './garden-audio-config';
-import { GardenAudioGraph } from './garden-audio-graph';
-import { ActivePianoVoice, LoadedPianoSample, PianoNote } from './garden-audio-types';
+import { clamp, clamp01 } from '../utils/math';
+import { createAudioPanNode } from './audio-pan-node';
+import type { GardenAudioConfig } from './garden-audio-config';
+import type { GardenAudioGraph } from './garden-audio-graph';
+import { PITCH_SEMITONES_PER_OCTAVE } from './garden-audio-music';
+import { PIANO_SCHEDULE_AHEAD_SECONDS } from './garden-audio-scheduling';
+import type {
+ ActivePianoVoice,
+ LoadedPianoSample,
+ PianoNote,
+} from './garden-audio-types';
import { getLoadedPianoSamples, loadPianoSamples } from './piano-samples';
type PianoLoadState = 'idle' | 'loading' | 'loaded';
+const pianoSamplerTuning = {
+ filterType: 'lowpass' as BiquadFilterType,
+ filterQ: 0.7,
+ minDurationSeconds: 0.08,
+ minFadeSeconds: 0.08,
+ minGain: 0.0001,
+ synthGainScale: 0.34,
+ synthMaxDurationSeconds: 1.8,
+ synthOscillatorType: 'triangle' as OscillatorType,
+ tailStopExtraSeconds: 0.05,
+ voiceStealFadeSeconds: 0.025,
+ voiceStealStopSeconds: 0.05,
+};
+
export class PianoSampler {
private loadState: PianoLoadState = 'idle';
private sampleLoadPromise: Promise | null = null;
@@ -34,7 +55,9 @@ export class PianoSampler {
}
this.loadState = 'loading';
- this.sampleLoadPromise = loadPianoSamples(context)
+ this.sampleLoadPromise = loadPianoSamples(context, undefined, {
+ forceReload: true,
+ })
.then((samples) => {
this.setSamples(samples);
this.loadState = 'loaded';
@@ -74,16 +97,26 @@ export class PianoSampler {
const sample = this.findNearestSample(midi);
if (!sample) {
+ this.playSynthFallback({
+ midi,
+ velocity,
+ startTime,
+ durationSeconds,
+ pan,
+ role,
+ delaySend,
+ lowpassHz,
+ });
return;
}
const scheduledStart = Math.max(
- context.currentTime + this.config.piano.scheduleAheadSeconds,
+ context.currentTime + PIANO_SCHEDULE_AHEAD_SECONDS,
startTime
);
const noteVelocity = clamp01(velocity);
const noteGainValue = Math.max(
- this.config.piano.minGain,
+ pianoSamplerTuning.minGain,
this.config.piano.gain * noteVelocity
);
const sustainSeconds =
@@ -91,14 +124,14 @@ export class PianoSampler {
(this.config.piano.sustainBase +
noteVelocity * this.config.piano.sustainVelocityRange);
const sustainAt =
- scheduledStart + Math.max(this.config.piano.minDurationSeconds, durationSeconds);
+ scheduledStart + Math.max(pianoSamplerTuning.minDurationSeconds, durationSeconds);
const releaseAt = sustainAt + sustainSeconds;
const releaseSeconds = this.config.piano.releaseSeconds;
const stopAt = releaseAt + releaseSeconds;
const source = context.createBufferSource();
const filter = context.createBiquadFilter();
const gain = context.createGain();
- const panner = context.createStereoPanner();
+ const panNode = createAudioPanNode(context, pan, scheduledStart);
let sendGain: GainNode | null = null;
this.trimActiveVoices(scheduledStart);
@@ -111,45 +144,46 @@ export class PianoSampler {
source.buffer = sample.buffer;
source.playbackRate.setValueAtTime(
- Math.pow(2, (midi - sample.midi) / this.config.piano.pitchSemitonesPerOctave),
+ Math.pow(2, (midi - sample.midi) / PITCH_SEMITONES_PER_OCTAVE),
scheduledStart
);
- filter.type = this.config.piano.filterType;
+ filter.type = pianoSamplerTuning.filterType;
filter.frequency.setValueAtTime(
clamp(lowpassHz, this.config.piano.lowpassMinHz, this.config.piano.lowpassMaxHz),
scheduledStart
);
- filter.Q.value = this.config.piano.filterQ;
- gain.gain.setValueAtTime(this.config.piano.minGain, scheduledStart);
+ filter.Q.value = pianoSamplerTuning.filterQ;
+ gain.gain.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart);
gain.gain.exponentialRampToValueAtTime(
noteGainValue,
scheduledStart + this.config.piano.gainAttackSeconds
);
gain.gain.setTargetAtTime(
- Math.max(this.config.piano.minGain, noteGainValue * this.config.piano.sustainLevel),
+ Math.max(
+ pianoSamplerTuning.minGain,
+ noteGainValue * this.config.piano.sustainLevel
+ ),
sustainAt,
Math.max(
- this.config.piano.minFadeSeconds,
+ pianoSamplerTuning.minFadeSeconds,
sustainSeconds * this.config.piano.sustainBase
)
);
- gain.gain.setTargetAtTime(this.config.piano.minGain, releaseAt, releaseSeconds);
- panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
-
+ gain.gain.setTargetAtTime(pianoSamplerTuning.minGain, releaseAt, releaseSeconds);
source.connect(filter);
filter.connect(gain);
- gain.connect(panner);
- panner.connect(eventBus);
+ gain.connect(panNode.input);
+ panNode.output.connect(eventBus);
if (delayInput && delaySend > 0) {
sendGain = context.createGain();
sendGain.gain.value = delaySend;
- panner.connect(sendGain);
+ panNode.output.connect(sendGain);
sendGain.connect(delayInput);
}
source.start(scheduledStart);
- source.stop(stopAt + this.config.piano.tailStopExtraSeconds);
+ source.stop(stopAt + pianoSamplerTuning.tailStopExtraSeconds);
this.activeVoices.push({ gain, source, stopAt });
source.addEventListener(
@@ -158,7 +192,7 @@ export class PianoSampler {
source.disconnect();
filter.disconnect();
gain.disconnect();
- panner.disconnect();
+ panNode.disconnect();
sendGain?.disconnect();
this.activeVoices = this.activeVoices.filter((voice) => voice.gain !== gain);
},
@@ -181,6 +215,22 @@ export class PianoSampler {
this.activeVoices = [];
}
+ public fadeAll(fadeSeconds: number): void {
+ const context = this.graph.context;
+ if (!context) {
+ this.activeVoices = [];
+ return;
+ }
+
+ const now = context.currentTime;
+ const fadeDurationSeconds = Math.max(pianoSamplerTuning.minFadeSeconds, fadeSeconds);
+
+ this.trimActiveVoices(now);
+ this.activeVoices.forEach((voice) => {
+ this.fadeVoice(voice, now, fadeDurationSeconds);
+ });
+ }
+
public reset(): void {
this.loadState = 'idle';
this.sampleLoadPromise = null;
@@ -198,18 +248,114 @@ export class PianoSampler {
);
}
+ private playSynthFallback({
+ midi,
+ velocity,
+ startTime,
+ durationSeconds,
+ pan,
+ role,
+ delaySend = 0,
+ lowpassHz = this.config.piano.lowpassHz,
+ }: PianoNote): void {
+ const { context, delayInput } = this.graph;
+ const eventBus = this.graph.getPianoBus(role);
+ if (!context || !eventBus) {
+ return;
+ }
+
+ const scheduledStart = Math.max(
+ context.currentTime + PIANO_SCHEDULE_AHEAD_SECONDS,
+ startTime
+ );
+ const noteVelocity = clamp01(velocity);
+ const noteGainValue = Math.max(
+ pianoSamplerTuning.minGain,
+ this.config.piano.gain * noteVelocity * pianoSamplerTuning.synthGainScale
+ );
+ const releaseAt =
+ scheduledStart +
+ clamp(
+ durationSeconds + this.config.piano.sustainSeconds * 0.5,
+ pianoSamplerTuning.minDurationSeconds,
+ pianoSamplerTuning.synthMaxDurationSeconds
+ );
+ const stopAt = releaseAt + this.config.piano.releaseSeconds;
+ const source = context.createOscillator();
+ const filter = context.createBiquadFilter();
+ const gain = context.createGain();
+ const panNode = createAudioPanNode(context, pan, scheduledStart);
+ let sendGain: GainNode | null = null;
+
+ this.trimActiveVoices(scheduledStart);
+ while (this.activeVoices.length >= this.config.piano.maxVoices) {
+ const oldest = this.activeVoices.shift();
+ if (oldest) {
+ this.stopVoice(oldest, scheduledStart);
+ }
+ }
+
+ source.type = pianoSamplerTuning.synthOscillatorType;
+ source.frequency.setValueAtTime(getMidiFrequency(midi), scheduledStart);
+ filter.type = pianoSamplerTuning.filterType;
+ filter.frequency.setValueAtTime(
+ clamp(lowpassHz, this.config.piano.lowpassMinHz, this.config.piano.lowpassMaxHz),
+ scheduledStart
+ );
+ filter.Q.value = pianoSamplerTuning.filterQ;
+ gain.gain.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart);
+ gain.gain.exponentialRampToValueAtTime(
+ noteGainValue,
+ scheduledStart + this.config.piano.gainAttackSeconds
+ );
+ gain.gain.setTargetAtTime(
+ pianoSamplerTuning.minGain,
+ releaseAt,
+ this.config.piano.releaseSeconds
+ );
+
+ source.connect(filter);
+ filter.connect(gain);
+ gain.connect(panNode.input);
+ panNode.output.connect(eventBus);
+
+ if (delayInput && delaySend > 0) {
+ sendGain = context.createGain();
+ sendGain.gain.value = delaySend;
+ panNode.output.connect(sendGain);
+ sendGain.connect(delayInput);
+ }
+
+ source.start(scheduledStart);
+ source.stop(stopAt + pianoSamplerTuning.tailStopExtraSeconds);
+ this.activeVoices.push({ gain, source, stopAt });
+
+ source.addEventListener(
+ 'ended',
+ () => {
+ source.disconnect();
+ filter.disconnect();
+ gain.disconnect();
+ panNode.disconnect();
+ sendGain?.disconnect();
+ this.activeVoices = this.activeVoices.filter((voice) => voice.gain !== gain);
+ },
+ { once: true }
+ );
+ }
+
private trimActiveVoices(now: number): void {
this.activeVoices = this.activeVoices.filter((voice) => voice.stopAt > now);
}
private stopVoice(voice: ActivePianoVoice, now: number): void {
- const stopAt = now + this.config.piano.voiceStealStopSeconds;
+ const stopAt = now + pianoSamplerTuning.voiceStealStopSeconds;
voice.gain.gain.cancelScheduledValues(now);
voice.gain.gain.setTargetAtTime(
- this.config.piano.minGain,
+ pianoSamplerTuning.minGain,
now,
- this.config.piano.voiceStealFadeSeconds
+ pianoSamplerTuning.voiceStealFadeSeconds
);
voice.stopAt = stopAt;
try {
@@ -219,7 +365,31 @@ export class PianoSampler {
}
}
+ private fadeVoice(
+ voice: ActivePianoVoice,
+ now: number,
+ fadeDurationSeconds: number
+ ): void {
+ const stopAt = Math.min(voice.stopAt, now + fadeDurationSeconds);
+
+ voice.gain.gain.cancelScheduledValues(now);
+ voice.gain.gain.setTargetAtTime(
+ pianoSamplerTuning.minGain,
+ now,
+ Math.max(0.001, fadeDurationSeconds / 4)
+ );
+ voice.stopAt = stopAt;
+ try {
+ voice.source.stop(stopAt + pianoSamplerTuning.tailStopExtraSeconds);
+ } catch {
+ // The voice may already have ended; either way it is fading out of the mix.
+ }
+ }
+
private setSamples(samples: Array): void {
this.samples = samples.slice().sort((a, b) => a.midi - b.midi);
}
}
+
+const getMidiFrequency = (midi: number): number =>
+ 440 * Math.pow(2, (midi - 69) / PITCH_SEMITONES_PER_OCTAVE);
diff --git a/src/audio/piano-samples.ts b/src/audio/piano-samples.ts
index 914e831..d456968 100644
--- a/src/audio/piano-samples.ts
+++ b/src/audio/piano-samples.ts
@@ -1,4 +1,3 @@
-import { gardenAudioConfig } from './garden-audio-config';
import type { LoadedPianoSample } from './garden-audio-types';
interface PianoSampleDefinition {
@@ -45,21 +44,36 @@ const sampleFiles: Array<[fileName: string, midi: number]> = [
['C8v12.m4a', 108],
];
+const sampleBaseUrl = `${import.meta.env.BASE_URL}audio/`;
+const preloadDecode = {
+ channels: 2,
+ frames: 128,
+ sampleRateHz: 48_000,
+};
+
const pianoSampleDefinitions: Array = sampleFiles
.map(([fileName, midi]) => ({
midi,
- url: `${gardenAudioConfig.piano.sampleBaseUrl}${fileName}`,
+ url: `${sampleBaseUrl}${fileName}`,
}))
.sort((a, b) => a.midi - b.midi);
let loadedPianoSamples: Array | null = null;
let pianoSampleLoadPromise: Promise> | null = null;
+interface PianoSampleLoadOptions {
+ forceReload?: boolean;
+}
+
+const sampleLoadTuning = {
+ concurrency: 4,
+ sampleTimeoutMs: 15_000,
+};
+
export const preloadPianoSamples = (
onProgress?: (progress: PianoSampleLoadProgress) => void
): Promise> => {
- const OfflineAudioContextConstructor =
- globalThis.OfflineAudioContext ?? globalThis.webkitOfflineAudioContext;
+ const OfflineAudioContextConstructor = globalThis.OfflineAudioContext;
if (!OfflineAudioContextConstructor) {
return Promise.reject(
@@ -68,16 +82,17 @@ export const preloadPianoSamples = (
}
const decodeContext = new OfflineAudioContextConstructor(
- gardenAudioConfig.piano.preloadDecode.channels,
- gardenAudioConfig.piano.preloadDecode.frames,
- gardenAudioConfig.piano.preloadDecode.sampleRateHz
+ preloadDecode.channels,
+ preloadDecode.frames,
+ preloadDecode.sampleRateHz
);
return loadPianoSamples(decodeContext, onProgress);
};
export const loadPianoSamples = (
decodeContext: BaseAudioContext,
- onProgress?: (progress: PianoSampleLoadProgress) => void
+ onProgress?: (progress: PianoSampleLoadProgress) => void,
+ options: PianoSampleLoadOptions = {}
): Promise> => {
if (loadedPianoSamples) {
onProgress?.({
@@ -87,7 +102,7 @@ export const loadPianoSamples = (
return Promise.resolve([...loadedPianoSamples]);
}
- if (pianoSampleLoadPromise) {
+ if (pianoSampleLoadPromise && options.forceReload !== true) {
return pianoSampleLoadPromise;
}
@@ -95,16 +110,27 @@ export const loadPianoSamples = (
const totalCount = pianoSampleDefinitions.length;
onProgress?.({ loadedCount, totalCount });
- pianoSampleLoadPromise = Promise.all(
- pianoSampleDefinitions.map(async (sample) => {
- const loadedSample = await loadPianoSample(decodeContext, sample);
- loadedCount += 1;
- onProgress?.({ loadedCount, totalCount, sample });
- return loadedSample;
- })
+ pianoSampleLoadPromise = loadPianoSampleBatch(
+ pianoSampleDefinitions,
+ async (sample) => {
+ try {
+ return await withTimeout(
+ loadPianoSample(decodeContext, sample),
+ sampleLoadTuning.sampleTimeoutMs
+ );
+ } finally {
+ loadedCount += 1;
+ onProgress?.({ loadedCount, totalCount, sample });
+ }
+ }
).then(
(samples) => {
- loadedPianoSamples = samples.slice().sort((a, b) => a.midi - b.midi);
+ loadedPianoSamples = samples
+ .filter((sample): sample is LoadedPianoSample => sample !== null)
+ .sort((a, b) => a.midi - b.midi);
+ if (loadedPianoSamples.length === 0) {
+ throw new Error('Unable to load any piano samples.');
+ }
return [...loadedPianoSamples];
},
(error: unknown) => {
@@ -133,6 +159,43 @@ const loadPianoSample = async (
return { midi: sample.midi, buffer };
};
+const loadPianoSampleBatch = async (
+ samples: Array,
+ loadSample: (
+ sample: PianoSampleDefinition
+ ) => Promise
+): Promise> => {
+ const results: Array = [];
+
+ for (let index = 0; index < samples.length; index += sampleLoadTuning.concurrency) {
+ const batch = samples.slice(index, index + sampleLoadTuning.concurrency);
+ const batchResults = await Promise.all(
+ batch.map((sample) => loadSample(sample).catch(() => null))
+ );
+ results.push(...batchResults);
+ }
+
+ return results;
+};
+
+const withTimeout = (promise: Promise, timeoutMs: number): Promise =>
+ new Promise((resolve, reject) => {
+ const timeout = globalThis.setTimeout(() => {
+ reject(new Error('Timed out while loading a piano sample.'));
+ }, timeoutMs);
+
+ promise.then(
+ (value) => {
+ globalThis.clearTimeout(timeout);
+ resolve(value);
+ },
+ (error: unknown) => {
+ globalThis.clearTimeout(timeout);
+ reject(error);
+ }
+ );
+ });
+
const decodeAudioData = (
decodeContext: BaseAudioContext,
audioData: ArrayBuffer
diff --git a/src/config.ts b/src/config.ts
index de6a9c9..3df06aa 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -1,10 +1,9 @@
+import { APP_STORAGE_KEYS, DEFAULT_AUDIO_VOLUME } from './app-constants';
import { defaultSettings } from './config/default-settings';
import { runtimeControls } from './config/runtime-controls';
import type { GardenAppConfig } from './config/types';
import { defaultVibeId, vibePresets } from './config/vibe-presets';
-const defaultAudioMasterVolume = 0.42;
-
export { VibeId } from './config/types';
export type {
@@ -16,10 +15,9 @@ export type {
export const appConfig = {
audio: {
- masterVolume: defaultAudioMasterVolume,
+ masterVolume: DEFAULT_AUDIO_VOLUME,
fadeInSeconds: 0.45,
updateRampSeconds: 0.08,
- highPassFrequencyHz: 45,
delay: {
timeSeconds: 0.46,
feedback: 0.12,
@@ -32,44 +30,24 @@ export const appConfig = {
outputBase: 0.65,
outputActivityDuck: 0.28,
timeRampSeconds: 0.12,
- feedbackHighPassHz: 180,
- feedbackLowPassHz: 5200,
- returnLowPassHz: 6200,
},
piano: {
maxVoices: 24,
- filterType: 'lowpass',
gain: 0.48,
sustainSeconds: 0.42,
sustainLevel: 0.32,
releaseSeconds: 0.24,
lowpassHz: 7600,
- filterQ: 0.7,
gainAttackSeconds: 0.006,
lowpassMaxHz: 12000,
lowpassMinHz: 1400,
- minDurationSeconds: 0.08,
- minFadeSeconds: 0.08,
- minGain: 0.0001,
- pitchSemitonesPerOctave: 12,
- scheduleAheadSeconds: 0.002,
sustainBase: 0.45,
sustainVelocityRange: 0.55,
- tailStopExtraSeconds: 0.05,
- voiceStealFadeSeconds: 0.025,
- voiceStealStopSeconds: 0.05,
- sampleBaseUrl: `${import.meta.env.BASE_URL}audio/`,
- preloadDecode: {
- channels: 1,
- frames: 1,
- sampleRateHz: 44_100,
- },
},
rhythm: {
bpm: 74,
stepsPerBeat: 4,
stepsPerBar: 16,
- lookaheadSeconds: 0.3,
sparseActivity: 0.055,
},
eraser: {
@@ -89,19 +67,6 @@ export const appConfig = {
strokeDecaySeconds: 0.32,
},
graph: {
- closeGain: 0.0001,
- closeRampSeconds: 0.015,
- delayMaxSeconds: 2,
- eventBusGain: 1,
- noiseMax: 1,
- noiseMin: -1,
- unlockTickFrequencyHz: 440,
- unlockTickSeconds: 0.035,
- unlockTickType: 'sine',
- latencyHint: 'interactive',
- outputFilterType: 'highpass',
- noiseBufferChannels: 1,
- noiseBufferDurationSeconds: 1,
pianoBusGains: {
pad: 0.86,
support: 0.94,
@@ -119,16 +84,8 @@ export const appConfig = {
stinger: 0,
},
noiseBusGain: 0.72,
- compressor: {
- thresholdDb: -18,
- kneeDb: 18,
- ratio: 2.1,
- attackSeconds: 0.018,
- releaseSeconds: 0.18,
- },
},
input: {
- fallbackFrameSeconds: 1 / 60,
fullActivitySpeed: 0.86,
activityNoiseFloorSpeed: 0.025,
activityCurve: 0.74,
@@ -139,259 +96,7 @@ export const appConfig = {
manicActivityThreshold: 0.9,
manicReleaseThreshold: 0.76,
maniaSmoothingSeconds: 0.12,
- minElapsedSeconds: 0.001,
},
- muteGain: 0.0001,
- muteRampSeconds: 0.02,
- noiseBurst: {
- attackSeconds: 0.004,
- filterQ: 1.4,
- offsetRandomSeconds: 0.4,
- scheduleAheadSeconds: 0.002,
- silentGain: 0.0001,
- filterType: 'bandpass',
- },
- startDelaySeconds: 0.02,
- vibeChangeStingerMinIntervalSeconds: 0.45,
- generativePiano: {
- stylePools: [
- {
- midiMin: 48,
- midiMax: 67,
- preferredMidi: 55,
- pan: -0.18,
- scaleDegrees: [0, 1, 2, 4],
- },
- {
- midiMin: 55,
- midiMax: 74,
- preferredMidi: 63,
- pan: 0,
- scaleDegrees: [1, 2, 3, 5],
- },
- {
- midiMin: 62,
- midiMax: 81,
- preferredMidi: 72,
- pan: 0.18,
- scaleDegrees: [2, 3, 4, 6],
- },
- ],
- padRegisters: [
- {
- midiMin: 40,
- midiMax: 55,
- preferredMidi: 48,
- pan: -0.12,
- },
- {
- midiMin: 48,
- midiMax: 64,
- preferredMidi: 55,
- pan: 0.08,
- },
- {
- midiMin: 58,
- midiMax: 76,
- preferredMidi: 67,
- pan: 0.2,
- },
- ],
- chordVoicings: {
- majorOpen: [0, 7, 12, 16],
- minorOpen: [0, 7, 12, 15],
- majorClosed: [0, 4, 7, 12, 16],
- minorClosed: [0, 3, 7, 12, 15],
- },
- vibeChangeStinger: {
- velocities: [0.1, 0.085, 0.07],
- pans: [-0.16, 0, 0.16],
- delaySends: [0.012, 0.014, 0.016],
- lowpassExpression: 0.35,
- },
- highActivityExtra: {
- barOffset: 1,
- expressionMultiplier: 0.9,
- },
- padChord: {
- velocities: [0.052, 0.041, 0.033],
- expressionVelocityWeight: 0.02,
- delaySend: 0.008,
- lowpassExpressionWeight: 0.28,
- },
- supportNote: {
- velocityBase: 0.105,
- velocityExpressionWeight: 0.07,
- durationBaseSeconds: 1.35,
- durationExpressionSeconds: 0.4,
- delaySendBase: 0.016,
- delaySendExpressionWeight: 0.006,
- lowpassExpressionWeight: 0.7,
- expressionThreshold: 0.55,
- offsetsByStyle: [
- [0, 2, 12],
- [1, 2, 0, 12],
- [2, 12, 3, 13],
- ],
- },
- textureNote: {
- velocityBase: 0.09,
- velocityExpressionWeight: 0.08,
- durationBaseSeconds: 0.62,
- durationExpressionSeconds: 0.24,
- delaySendBase: 0.016,
- delaySendExpressionWeight: 0.006,
- idleExpressionThreshold: 0.35,
- mediumExpressionThreshold: 0.7,
- intenseSpacing: 1,
- idlePhase: 1,
- },
- gestureAccent: {
- rotationStrengthMultiplier: 3,
- quantizeStepLookahead: 1,
- velocityBase: 0.12,
- velocityStrengthWeight: 0.09,
- durationBaseSeconds: 0.48,
- durationStrengthSeconds: 0.22,
- delaySend: 0.012,
- },
- touchNote: {
- registerBiasManiaAmount: 0,
- velocityBase: 0.14,
- velocityStrengthWeight: 0.11,
- durationBaseSeconds: 0.55,
- durationStrengthSeconds: 0.18,
- delaySend: 0.006,
- lowpassBaseExpression: 0.55,
- lowpassStrengthWeight: 0.35,
- },
- brushPhrase: {
- initialMotifOffset: -1,
- energyDecaySeconds: 0.72,
- maniaDecaySeconds: 0.54,
- fadeMinimumLifetimeSeconds: 0.001,
- layerIntensityBase: 0.8,
- layerIntensityManiaWeight: 0.42,
- frameActivityWeight: 0.42,
- frameManiaWeight: 0.18,
- },
- brushStream: {
- inferredManiaThreshold: 0.82,
- inferredManiaRange: 0.18,
- registerManiaShift: 0.45,
- chordToneEverySteps: 4,
- durationBaseSeconds: 0.48,
- durationIntensitySeconds: 0.08,
- durationManiaSeconds: 0.34,
- durationMinSeconds: 0.14,
- durationMaxSeconds: 0.62,
- delaySendBase: 0.012,
- delaySendIntensityWeight: 0.011,
- delaySendManiaWeight: 0.006,
- delaySendMin: 0.006,
- delaySendMax: 0.032,
- velocityBase: 0.1,
- velocityIntensityWeight: 0.13,
- lowpassBaseExpression: 0.39,
- lowpassIntensityWeight: 0.48,
- lowpassManiaWeight: 0.18,
- manicThreshold: 0.85,
- intenseThreshold: 0.62,
- activeThreshold: 0.34,
- },
- brushStreamEcho: {
- maniaThreshold: 0.86,
- stepModulo: 2,
- stepRemainder: 1,
- intensityThreshold: 0.95,
- octaveSemitones: 12,
- maxMidi: 88,
- velocityBase: 0.045,
- velocityIntensityWeight: 0.05,
- durationMinSeconds: 0.11,
- durationScale: 0.68,
- panScale: -0.75,
- delaySendMin: 0.006,
- delaySendScale: 0.72,
- lowpassBaseExpression: 0.62,
- lowpassManiaWeight: 0.24,
- },
- brushMotif: {
- highThreshold: 0.82,
- mediumThreshold: 0.55,
- highOffset: 1,
- mediumOffset: 0,
- lowOffset: -1,
- minOffset: -3,
- maxOffset: 4,
- },
- registerBias: {
- maniaShiftSemitones: 4,
- midiMin: 36,
- midiMaxForMin: 86,
- minimumSpan: 4,
- midiMax: 91,
- },
- candidateOctaveSearch: {
- min: -3,
- max: 3,
- },
- stylePanOffsetScale: 0.35,
- lowpass: {
- midiBase: 48,
- midiRange: 33,
- midiLiftHz: 720,
- expressionBase: 0.58,
- expressionWeight: 0.32,
- },
- styleRotationBars: 2,
- chordBars: 4,
- supportBarSpacing: 2,
- supportBarOffset: 1,
- idleTextureBarSpacing: 2,
- mediumTextureBarSpacing: 1,
- textureBeat: 2,
- highActivityExtraBeat: 3,
- highActivityExtraThreshold: 0.45,
- noteScorePreferenceWeight: 1.8,
- noteScoreRegisterWeight: 0.28,
- noteScoreChordToneWeight: 0.75,
- noteScoreRepeatPenalty: 3.2,
- gestureAccentMinIntervalSeconds: 2.5,
- strokeAccentMinSteps: 12,
- strokeAccentThreshold: 0.58,
- stingerSpacingSeconds: 0.08,
- stingerDurationSeconds: 1.1,
- maxBrushPhraseLayers: 3,
- maxBrushStreamNotesPerBar: 9,
- brushLayerBaseSeconds: 5.5,
- brushLayerEnergySeconds: 2.5,
- brushLayerMinIntensity: 0.12,
- brushStreamIdleIntervalBeats: 2,
- brushStreamActiveIntervalBeats: 1,
- brushStreamIntenseIntervalBeats: 0.5,
- brushStreamManicIntervalBeats: 0.5,
- brushMotifMaxSteps: 8,
- brushMotifCanonDelaySeconds: 0.055,
- padDurationBarScale: 0.46,
- },
- styleVoices: [
- {
- scaleDegreeOffset: 0,
- velocityMultiplier: 0.92,
- panOffset: -0.14,
- },
- {
- scaleDegreeOffset: 1,
- velocityMultiplier: 1,
- panOffset: 0,
- },
- {
- scaleDegreeOffset: 2,
- velocityMultiplier: 0.86,
- panOffset: 0.14,
- },
- ],
},
deltaTime: {
maxDeltaTimeSeconds: 1 / 30,
@@ -444,10 +149,9 @@ export const appConfig = {
},
simulation: {
budget: {
- adaptiveCapDecreaseAgentsPerSecond: 50_000,
+ adaptiveCapDecreaseAgentsPerSecond: 200_000,
adaptiveCapInitial: 1_000_000,
- adaptiveCapMax: 2_000_000,
- adaptiveCapMin: 500_000,
+ adaptiveCapMin: 50_000,
adaptiveRefreshTargetFps: 60,
frameGapResetSeconds: 1,
fpsHeadroom: 0.95,
@@ -458,6 +162,7 @@ export const appConfig = {
brushEffectFramesPerSecond: 60,
clearColor: { r: 0, g: 0, b: 0, a: 0 },
initialAgentCount: 180_000,
+ maxDevicePixelRatio: 2,
intro: {
angleJitterRadians: Math.PI * 0.08,
angleEaseEnd: 1,
@@ -508,9 +213,9 @@ export const appConfig = {
},
},
storage: {
- audioMutedKey: 'fleeting-garden:audio-muted',
- audioVolumeKey: 'fleeting-garden:audio-volume',
- vibeKey: 'fleeting-garden:vibe',
+ audioMutedKey: APP_STORAGE_KEYS.audioMuted,
+ audioVolumeKey: APP_STORAGE_KEYS.audioVolume,
+ vibeKey: APP_STORAGE_KEYS.vibe,
},
toolbar: {
eraser: {
@@ -566,7 +271,7 @@ export const appConfig = {
whiteContrastNumerator: 1.05,
},
volume: {
- default: defaultAudioMasterVolume,
+ default: DEFAULT_AUDIO_VOLUME,
max: 1,
min: 0,
step: 0.01,
diff --git a/src/config/default-settings.ts b/src/config/default-settings.ts
index aa20316..47584d3 100644
--- a/src/config/default-settings.ts
+++ b/src/config/default-settings.ts
@@ -55,7 +55,9 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = {
eraserLineDistanceEpsilon: 0.0001,
eraserMaskAlphaThreshold: 0.5,
+ internalRenderAreaMegapixels: 8.3,
strokeSpawnSpreadBrushSizeMultiplier: 1,
+ maxAgentCount: 700_000,
renderTraceNormalizationFloor: 1,
renderBrushColorBase: 1.2,
diff --git a/src/config/runtime-controls.ts b/src/config/runtime-controls.ts
index b526d34..7d9870e 100644
--- a/src/config/runtime-controls.ts
+++ b/src/config/runtime-controls.ts
@@ -157,6 +157,13 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
max: 0.12,
step: 0.001,
},
+ internalRenderAreaMegapixels: {
+ folder: 'Render',
+ label: 'internal area (MP)',
+ min: 0.5,
+ max: 16.6,
+ step: 0.1,
+ },
decayRateBrush: {
folder: 'Diffusion',
min: 0.1,
@@ -248,6 +255,12 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
max: 1,
step: 0.001,
},
+ maxAgentCount: {
+ folder: 'Agent',
+ integer: true,
+ label: 'max agent count',
+ step: 10_000,
+ },
mirrorSegmentCount: {
folder: 'Brush',
integer: true,
diff --git a/src/config/types.ts b/src/config/types.ts
index 19fd7da..f347cea 100644
--- a/src/config/types.ts
+++ b/src/config/types.ts
@@ -11,8 +11,8 @@ export interface NumberControlConfig {
folder: string;
integer?: boolean;
label?: string;
- max: number;
- min: number;
+ max?: number;
+ min?: number;
options?: Record;
step?: number;
}
@@ -32,7 +32,9 @@ export type GardenRuntimeSettings = {
eraserLineDistanceEpsilon: number;
eraserMaskAlphaThreshold: number;
eraserSize: number;
+ internalRenderAreaMegapixels: number;
mirrorSegmentCount: number;
+ maxAgentCount: number;
selectedColorIndex: number;
spawnPerPixel: number;
strokeSpawnSpreadBrushSizeMultiplier: number;
@@ -137,7 +139,6 @@ export interface GardenAppConfig {
budget: {
adaptiveCapDecreaseAgentsPerSecond: number;
adaptiveCapInitial: number;
- adaptiveCapMax: number;
adaptiveCapMin: number;
adaptiveRefreshTargetFps: number;
frameGapResetSeconds: number;
@@ -149,6 +150,7 @@ export interface GardenAppConfig {
brushEffectFramesPerSecond: number;
clearColor: GPUColor;
initialAgentCount: number;
+ maxDevicePixelRatio: number;
intro: {
angleJitterRadians: number;
angleEaseEnd: number;
diff --git a/src/game-loop/agent-population.test.ts b/src/game-loop/agent-population.test.ts
deleted file mode 100644
index 6cce50a..0000000
--- a/src/game-loop/agent-population.test.ts
+++ /dev/null
@@ -1,141 +0,0 @@
-import { vec2 } from 'gl-matrix';
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-
-import { appConfig } from '../config';
-import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
-import { settings } from '../settings';
-import { AgentPopulation } from './agent-population';
-
-vi.hoisted(() => {
- Object.defineProperty(globalThis, 'localStorage', {
- configurable: true,
- value: {
- getItem: vi.fn(() => null),
- setItem: vi.fn(),
- },
- });
-});
-
-const originalBrushSize = settings.brushSize;
-const originalSelectedColorIndex = settings.selectedColorIndex;
-const originalSpawnPerPixel = settings.spawnPerPixel;
-const originalStrokeSpawnSpreadBrushSizeMultiplier =
- settings.strokeSpawnSpreadBrushSizeMultiplier;
-
-const createPopulation = () => {
- const pipeline = {
- maxAgentCount: 10_000_000,
- writeAgents: vi.fn(),
- resizeAgents: vi.fn(),
- compactAgents: vi.fn(),
- } as unknown as AgentGenerationPipeline;
-
- return new AgentPopulation(pipeline);
-};
-
-const setPopulationActiveCount = (population: AgentPopulation, activeCount: number) => {
- Object.assign(population as unknown as Record, {
- activeCount,
- });
-};
-
-const setPopulationAdaptiveCap = (population: AgentPopulation, adaptiveCap: number) => {
- Object.assign(population as unknown as Record, {
- adaptiveCap,
- });
-};
-
-const getPopulationAdaptiveCap = (population: AgentPopulation): number =>
- (population as unknown as { adaptiveCap: number }).adaptiveCap;
-
-describe('AgentPopulation adaptive budget', () => {
- beforeEach(() => {
- settings.brushSize = 1;
- settings.selectedColorIndex = 0;
- settings.spawnPerPixel = 1;
- settings.strokeSpawnSpreadBrushSizeMultiplier = 1;
- });
-
- afterEach(() => {
- settings.brushSize = originalBrushSize;
- settings.selectedColorIndex = originalSelectedColorIndex;
- settings.spawnPerPixel = originalSpawnPerPixel;
- settings.strokeSpawnSpreadBrushSizeMultiplier =
- originalStrokeSpawnSpreadBrushSizeMultiplier;
- vi.restoreAllMocks();
- });
-
- it('expands beyond the 1M start cap only when new agents arrive under healthy FPS', () => {
- const population = createPopulation();
- setPopulationActiveCount(population, 1_000_000);
-
- population.growBudget(1 / 60, 60);
- population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(16, 0));
-
- expect(getPopulationAdaptiveCap(population)).toBeGreaterThan(
- appConfig.simulation.budget.adaptiveCapInitial
- );
- expect(population.activeAgentCount).toBeGreaterThan(
- appConfig.simulation.budget.adaptiveCapInitial
- );
- expect(getPopulationAdaptiveCap(population)).toBeLessThanOrEqual(
- appConfig.simulation.budget.adaptiveCapMax
- );
- });
-
- it('does not grow the cap above the adaptive max agent count', () => {
- const population = createPopulation();
- const maxAgentCount = appConfig.simulation.budget.adaptiveCapMax;
- setPopulationAdaptiveCap(population, maxAgentCount - 1);
- setPopulationActiveCount(population, maxAgentCount - 1);
-
- population.growBudget(1 / 60, 60);
- population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(16, 0));
-
- expect(getPopulationAdaptiveCap(population)).toBe(maxAgentCount);
- expect(population.activeAgentCount).toBe(maxAgentCount);
- });
-
- it('clamps a stale cap before adding agents', () => {
- const population = createPopulation();
- const maxAgentCount = appConfig.simulation.budget.adaptiveCapMax;
- setPopulationAdaptiveCap(population, maxAgentCount + 1_000);
- setPopulationActiveCount(population, maxAgentCount);
-
- population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(16, 0));
-
- expect(getPopulationAdaptiveCap(population)).toBe(maxAgentCount);
- expect(population.activeAgentCount).toBe(maxAgentCount);
- });
-
- it('scales stroke spawn spread by device pixel ratio', () => {
- settings.brushSize = 10;
- const writeAgents = vi.fn();
- const pipeline = {
- maxAgentCount: 10_000_000,
- writeAgents,
- resizeAgents: vi.fn(),
- compactAgents: vi.fn(),
- } as unknown as AgentGenerationPipeline;
- const population = new AgentPopulation(pipeline, 0, () => 2);
- vi.spyOn(Math, 'random').mockReturnValue(1);
-
- population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(0, 0));
-
- const firstBatch = writeAgents.mock.calls[0][1] as Float32Array;
- expect(firstBatch[0]).toBe(10);
- expect(firstBatch[1]).toBe(10);
- });
-
- it('decreases the cap and active count slowly when FPS falls below the threshold', () => {
- const population = createPopulation();
- setPopulationActiveCount(population, 1_000_000);
-
- population.growBudget(10, 50);
-
- expect(getPopulationAdaptiveCap(population)).toBe(
- appConfig.simulation.budget.adaptiveCapMin
- );
- expect(population.activeAgentCount).toBe(appConfig.simulation.budget.adaptiveCapMin);
- });
-});
diff --git a/src/game-loop/agent-population.ts b/src/game-loop/agent-population.ts
index 89f40da..94d5846 100644
--- a/src/game-loop/agent-population.ts
+++ b/src/game-loop/agent-population.ts
@@ -1,9 +1,11 @@
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 { getSafeDevicePixelRatio } from '../pipelines/brush/brush-pipeline';
+import {
+ AGENT_FLOAT_COUNT,
+ AgentGenerationPipeline,
+} from '../pipelines/agents/agent-generation/agent-generation-pipeline';
+import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline';
import { settings } from '../settings';
import { createIntroTitleAgents } from './intro-title-agents';
@@ -14,6 +16,8 @@ export class AgentPopulation {
private canExpandAdaptiveCap = true;
private shouldCompactAfterErase = false;
private isCompacting = false;
+ private pendingCompaction: Promise | null = null;
+ private postCompactionWriteEnd = 0;
private readonly strokeAgentData = new Float32Array(
appConfig.simulation.stroke.maxAgentCount * AGENT_FLOAT_COUNT
);
@@ -21,7 +25,7 @@ export class AgentPopulation {
public constructor(
private readonly pipeline: AgentGenerationPipeline,
private readonly introSeed = Math.floor(Math.random() * 0xffffffff),
- private readonly getDevicePixelRatio = () => 1
+ private readonly getCanvasPixelRatio = () => 1
) {
this.adaptiveCap = this.clampAdaptiveCap(
appConfig.simulation.budget.adaptiveCapInitial
@@ -55,6 +59,7 @@ export class AgentPopulation {
}
this.pipeline.writeAgents(0, data);
+ this.markPostCompactionWrite(0, data.length / AGENT_FLOAT_COUNT);
this.activeCount = data.length / AGENT_FLOAT_COUNT;
this.replacementCursor = 0;
}
@@ -76,7 +81,7 @@ export class AgentPopulation {
this.shouldCompactAfterErase = true;
}
- public async compactAfterErase(isSwipeActive: boolean): Promise {
+ public compactAfterErase(isSwipeActive: boolean): void {
if (!this.shouldCompactAfterErase || this.isCompacting || isSwipeActive) {
return;
}
@@ -87,14 +92,33 @@ export class AgentPopulation {
}
this.isCompacting = true;
- try {
- const compactedAgentCount = await this.pipeline.compactAgents(this.activeCount);
- this.activeCount = compactedAgentCount;
- this.replacementCursor =
- compactedAgentCount === 0 ? 0 : this.replacementCursor % compactedAgentCount;
- } finally {
- this.isCompacting = false;
- }
+ this.postCompactionWriteEnd = 0;
+ this.pendingCompaction = this.pipeline
+ .compactAgents(this.activeCount)
+ .then((compactedAgentCount) => {
+ const finiteCompactedAgentCount = Number.isFinite(compactedAgentCount)
+ ? Math.max(0, Math.floor(compactedAgentCount))
+ : 0;
+ this.activeCount = Math.min(
+ this.activeCount,
+ Math.max(finiteCompactedAgentCount, this.postCompactionWriteEnd)
+ );
+ this.replacementCursor =
+ this.activeCount === 0 ? 0 : this.replacementCursor % this.activeCount;
+ this.trimActiveCountToBudget();
+ })
+ .catch((error: unknown) => {
+ console.warn('Could not compact agents after erase.', error);
+ })
+ .finally(() => {
+ this.isCompacting = false;
+ this.pendingCompaction = null;
+ this.postCompactionWriteEnd = 0;
+ });
+ }
+
+ public async waitForCompaction(): Promise {
+ await this.pendingCompaction;
}
public spawnStrokeAgents(from: vec2, to: vec2): void {
@@ -125,7 +149,7 @@ export class AgentPopulation {
const base = i * AGENT_FLOAT_COUNT;
const spread =
settings.brushSize *
- getSafeDevicePixelRatio(this.getDevicePixelRatio()) *
+ getSafePixelRatio(this.getCanvasPixelRatio()) *
settings.strokeSpawnSpreadBrushSizeMultiplier;
this.strokeAgentData[base] = x + (Math.random() - 0.5) * spread;
this.strokeAgentData[base + 1] = y + (Math.random() - 0.5) * spread;
@@ -157,6 +181,7 @@ export class AgentPopulation {
this.activeCount,
data.subarray(0, appendCount * AGENT_FLOAT_COUNT)
);
+ this.markPostCompactionWrite(this.activeCount, appendCount);
this.activeCount += appendCount;
}
@@ -175,12 +200,24 @@ export class AgentPopulation {
(sourceAgentOffset + chunkAgentCount) * AGENT_FLOAT_COUNT
)
);
+ this.markPostCompactionWrite(targetAgentOffset, chunkAgentCount);
sourceAgentOffset += chunkAgentCount;
this.replacementCursor = (targetAgentOffset + chunkAgentCount) % this.activeCount;
}
}
+ private markPostCompactionWrite(agentOffset: number, agentCount: number): void {
+ if (!this.isCompacting || agentCount <= 0) {
+ return;
+ }
+
+ this.postCompactionWriteEnd = Math.max(
+ this.postCompactionWriteEnd,
+ Math.ceil(agentOffset + agentCount)
+ );
+ }
+
private updateAdaptiveCap(deltaTime: number, smoothedFps: number): void {
const previousCap = this.clampAdaptiveCap(this.adaptiveCap);
this.canExpandAdaptiveCap =
@@ -200,7 +237,8 @@ export class AgentPopulation {
appConfig.simulation.budget.adaptiveCapDecreaseAgentsPerSecond * deltaTime
)
);
- const nextCap = this.clampAdaptiveCap(previousCap - decrease);
+ const responsiveCap = Math.min(previousCap, this.clampAdaptiveCap(this.activeCount));
+ const nextCap = this.clampAdaptiveCap(responsiveCap - decrease);
this.adaptiveCap = nextCap;
this.trimActiveCountToBudget(decrease);
}
@@ -230,10 +268,19 @@ export class AgentPopulation {
}
private clampAdaptiveCap(value: number): number {
- const pipelineCap = Math.max(0, Math.floor(this.pipeline.maxAgentCount));
- const maxCap = Math.min(appConfig.simulation.budget.adaptiveCapMax, pipelineCap);
+ const runtimeMaxCap =
+ settings.maxAgentCount === Number.POSITIVE_INFINITY
+ ? Number.POSITIVE_INFINITY
+ : Number.isFinite(settings.maxAgentCount)
+ ? Math.max(0, Math.floor(settings.maxAgentCount))
+ : Math.max(0, Math.floor(this.pipeline.maxAgentCount));
+ const maxCap = Math.min(this.pipeline.maxSupportedAgentCount, runtimeMaxCap);
const minCap = Math.min(appConfig.simulation.budget.adaptiveCapMin, maxCap);
const finiteValue = Number.isFinite(value) ? value : minCap;
- return Math.min(maxCap, Math.max(minCap, Math.round(finiteValue)));
+ const nextCap = Math.min(maxCap, Math.max(minCap, Math.round(finiteValue)));
+ return Math.min(
+ nextCap,
+ this.pipeline.ensureMaxAgentCount(nextCap, this.activeCount)
+ );
}
}
diff --git a/src/game-loop/export-4k.test.ts b/src/game-loop/export-4k.test.ts
deleted file mode 100644
index 27b7e8d..0000000
--- a/src/game-loop/export-4k.test.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-import { describe, expect, it } from 'vitest';
-
-import {
- estimateExport4KMemory,
- 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);
- });
-
- 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();
- });
-});
diff --git a/src/game-loop/frame-performance.test.ts b/src/game-loop/frame-performance.test.ts
deleted file mode 100644
index 5facc5d..0000000
--- a/src/game-loop/frame-performance.test.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import { describe, expect, it } from 'vitest';
-
-import { FramePerformance } from './frame-performance';
-
-const INITIAL_FPS = 60;
-
-function createScenario() {
- const performance = new FramePerformance();
- let time = 0;
- performance.update(time);
- const advance = (fps: number): void => {
- time += 1000 / fps;
- performance.update(time);
- };
- return { performance, advance };
-}
-
-describe('FramePerformance', () => {
- it('starts at the adaptive budget target', () => {
- const { performance } = createScenario();
-
- expect(performance.smoothedFps).toBe(INITIAL_FPS);
- });
-
- it('smooths measured frame rates', () => {
- const { performance, advance } = createScenario();
-
- advance(120);
-
- expect(performance.smoothedFps).toBeGreaterThan(INITIAL_FPS);
- expect(performance.smoothedFps).toBeLessThan(120);
- });
-
- it('ignores long gaps before smoothing resumes', () => {
- const performance = new FramePerformance();
- performance.update(0);
- performance.update(2_000);
-
- expect(performance.smoothedFps).toBe(INITIAL_FPS);
-
- performance.update(2_000 + 1000 / 30);
-
- expect(performance.smoothedFps).toBeLessThan(INITIAL_FPS);
- });
-});
diff --git a/src/game-loop/frame-performance.ts b/src/game-loop/frame-performance.ts
index ac3e18f..45e19fc 100644
--- a/src/game-loop/frame-performance.ts
+++ b/src/game-loop/frame-performance.ts
@@ -2,6 +2,9 @@ import { appConfig } from '../config';
export class FramePerformance {
public smoothedFps = appConfig.simulation.budget.initialFps;
+ public measuredFps = 0;
+ public frameDeltaSeconds = 0;
+ public measuredFrameTimeMs = 0;
private previousFrameTime: DOMHighResTimeStamp | null = null;
@@ -13,14 +16,18 @@ export class FramePerformance {
}
const deltaSeconds = (time - previous) / 1000;
- if (
- deltaSeconds <= 0 ||
- deltaSeconds > appConfig.simulation.budget.frameGapResetSeconds
- ) {
+ if (deltaSeconds <= 0) {
return;
}
const fps = 1 / deltaSeconds;
+ this.frameDeltaSeconds = deltaSeconds;
+ this.measuredFrameTimeMs = deltaSeconds * 1000;
+ this.measuredFps = fps;
+ if (deltaSeconds > appConfig.simulation.budget.frameGapResetSeconds) {
+ return;
+ }
+
this.smoothedFps =
this.smoothedFps * appConfig.simulation.budget.fpsSmoothingRetain +
fps * appConfig.simulation.budget.fpsSmoothingNew;
diff --git a/src/game-loop/game-loop-intro.test.ts b/src/game-loop/game-loop-intro.test.ts
deleted file mode 100644
index 9131f18..0000000
--- a/src/game-loop/game-loop-intro.test.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { readFileSync } from 'node:fs';
-import { join } from 'node:path';
-import { describe, expect, it } from 'vitest';
-
-const gameLoopSource = readFileSync(
- join(process.cwd(), 'src/game-loop/game-loop.ts'),
- 'utf8'
-);
-
-const getStartDrawingHandlerSource = () => {
- const start = gameLoopSource.indexOf('onStartDrawing:');
- const end = gameLoopSource.indexOf('onEraseGestureEnded:', start);
-
- if (start < 0 || end < 0) {
- throw new Error('Could not find the pointer drawing intro handler');
- }
-
- return gameLoopSource.slice(start, end);
-};
-
-describe('GameLoop intro drawing policy', () => {
- it('allows drawing to start without completing the intro sequence', () => {
- const handlerSource = getStartDrawingHandlerSource();
-
- expect(handlerSource).toContain('this.introPrompt.markStartedDrawing()');
- expect(handlerSource).not.toContain('this.introPrompt.complete(');
- });
-});
diff --git a/src/game-loop/game-loop-ping-pong.test.ts b/src/game-loop/game-loop-ping-pong.test.ts
deleted file mode 100644
index 588725b..0000000
--- a/src/game-loop/game-loop-ping-pong.test.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-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 resizableTextureSource = readFileSync(
- join(process.cwd(), 'src/utils/graphics/resizable-texture.ts'),
- 'utf8'
-);
-
-const getRenderStepSource = () => {
- const start = simulationFrameSource.indexOf(
- 'const commandEncoder = this.device.createCommandEncoder();'
- );
- const swapCall = ' this.textures.swapBrushEffectMaps();';
- const end = simulationFrameSource.indexOf(swapCall, start) + swapCall.length;
-
- if (start < 0 || end < 0) {
- throw new Error('Could not find the simulation frame execution body');
- }
-
- return simulationFrameSource.slice(start, end);
-};
-
-describe('GameLoop ping-pong texture flow', () => {
- it('copies only the trail map with a GPU texture copy and swaps source/influence references after diffusion', () => {
- const renderStepSource = getRenderStepSource();
-
- expect(renderStepSource).not.toContain('copyPipeline.execute');
- expect(renderStepSource).toContain('this.textures.copyTrailMapAToB(commandEncoder);');
- expect(simulationTexturesSource).toMatch(
- /commandEncoder\.copyTextureToTexture\([\s\S]*this\.trailMapA\.getTexture\(\)[\s\S]*this\.trailMapB\.getTexture\(\)[\s\S]*width: size\[0\][\s\S]*height: size\[1\][\s\S]*\);/
- );
- expect(renderStepSource).toMatch(
- /this\.pipelines\.diffusionPipeline\.execute\([\s\S]*this\.textures\.sourceMapA\.getTextureView\(\)[\s\S]*this\.textures\.sourceMapB\.getTextureView\(\)[\s\S]*\);[\s\S]*this\.pipelines\.brushEffectDiffusionPipeline\.execute\([\s\S]*this\.textures\.influenceMapA\.getTextureView\(\)[\s\S]*this\.textures\.influenceMapB\.getTextureView\(\)[\s\S]*\);[\s\S]*this\.device\.queue\.submit\(\[commandEncoder\.finish\(\)\]\);[\s\S]*this\.textures\.swapBrushEffectMaps\(\);/
- );
- });
-
- it('keeps resizable textures usable for render, shader, and GPU copy paths', () => {
- expect(resizableTextureSource).toContain('public getTexture(): GPUTexture');
- expect(resizableTextureSource).toContain('GPUTextureUsage.COPY_SRC');
- expect(resizableTextureSource).toContain('GPUTextureUsage.COPY_DST');
- expect(resizableTextureSource).toContain('commandEncoder.copyTextureToTexture(');
- expect(simulationTexturesSource).not.toContain('private readonly copyPipeline');
- });
-
- 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('public swapBrushEffectMaps(): void');
- expect(simulationTexturesSource).toContain(
- '[this.sourceMapA, this.sourceMapB] = [this.sourceMapB, this.sourceMapA];'
- );
- expect(simulationTexturesSource).toContain(
- '[this.influenceMapA, this.influenceMapB] = [this.influenceMapB, this.influenceMapA];'
- );
- });
-});
diff --git a/src/game-loop/game-loop-resources.ts b/src/game-loop/game-loop-resources.ts
index 4acd14b..06778f6 100644
--- a/src/game-loop/game-loop-resources.ts
+++ b/src/game-loop/game-loop-resources.ts
@@ -20,7 +20,7 @@ interface FrameParameters extends RenderInputs {
deltaTime: number;
canvasSize: vec2;
activeAgentCount: number;
- devicePixelRatio: number;
+ canvasPixelRatio: number;
introProgress: number;
selectedColorIndex: number;
isErasing: boolean;
@@ -58,7 +58,7 @@ export class GameLoopResources {
this.agentGenerationPipeline = new AgentGenerationPipeline(
this.device,
- appConfig.simulation.budget.adaptiveCapMax
+ Math.min(settings.maxAgentCount, appConfig.simulation.budget.adaptiveCapInitial)
);
this.agentPipeline = new AgentPipeline(
@@ -100,7 +100,7 @@ export class GameLoopResources {
deltaTime,
canvasSize,
activeAgentCount,
- devicePixelRatio,
+ canvasPixelRatio,
introProgress,
selectedColorIndex,
channelColors,
@@ -125,7 +125,7 @@ export class GameLoopResources {
});
this.brushPipeline.setParameters({
...settings,
- devicePixelRatio,
+ pixelRatio: canvasPixelRatio,
selectedColorIndex,
});
this.diffusionPipeline.setParameters(settings);
diff --git a/src/game-loop/game-loop.ts b/src/game-loop/game-loop.ts
index 3ba7ee3..09af1e6 100644
--- a/src/game-loop/game-loop.ts
+++ b/src/game-loop/game-loop.ts
@@ -6,11 +6,13 @@ import { appConfig } from '../config';
import { activeVibe, settings } from '../settings';
import { DeltaTimeCalculator } from '../utils/delta-time-calculator';
import { AgentPopulation } from './agent-population';
+import { DevStatsOverlay } from './dev-stats-overlay';
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 { getInternalRenderSize } from './internal-render-size';
import { IntroPrompt } from './intro-prompt';
import { GardenPointerInput } from './pointer-input';
import { RenderInputCache } from './render-input-cache';
@@ -26,11 +28,11 @@ export default class GameLoop {
private readonly agentPopulation: AgentPopulation;
private readonly export4KRenderer: Export4KRenderer;
private readonly framePerformance = new FramePerformance();
+ private readonly devStatsOverlay: DevStatsOverlay | null;
private readonly toolbarContrastMonitor: ToolbarContrastMonitor;
private readonly seedValue = Math.floor(Math.random() * 0xffffffff);
private readonly seed = this.seedValue.toString(16);
private readonly resizeListener = this.resize.bind(this);
- private readonly keydownListener: (event: KeyboardEvent) => void;
private pendingIntroResizeAt: DOMHighResTimeStamp | null = null;
private hasFinished = false;
@@ -38,11 +40,14 @@ export default class GameLoop {
public constructor(
private readonly canvas: HTMLCanvasElement,
- device: GPUDevice,
+ private readonly device: GPUDevice,
private readonly deltaTimeCalculator: DeltaTimeCalculator,
ui: GardenUi
) {
this.resize();
+ this.devStatsOverlay = import.meta.env.DEV
+ ? new DevStatsOverlay(canvas.parentElement ?? document.body)
+ : null;
this.resources = new GameLoopResources(canvas, device, this.canvasSize);
this.introPrompt = new IntroPrompt(ui.prompt);
this.eraserPreview = new EraserPreview(canvas, ui.eraserPreview);
@@ -50,7 +55,7 @@ export default class GameLoop {
this.agentPopulation = new AgentPopulation(
this.resources.agentGenerationPipeline,
this.seedValue,
- () => this.devicePixelRatio
+ () => this.canvasPixelRatio
);
this.agentPopulation.initializeIntroAgents(this.canvasSize);
this.pointerInput = new GardenPointerInput({
@@ -60,7 +65,7 @@ export default class GameLoop {
eraserAgentPipeline: this.resources.eraserAgentPipeline,
eraserTexturePipeline: this.resources.eraserTexturePipeline,
eraserPreview: this.eraserPreview,
- getDevicePixelRatio: () => this.devicePixelRatio,
+ getCanvasPixelRatio: () => this.canvasPixelRatio,
getMirrorSegmentCount: () => this.mirrorSegmentCount,
onStartDrawing: () => this.introPrompt.markStartedDrawing(),
onEraseGestureEnded: () => this.agentPopulation.requestCompactionAfterErase(),
@@ -79,13 +84,8 @@ export default class GameLoop {
getSourceTextureView: () => this.resources.textures.sourceMapA.getTextureView(),
getVibeId: () => activeVibe.id,
});
- this.keydownListener = () => {
- this.audio.start(activeVibe, { userGesture: true });
- this.introPrompt.complete();
- };
window.addEventListener('resize', this.resizeListener);
- window.addEventListener('keydown', this.keydownListener, { once: true });
this.pointerInput.attach();
}
@@ -132,15 +132,16 @@ export default class GameLoop {
await this.finished.promise;
window.removeEventListener('resize', this.resizeListener);
- window.removeEventListener('keydown', this.keydownListener);
this.pointerInput.detach();
+ this.devStatsOverlay?.destroy();
this.toolbarContrastMonitor.destroy();
this.introPrompt.destroy();
+ await this.agentPopulation.waitForCompaction();
this.resources.destroy();
await this.audio.destroy();
}
- private readonly render = async (time: DOMHighResTimeStamp) => {
+ private readonly render = (time: DOMHighResTimeStamp) => {
if (this.hasFinished) {
this.finished.resolve();
return;
@@ -148,7 +149,10 @@ export default class GameLoop {
const deltaTime = this.deltaTimeCalculator.calculateDeltaTimeInSeconds(time);
this.framePerformance.update(time);
- this.agentPopulation.growBudget(deltaTime, this.framePerformance.smoothedFps);
+ this.agentPopulation.growBudget(
+ this.framePerformance.frameDeltaSeconds,
+ this.framePerformance.smoothedFps
+ );
this.introPrompt.update(this.pendingIntroResizeAt === null ? deltaTime : 0);
this.resize();
this.resizeSimulationToCanvas(time);
@@ -156,8 +160,8 @@ export default class GameLoop {
const { channelColors, backgroundColor } = this.renderInputs.get();
const introProgress = this.introPrompt.progress;
- const devicePixelRatio = this.devicePixelRatio;
- const eraserPixelSize = settings.eraserSize * devicePixelRatio;
+ const canvasPixelRatio = this.canvasPixelRatio;
+ const eraserPixelSize = settings.eraserSize * canvasPixelRatio;
const isErasing = this.pointerInput.isEraseMode;
const accentColor = channelColors[settings.selectedColorIndex] ?? channelColors[0];
this.renderInputs.updateAccentColor(accentColor);
@@ -171,7 +175,7 @@ export default class GameLoop {
deltaTime,
canvasSize: this.canvasSize,
activeAgentCount: this.agentPopulation.activeAgentCount,
- devicePixelRatio,
+ canvasPixelRatio,
introProgress,
selectedColorIndex: settings.selectedColorIndex,
isErasing,
@@ -186,20 +190,28 @@ export default class GameLoop {
);
this.pointerInput.clearSwipesIfIdle();
- await this.agentPopulation.compactAfterErase(this.pointerInput.isSwipeActive);
+ this.agentPopulation.compactAfterErase(this.pointerInput.isSwipeActive);
+ this.devStatsOverlay?.update({
+ time,
+ fps: this.framePerformance.measuredFps,
+ agentCount: this.agentPopulation.activeAgentCount,
+ frameTimeMs: this.framePerformance.measuredFrameTimeMs,
+ renderWidth: this.canvas.width,
+ renderHeight: this.canvas.height,
+ });
requestAnimationFrame(this.render);
};
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)
- );
+ const rect = this.canvas.getBoundingClientRect();
+ const { width, height } = getInternalRenderSize({
+ clientHeight: rect.height || this.canvas.clientHeight,
+ clientWidth: rect.width || this.canvas.clientWidth,
+ maxPixelScale: appConfig.simulation.maxDevicePixelRatio,
+ maxTextureDimension: this.device.limits.maxTextureDimension2D,
+ targetAreaMegapixels: settings.internalRenderAreaMegapixels,
+ });
if (this.canvas.width === width && this.canvas.height === height) {
return;
@@ -249,8 +261,11 @@ export default class GameLoop {
return vec2.fromValues(this.canvas.width, this.canvas.height);
}
- private get devicePixelRatio(): number {
- const ratio = window.devicePixelRatio;
+ private get canvasPixelRatio(): number {
+ const rect = this.canvas.getBoundingClientRect();
+ const xScale = rect.width > 0 ? this.canvas.width / rect.width : 1;
+ const yScale = rect.height > 0 ? this.canvas.height / rect.height : xScale;
+ const ratio = (xScale + yScale) / 2;
return Number.isFinite(ratio) && ratio > 0 ? ratio : 1;
}
diff --git a/src/game-loop/intro-prompt.test.ts b/src/game-loop/intro-prompt.test.ts
deleted file mode 100644
index 90650fe..0000000
--- a/src/game-loop/intro-prompt.test.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-import { describe, expect, it, vi } from 'vitest';
-
-import { appConfig } from '../config';
-import { IntroPrompt } from './intro-prompt';
-
-const createPromptElement = (): HTMLElement =>
- ({
- classList: {
- add: vi.fn(),
- contains: vi.fn(() => false),
- remove: vi.fn(),
- },
- innerHTML: '',
- replaceChildren: vi.fn(),
- }) as unknown as HTMLElement;
-
-describe('IntroPrompt', () => {
- it('advances progress from simulation delta time', () => {
- const prompt = new IntroPrompt(createPromptElement());
-
- prompt.update(appConfig.simulation.intro.durationSeconds / 2);
-
- expect(prompt.progress).toBeCloseTo(0.5);
- });
-
- it('caps progress when the intro completes', () => {
- const prompt = new IntroPrompt(createPromptElement());
-
- prompt.update(appConfig.simulation.intro.durationSeconds * 2);
- prompt.update(appConfig.simulation.intro.durationSeconds);
-
- expect(prompt.progress).toBe(1);
- });
-
- it('can rewind an active intro to leave a minimum resize paint window', () => {
- const prompt = new IntroPrompt(createPromptElement());
-
- prompt.update(appConfig.simulation.intro.durationSeconds * 0.95);
- prompt.rewindToLeaveRemainingTime(appConfig.simulation.intro.durationSeconds * 0.25);
-
- expect(prompt.progress).toBeCloseTo(0.75);
- });
-
- it('allows title regeneration only before drawing or completion', () => {
- const prompt = new IntroPrompt(createPromptElement());
-
- expect(prompt.shouldRegenerateTitleOnResize).toBe(true);
-
- prompt.markStartedDrawing();
-
- expect(prompt.shouldRegenerateTitleOnResize).toBe(false);
-
- const completedPrompt = new IntroPrompt(createPromptElement());
- completedPrompt.complete();
-
- expect(completedPrompt.shouldRegenerateTitleOnResize).toBe(false);
- });
-});
diff --git a/src/game-loop/intro-title-agents.test.ts b/src/game-loop/intro-title-agents.test.ts
deleted file mode 100644
index 03a8688..0000000
--- a/src/game-loop/intro-title-agents.test.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-
-import { AGENT_FLOAT_COUNT } from '../pipelines/agents/agent-generation/agent';
-import { createIntroTitleAgents } from './intro-title-agents';
-
-const installCanvasMask = () => {
- Object.defineProperty(globalThis, 'document', {
- configurable: true,
- value: {
- createElement: vi.fn(() => {
- const canvas = {
- width: 0,
- height: 0,
- getContext: vi.fn(() => ({
- clearRect: vi.fn(),
- fillStyle: '',
- fillText: vi.fn(),
- font: '',
- getImageData: vi.fn(() => {
- const data = new Uint8ClampedArray(canvas.width * canvas.height * 4);
- for (let i = 3; i < data.length; i += 4) {
- data[i] = 255;
- }
- return { data };
- }),
- lineJoin: '',
- lineWidth: 0,
- measureText: vi.fn((text: string) => ({
- actualBoundingBoxAscent: 10,
- actualBoundingBoxDescent: 4,
- width: text.length * 10,
- })),
- strokeStyle: '',
- strokeText: vi.fn(),
- textAlign: '',
- textBaseline: '',
- })),
- };
-
- return canvas;
- }),
- },
- });
-};
-
-describe('createIntroTitleAgents', () => {
- beforeEach(() => {
- installCanvasMask();
- });
-
- afterEach(() => {
- Reflect.deleteProperty(globalThis, 'document');
- });
-
- it('creates deterministic agents for the same seed and progress', () => {
- const first = createIntroTitleAgents({
- count: 4,
- height: 32,
- progress: 0.4,
- seed: 123,
- width: 64,
- });
- const second = createIntroTitleAgents({
- count: 4,
- height: 32,
- progress: 0.4,
- seed: 123,
- width: 64,
- });
-
- expect(Array.from(first)).toEqual(Array.from(second));
- });
-
- it('preserves targets while advancing positions for a later intro progress', () => {
- const initial = createIntroTitleAgents({
- count: 1,
- height: 32,
- progress: 0,
- seed: 456,
- width: 64,
- });
- const later = createIntroTitleAgents({
- count: 1,
- height: 32,
- progress: 0.8,
- seed: 456,
- width: 64,
- });
-
- expect(later[0]).not.toBe(initial[0]);
- expect(later[1]).not.toBe(initial[1]);
- expect(later[4]).toBe(initial[4]);
- expect(later[5]).toBe(initial[5]);
- expect(later[7]).toBe(initial[7]);
- expect(later.length).toBe(AGENT_FLOAT_COUNT);
- });
-});
diff --git a/src/game-loop/intro-title-agents.ts b/src/game-loop/intro-title-agents.ts
index b102ae1..95c3480 100644
--- a/src/game-loop/intro-title-agents.ts
+++ b/src/game-loop/intro-title-agents.ts
@@ -1,5 +1,6 @@
import { appConfig } from '../config';
-import { AGENT_FLOAT_COUNT } from '../pipelines/agents/agent-generation/agent';
+import { AGENT_FLOAT_COUNT } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
+import { clamp, easeOutQuad, mix, mixAngle, smoothstep } from '../utils/math';
interface IntroTitlePoint {
x: number;
@@ -398,19 +399,6 @@ const createSeededRandom = (seed: number): RandomSource => {
};
};
-const mix = (from: number, to: number, amount: number): number =>
- from + (to - from) * amount;
-
-const mixAngle = (from: number, to: number, amount: number): number => {
- const delta = Math.atan2(Math.sin(to - from), Math.cos(to - from));
- return from + delta * amount;
-};
-
-const smoothstep = (edge0: number, edge1: number, value: number): number => {
- const t = clamp((value - edge0) / (edge1 - edge0), 0, 1);
- return t * t * (3 - 2 * t);
-};
-
const easePathProgress = (amount: number): number => {
if (appConfig.simulation.intro.pathEasing === 'linear') {
return amount;
@@ -418,10 +406,3 @@ const easePathProgress = (amount: number): number => {
return easeOutQuad(amount);
};
-
-const easeOutQuad = (value: number): number => value * (2 - value);
-
-const clamp = (value: number, min: number, max: number): number => {
- const safeValue = Number.isFinite(value) ? value : min;
- return Math.min(max, Math.max(min, safeValue));
-};
diff --git a/src/game-loop/pointer-input.test.ts b/src/game-loop/pointer-input.test.ts
deleted file mode 100644
index a22a715..0000000
--- a/src/game-loop/pointer-input.test.ts
+++ /dev/null
@@ -1,327 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-
-type PointerListener = (event: PointerEvent) => void;
-
-const makePointerEvent = (
- type: string,
- event: Partial = {}
-): PointerEvent =>
- ({
- buttons: 1,
- clientX: 10,
- clientY: 20,
- isTrusted: true,
- pointerId: 1,
- pointerType: 'mouse',
- pressure: 0.5,
- timeStamp: 100,
- type,
- ...event,
- }) as PointerEvent;
-
-const toPoint = (point: ArrayLike): Array => Array.from(point);
-
-class FakeCanvas {
- public readonly capturedPointerIds: Array = [];
- public readonly releasedPointerIds: Array = [];
- public width = 300;
- public height = 200;
-
- private readonly listeners = new Map>();
-
- public addEventListener(
- type: string,
- listener: EventListenerOrEventListenerObject
- ): void {
- const listeners = this.listeners.get(type) ?? new Set();
- const pointerListener =
- typeof listener === 'function'
- ? listener
- : (event: Event) => listener.handleEvent(event);
- listeners.add(pointerListener as PointerListener);
- this.listeners.set(type, listeners);
- }
-
- public removeEventListener(
- type: string,
- listener: EventListenerOrEventListenerObject
- ): void {
- const listeners = this.listeners.get(type);
- if (!listeners) {
- return;
- }
-
- listeners.delete(listener as PointerListener);
- }
-
- public dispatchPointerEvent(type: string, event: Partial = {}): void {
- const pointerEvent = makePointerEvent(type, event);
-
- this.listeners.get(type)?.forEach((listener) => listener(pointerEvent));
- }
-
- public getBoundingClientRect(): DOMRect {
- return {
- bottom: this.height,
- height: this.height,
- left: 0,
- right: this.width,
- toJSON: () => ({}),
- top: 0,
- width: this.width,
- x: 0,
- y: 0,
- } as DOMRect;
- }
-
- public setPointerCapture(pointerId: number): void {
- this.capturedPointerIds.push(pointerId);
- }
-
- public releasePointerCapture(pointerId: number): void {
- this.releasedPointerIds.push(pointerId);
- }
-}
-
-const makeSwipePipeline = () => ({
- addSwipeSegment: vi.fn(),
- clearSwipes: vi.fn(),
-});
-
-const createPointerInput = async ({
- devicePixelRatio = 1,
-}: { devicePixelRatio?: number } = {}) => {
- const { GardenPointerInput } = await import('./pointer-input');
- const { settings: runtimeSettings } = await import('../settings');
- const canvas = new FakeCanvas();
- const audio = {
- beginGesture: vi.fn(),
- endGesture: vi.fn(),
- start: vi.fn(),
- stroke: vi.fn(),
- };
- const brushPipeline = makeSwipePipeline();
- const eraserAgentPipeline = makeSwipePipeline();
- const eraserTexturePipeline = makeSwipePipeline();
- const eraserPreview = {
- isPointerInsideCanvas: vi.fn(() => true),
- setEraseMode: vi.fn(),
- setPointerHoveringCanvas: vi.fn(),
- update: vi.fn(),
- };
- const onStartDrawing = vi.fn();
- const onEraseGestureEnded = vi.fn();
- const spawnStrokeAgents = vi.fn();
- const input = new GardenPointerInput({
- audio,
- brushPipeline,
- canvas: canvas as unknown as HTMLCanvasElement,
- eraserAgentPipeline,
- eraserPreview,
- eraserTexturePipeline,
- getDevicePixelRatio: () => devicePixelRatio,
- getMirrorSegmentCount: () => 1,
- onEraseGestureEnded,
- onStartDrawing,
- spawnStrokeAgents,
- } as unknown as ConstructorParameters[0]);
-
- input.attach();
-
- return {
- audio,
- brushPipeline,
- canvas,
- input,
- onStartDrawing,
- runtimeSettings,
- spawnStrokeAgents,
- };
-};
-
-describe('GardenPointerInput drawing startup', () => {
- beforeEach(() => {
- vi.resetModules();
- vi.stubGlobal('localStorage', {
- clear: vi.fn(),
- getItem: vi.fn(() => null),
- removeItem: vi.fn(),
- setItem: vi.fn(),
- });
- });
-
- afterEach(() => {
- vi.unstubAllGlobals();
- });
-
- it('allows pointer drawing immediately', async () => {
- const { audio, brushPipeline, canvas, onStartDrawing, spawnStrokeAgents } =
- await createPointerInput();
-
- canvas.dispatchPointerEvent('pointerdown', { pointerId: 7 });
- canvas.dispatchPointerEvent('pointermove', {
- clientX: 60,
- clientY: 80,
- pointerId: 7,
- timeStamp: 120,
- });
-
- expect(onStartDrawing).toHaveBeenCalledTimes(1);
- expect(audio.start).toHaveBeenCalledWith(expect.anything(), { userGesture: true });
- expect(audio.beginGesture).toHaveBeenCalledTimes(1);
- expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(2);
- expect(spawnStrokeAgents).toHaveBeenCalledTimes(2);
- expect(canvas.capturedPointerIds).toEqual([7]);
- });
-
- it('starts drawing from a fresh pointerdown', async () => {
- const { audio, brushPipeline, canvas, onStartDrawing, spawnStrokeAgents } =
- await createPointerInput();
-
- canvas.dispatchPointerEvent('pointerdown', { pointerId: 9 });
-
- expect(onStartDrawing).toHaveBeenCalledTimes(1);
- expect(audio.start).toHaveBeenCalledWith(expect.anything(), { userGesture: true });
- expect(audio.beginGesture).toHaveBeenCalledTimes(1);
- expect(audio.stroke).not.toHaveBeenCalled();
- expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(1);
- expect(spawnStrokeAgents).toHaveBeenCalledTimes(1);
- expect(canvas.capturedPointerIds).toEqual([9]);
- });
-
- it('flushes the delayed smoothed stroke tail on pointerup', async () => {
- const { brushPipeline, canvas } = await createPointerInput();
-
- canvas.dispatchPointerEvent('pointerdown', { pointerId: 9 });
- canvas.dispatchPointerEvent('pointermove', {
- clientX: 60,
- clientY: 80,
- pointerId: 9,
- timeStamp: 120,
- });
- canvas.dispatchPointerEvent('pointerup', {
- clientX: 60,
- clientY: 80,
- pointerId: 9,
- timeStamp: 140,
- });
-
- expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(3);
- expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[1][0])).toEqual([10, 20]);
- expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[1][1])).toEqual([35, 50]);
- expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[2][0])).toEqual([35, 50]);
- expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[2][1])).toEqual([60, 80]);
- });
-
- it('uses coalesced pointer samples for smoother brush segments', async () => {
- const { audio, brushPipeline, canvas, spawnStrokeAgents } =
- await createPointerInput();
-
- canvas.dispatchPointerEvent('pointerdown', { pointerId: 9 });
- audio.stroke.mockClear();
- brushPipeline.addSwipeSegment.mockClear();
- spawnStrokeAgents.mockClear();
-
- canvas.dispatchPointerEvent('pointermove', {
- clientX: 40,
- clientY: 20,
- getCoalescedEvents: () => [
- makePointerEvent('pointermove', {
- clientX: 20,
- clientY: 20,
- pointerId: 9,
- timeStamp: 110,
- }),
- makePointerEvent('pointermove', {
- clientX: 30,
- clientY: 20,
- pointerId: 9,
- timeStamp: 115,
- }),
- makePointerEvent('pointermove', {
- clientX: 40,
- clientY: 20,
- pointerId: 9,
- timeStamp: 120,
- }),
- ],
- pointerId: 9,
- timeStamp: 120,
- });
-
- expect(audio.stroke).toHaveBeenCalledTimes(3);
- expect(spawnStrokeAgents).toHaveBeenCalledTimes(3);
- expect(brushPipeline.addSwipeSegment.mock.calls.length).toBeGreaterThan(3);
- });
-
- it('passes normalized audio geometry context with stroke events', async () => {
- const { audio, canvas } = await createPointerInput();
-
- canvas.dispatchPointerEvent('pointerdown', { pointerId: 9, timeStamp: 100 });
- canvas.dispatchPointerEvent('pointermove', {
- clientX: 40,
- clientY: 50,
- pointerId: 9,
- timeStamp: 150,
- });
-
- expect(audio.stroke).toHaveBeenCalledWith(
- expect.objectContaining({
- canvasSize: [300, 200],
- elapsedSeconds: 0.05,
- from: expect.anything(),
- isErasing: false,
- to: expect.anything(),
- })
- );
- const stroke = audio.stroke.mock.calls[0][0];
- expect(toPoint(stroke.from)).toEqual([10, 20]);
- expect(toPoint(stroke.to)).toEqual([40, 50]);
- });
-
- it('keeps pointer geometry in backing pixels on high-DPR canvases', async () => {
- const { audio, brushPipeline, canvas } = await createPointerInput({
- devicePixelRatio: 2,
- });
-
- canvas.dispatchPointerEvent('pointerdown', {
- clientX: 10,
- clientY: 20,
- pointerId: 9,
- timeStamp: 100,
- });
- canvas.dispatchPointerEvent('pointermove', {
- clientX: 40,
- clientY: 50,
- pointerId: 9,
- timeStamp: 150,
- });
-
- const firstStroke = audio.stroke.mock.calls[0][0];
- expect(toPoint(firstStroke.from)).toEqual([20, 40]);
- expect(toPoint(firstStroke.to)).toEqual([80, 100]);
- expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[0][0])).toEqual([20, 40]);
- });
-
- it('caps curve tessellation with the brush curve resolution setting', async () => {
- const { brushPipeline, canvas, runtimeSettings } = await createPointerInput();
- runtimeSettings.brushCurveResolution = 2;
- runtimeSettings.brushSize = 1;
-
- canvas.dispatchPointerEvent('pointerdown', { pointerId: 9 });
- canvas.dispatchPointerEvent('pointermove', {
- clientX: 10,
- clientY: 60,
- pointerId: 9,
- timeStamp: 120,
- });
- canvas.dispatchPointerEvent('pointermove', {
- clientX: 60,
- clientY: 60,
- pointerId: 9,
- timeStamp: 140,
- });
-
- expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(4);
- });
-});
diff --git a/src/game-loop/pointer-input.ts b/src/game-loop/pointer-input.ts
index fa80996..c3441e9 100644
--- a/src/game-loop/pointer-input.ts
+++ b/src/game-loop/pointer-input.ts
@@ -4,7 +4,7 @@ import { GardenAudio } from '../audio/garden-audio';
import { appConfig } from '../config';
import {
BrushPipeline,
- getSafeDevicePixelRatio,
+ getSafePixelRatio,
} from '../pipelines/brush/brush-pipeline';
import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline';
import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline';
@@ -19,7 +19,7 @@ interface GardenPointerInputOptions {
eraserAgentPipeline: EraserAgentPipeline;
eraserTexturePipeline: EraserTexturePipeline;
eraserPreview: EraserPreview;
- getDevicePixelRatio: () => number;
+ getCanvasPixelRatio: () => number;
getMirrorSegmentCount: () => number;
onStartDrawing: () => void;
onEraseGestureEnded: () => void;
@@ -106,9 +106,7 @@ export class GardenPointerInput {
return;
}
- if (event.pointerType !== 'touch') {
- this.options.audio.start(activeVibe, { userGesture: true });
- }
+ this.options.audio.start(activeVibe, { userGesture: true });
this.options.audio.beginGesture();
this.options.onStartDrawing();
this.activePointerId = event.pointerId;
@@ -204,12 +202,11 @@ export class GardenPointerInput {
private getCanvasPointerPosition(event: PointerEvent): vec2 {
const rect = this.canvas.getBoundingClientRect();
- const devicePixelRatio = getSafeDevicePixelRatio(
- this.options.getDevicePixelRatio()
- );
+ const xScale = getSafePixelRatio(this.canvas.width / rect.width);
+ const yScale = getSafePixelRatio(this.canvas.height / rect.height);
return vec2.fromValues(
- (event.clientX - rect.left) * devicePixelRatio,
- (event.clientY - rect.top) * devicePixelRatio
+ (event.clientX - rect.left) * xScale,
+ (event.clientY - rect.top) * yScale
);
}
@@ -219,7 +216,7 @@ export class GardenPointerInput {
if (
previousSample !== undefined &&
vec2.squaredDistance(previousSample, position) <=
- getBrushSmoothingDistanceSquared(this.options.getDevicePixelRatio())
+ getBrushSmoothingDistanceSquared(this.options.getCanvasPixelRatio())
) {
return;
}
@@ -253,13 +250,15 @@ export class GardenPointerInput {
private addQuadraticBrushSegments(start: vec2, control: vec2, end: vec2): void {
const curveLength = vec2.distance(start, control) + vec2.distance(control, end);
- const devicePixelRatio = getSafeDevicePixelRatio(this.options.getDevicePixelRatio());
+ const canvasPixelRatio = getSafePixelRatio(
+ this.options.getCanvasPixelRatio()
+ );
const brushRadius = Math.max(
- settings.brushCurveMinBrushRadius * devicePixelRatio,
- (settings.brushSize * devicePixelRatio) / 2
+ settings.brushCurveMinBrushRadius * canvasPixelRatio,
+ (settings.brushSize * canvasPixelRatio) / 2
);
const segmentSpacing = Math.max(
- settings.brushCurveMinSegmentSpacing * devicePixelRatio,
+ settings.brushCurveMinSegmentSpacing * canvasPixelRatio,
brushRadius * settings.brushCurveSegmentBrushRadiusRatio
);
const mirrorSegmentCount = Math.max(1, this.options.getMirrorSegmentCount());
@@ -299,7 +298,7 @@ export class GardenPointerInput {
if (
this.lastSmoothedBrushPosition !== null &&
vec2.squaredDistance(this.lastSmoothedBrushPosition, finalSample) >
- getBrushSmoothingDistanceSquared(this.options.getDevicePixelRatio())
+ getBrushSmoothingDistanceSquared(this.options.getCanvasPixelRatio())
) {
this.addMirroredBrushSegment(this.lastSmoothedBrushPosition, finalSample);
}
@@ -381,11 +380,11 @@ const getBrushCurveResolution = (): number => {
return Math.max(1, Math.floor(resolution));
};
-const getBrushSmoothingDistanceSquared = (devicePixelRatio?: number): number => {
+const getBrushSmoothingDistanceSquared = (pixelRatio?: number): number => {
const distance = Number.isFinite(settings.brushSmoothingMinSampleDistance)
? settings.brushSmoothingMinSampleDistance
: appConfig.defaultSettings.brushSmoothingMinSampleDistance;
- return Math.max(0, distance * getSafeDevicePixelRatio(devicePixelRatio)) ** 2;
+ return Math.max(0, distance * getSafePixelRatio(pixelRatio)) ** 2;
};
const isSamePointerSample = (left: PointerEvent, right: PointerEvent): boolean =>
diff --git a/src/game-loop/render-input-cache.ts b/src/game-loop/render-input-cache.ts
index 6e72c16..53234b3 100644
--- a/src/game-loop/render-input-cache.ts
+++ b/src/game-loop/render-input-cache.ts
@@ -1,5 +1,6 @@
import { activeVibe } from '../settings';
-import { hexToRgb, type VibeId } from '../vibes';
+import { hexToRgb } from '../utils/hex-to-rgb';
+import { type VibeId } from '../vibes';
import { RenderInputs } from './game-loop-types';
export class RenderInputCache {
diff --git a/src/game-loop/toolbar-contrast-monitor.test.ts b/src/game-loop/toolbar-contrast-monitor.test.ts
deleted file mode 100644
index 142326a..0000000
--- a/src/game-loop/toolbar-contrast-monitor.test.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-import { describe, expect, it } from 'vitest';
-
-import { getToolbarContrastMetrics } from './toolbar-contrast-monitor';
-
-const makePixels = (
- samples: ReadonlyArray
-): Uint8Array => {
- const pixels = new Uint8Array(samples.length * 4);
- samples.forEach(([red, green, blue], index) => {
- const offset = index * 4;
- pixels[offset] = red;
- pixels[offset + 1] = green;
- pixels[offset + 2] = blue;
- pixels[offset + 3] = 255;
- });
- return pixels;
-};
-
-describe('toolbar contrast monitoring', () => {
- it('leaves the toolbar transparent over dark canvas samples', () => {
- const metrics = getToolbarContrastMetrics(
- makePixels(Array.from({ length: 91 }, () => [8, 12, 18])),
- 91,
- false
- );
-
- expect(metrics.backgroundOpacity).toBe(0);
- expect(metrics.lowContrastRatio).toBe(0);
- });
-
- it('ramps background opacity as canvas samples get lighter', () => {
- const dimMetrics = getToolbarContrastMetrics(
- makePixels(Array.from({ length: 91 }, () => [130, 130, 130])),
- 91,
- false
- );
- const brightMetrics = getToolbarContrastMetrics(
- makePixels(Array.from({ length: 91 }, () => [210, 210, 210])),
- 91,
- false
- );
-
- expect(dimMetrics.backgroundOpacity).toBeGreaterThan(0);
- expect(brightMetrics.backgroundOpacity).toBeGreaterThan(dimMetrics.backgroundOpacity);
- expect(brightMetrics.backgroundOpacity).toBeLessThanOrEqual(0.82);
- });
-
- it('raises background opacity when enough samples have poor contrast with white controls', () => {
- const darkSamples = Array.from({ length: 82 }, () => [8, 12, 18] as const);
- const brightSamples = Array.from({ length: 9 }, () => [245, 240, 218] as const);
- const metrics = getToolbarContrastMetrics(
- makePixels([...darkSamples, ...brightSamples]),
- 91,
- false
- );
-
- expect(metrics.lowContrastRatio).toBeGreaterThanOrEqual(0.08);
- expect(metrics.backgroundOpacity).toBeGreaterThan(0);
- });
-
- it('reads bgra canvas samples in the correct channel order', () => {
- const bgraPixels = new Uint8Array([0, 0, 255, 255]);
- const metrics = getToolbarContrastMetrics(bgraPixels, 1, true);
-
- expect(metrics.averageLuminance).toBeCloseTo(0.2126);
- });
-});
diff --git a/src/game-loop/toolbar-contrast-monitor.ts b/src/game-loop/toolbar-contrast-monitor.ts
index 5b90562..42ef8f1 100644
--- a/src/game-loop/toolbar-contrast-monitor.ts
+++ b/src/game-loop/toolbar-contrast-monitor.ts
@@ -1,4 +1,5 @@
import { appConfig } from '../config';
+import { clamp01 } from '../utils/math';
import type { CanvasReadbackRequest } from './game-loop-types';
interface CanvasSamplePoint {
@@ -16,8 +17,6 @@ interface ToolbarContrastMetrics {
const TOOLBAR_BACKGROUND_OPACITY_PROPERTY = '--toolbar-background-opacity';
const TOOLBAR_BACKGROUND_STRENGTH_PROPERTY = '--toolbar-background-strength';
-const clamp01 = (value: number): number => Math.min(1, Math.max(0, value));
-
const getLinearChannel = (channel: number): number => {
const normalized = channel / 255;
return normalized <= appConfig.toolbar.contrast.linearChannelBreakpoint
diff --git a/src/index.dom-contract.test.ts b/src/index.dom-contract.test.ts
deleted file mode 100644
index a1fd0a6..0000000
--- a/src/index.dom-contract.test.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-import { readFileSync } from 'node:fs';
-import { join } from 'node:path';
-import { describe, expect, it } from 'vitest';
-
-const projectRoot = process.cwd();
-const indexSource = readFileSync(join(projectRoot, 'src/index.ts'), 'utf8');
-const html = readFileSync(join(projectRoot, 'index.html'), 'utf8');
-
-const escapeRegex = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
-
-const hasClass = (className: string, tagName?: string) => {
- const tagPattern = tagName ? `<${tagName}\\b[^>]*` : '<[a-z][^>]*';
- return new RegExp(
- `${tagPattern}class="[^"]*\\b${escapeRegex(className)}\\b[^"]*"`,
- 'i'
- ).test(html);
-};
-
-const hasId = (id: string) => new RegExp(`\\bid="${escapeRegex(id)}"`, 'i').test(html);
-
-const hasTag = (tagName: string) =>
- new RegExp(`<${escapeRegex(tagName)}(?:\\s|>|/)`, 'i').test(html);
-
-const selectorExists = (selector: string) => {
- const idSelector = /^#(?[\w-]+)$/.exec(selector);
- if (idSelector?.groups?.id) {
- return hasId(idSelector.groups.id);
- }
-
- const classSelector = /^\.([\w-]+)$/.exec(selector);
- if (classSelector?.[1]) {
- return hasClass(classSelector[1]);
- }
-
- const tagClassSelector = /^(?[a-z]+)\.(?[\w-]+)$/.exec(selector);
- if (tagClassSelector?.groups) {
- return hasClass(tagClassSelector.groups.className, tagClassSelector.groups.tagName);
- }
-
- if (/^[a-z]+$/.test(selector)) {
- return hasTag(selector);
- }
-
- throw new Error(`Unsupported selector contract syntax: ${selector}`);
-};
-
-describe('index DOM selector contract', () => {
- it('keeps every boot-time required selector target present in index.html', () => {
- const selectors = Array.from(
- indexSource.matchAll(/queryRequiredElements?\(\s*'([^']+)'\s*,/g),
- (match) => match[1]
- );
-
- expect(selectors.length).toBeGreaterThan(0);
- expect(selectors.filter((selector) => !selectorExists(selector))).toEqual([]);
- });
-
- it('keeps the three color swatches expected by the palette UI', () => {
- const colorSwatchCount = Array.from(
- html.matchAll(/class="[^"]*\bcolor-swatch\b[^"]*"/g)
- ).length;
-
- expect(colorSwatchCount).toBe(3);
- });
-});
diff --git a/src/index.ts b/src/index.ts
index b624a08..f88ae26 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -2,16 +2,22 @@ import GameLoop from './game-loop/game-loop';
import './index.scss';
-import { initAnalytics, trackExport, trackVibeChange } from './analytics';
+import { initAnalytics, trackExport, trackStart, trackVibeChange } from './analytics';
+import {
+ APP_STORAGE_KEYS,
+ DEFAULT_AUDIO_VOLUME,
+ DISABLED_FLAG_VALUE,
+ ENABLED_FLAG_VALUE,
+ UNIT_INTERVAL_INPUT_MAX,
+ UNIT_INTERVAL_INPUT_MIN,
+} from './app-constants';
import { preloadPianoSamples } from './audio/piano-samples';
-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 { activeVibe, applyVibeSettings, resetSettings, settings } from './settings';
import { readBrowserStorage, writeBrowserStorage } from './utils/browser-storage';
-import { clamp } from './utils/clamp';
import { DeltaTimeCalculator } from './utils/delta-time-calculator';
import { queryRequiredElement, queryRequiredElements } from './utils/dom';
import {
@@ -21,56 +27,165 @@ import {
Severity,
} from './utils/error-handler';
import { initializeGpu } from './utils/graphics/initialize-gpu';
+import { clamp01 } from './utils/math';
import { VIBE_PRESETS } from './vibes';
+const AUDIO_VOLUME_STEP = 0.01;
+
+const ERASER_CONTROL_SCALE_MAX = 1.33;
+const ERASER_CONTROL_SCALE_MIN = 0.75;
+const ERASER_SIZE_DEFAULT = 96;
+const ERASER_SIZE_MAX = 240;
+const ERASER_SIZE_MIN = 24;
+const ERASER_SIZE_STEP = 1;
+
+const MIRROR_SEGMENT_DEFAULT = 1;
+const MIRROR_SEGMENT_MAX = 12;
+const MIRROR_SEGMENT_MIN = 1;
+const MIRROR_SEGMENT_OFF_LABEL = 'Mirror off';
+const MIRROR_SEGMENT_STEP = 1;
+const MIRROR_SEGMENT_LABEL_SUFFIX = 'slices';
+
+const ELEMENT_TAGS = {
+ div: 'div',
+ pre: 'pre',
+} as const;
+
+const ARIA_ATTRIBUTES = {
+ label: 'aria-label',
+ live: 'aria-live',
+ pressed: 'aria-pressed',
+ role: 'role',
+ valueNow: 'aria-valuenow',
+ valueText: 'aria-valuetext',
+} as const;
+
+const ARIA_LIVE_VALUES = {
+ assertive: 'assertive',
+ polite: 'polite',
+} as const;
+
+const ARIA_ROLES = {
+ alert: 'alert',
+ status: 'status',
+} as const;
+
+const CSS_CLASSES = {
+ active: 'active',
+ errorsContainer: 'errors-container',
+ isLoading: 'is-loading',
+ muted: 'muted',
+ preDrawing: 'pre-drawing',
+} as const;
+
+const CSS_VARIABLES = {
+ eraserControlScale: '--eraser-control-scale',
+ eraserProgress: '--eraser-progress',
+ gardenBackground: '--garden-background',
+ loadingProgress: '--loading-progress',
+ mirrorAngle: '--mirror-angle',
+ mirrorProgress: '--mirror-progress',
+ volumeProgress: '--volume-progress',
+} as const;
+
+const DOM_EVENTS = {
+ click: 'click',
+ focus: 'focus',
+ input: 'input',
+ keydown: 'keydown',
+ pointerDown: 'pointerdown',
+ pointerUp: 'pointerup',
+ touchEnd: 'touchend',
+ touchStart: 'touchstart',
+} as const;
+
+const APP_SELECTORS = {
+ aside: 'aside',
+ canvas: 'canvas',
+ eraserPreview: '.eraser-preview',
+ eraserSizeControl: '.eraser-size-control',
+ eraserSizeSlider: '.eraser-size-slider',
+ errorContainer: '.errors-container',
+ export4k: '.export-4k',
+ exportStatus: '.export-status',
+ infoButton: 'button.info',
+ infoElement: '.info-page',
+ loadingBar: '.loading-bar',
+ loadingProgress: '.loading-progress',
+ loadingStatus: '.loading-status',
+ maximizeFullScreenButton: 'button.maximize-full-screen',
+ minimizeFullScreenButton: 'button.minimize-full-screen',
+ mirrorSegmentControl: '.mirror-segment-control',
+ mirrorSegmentSlider: '.mirror-segment-slider',
+ nextVibe: '.next-vibe',
+ previousVibe: '.previous-vibe',
+ prompt: '.garden-prompt',
+ restartButton: 'button.restart',
+ settingsButton: 'button.settings',
+ soundButton: 'button.sound',
+ splash: '.splash',
+ startButton: '.start-button',
+ swatches: '.color-swatch',
+ toolbarRow: '.toolbar-row',
+ volumeControl: '.volume-control',
+ volumeSlider: '.volume-slider',
+} as const;
+
+const AUDIO_LABELS = {
+ mutedPrefix: 'Muted',
+ mute: 'Mute audio',
+ unmute: 'Unmute audio',
+ volumeSuffix: 'volume',
+} as const;
+
+const LOADING_MESSAGES = {
+ fontsError: 'Could not load fonts.',
+ pianoSamplesError: 'Could not preload piano samples.',
+ ready: 'Ready',
+} as const;
+
+const VIBE_CHANGE_SOURCES = {
+ nextButton: 'next-button',
+ previousButton: 'previous-button',
+ settings: 'settings',
+} as const;
+
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 safeValue = Number.isFinite(value) ? value : ERASER_SIZE_DEFAULT;
+ return Math.min(ERASER_SIZE_MAX, Math.max(ERASER_SIZE_MIN, Math.round(safeValue)));
};
const getEraserSizeRatio = (size: number): number =>
- (size - appConfig.toolbar.eraser.min) /
- (appConfig.toolbar.eraser.max - appConfig.toolbar.eraser.min);
+ (size - ERASER_SIZE_MIN) / (ERASER_SIZE_MAX - ERASER_SIZE_MIN);
const clampMirrorSegmentCount = (value: number): number => {
- const safeValue = Number.isFinite(value) ? value : appConfig.toolbar.mirror.default;
+ const safeValue = Number.isFinite(value) ? value : MIRROR_SEGMENT_DEFAULT;
return Math.min(
- appConfig.toolbar.mirror.max,
- Math.max(appConfig.toolbar.mirror.min, Math.round(safeValue))
+ MIRROR_SEGMENT_MAX,
+ Math.max(MIRROR_SEGMENT_MIN, Math.round(safeValue))
);
};
const getMirrorSegmentRatio = (count: number): number =>
- (count - appConfig.toolbar.mirror.min) /
- (appConfig.toolbar.mirror.max - appConfig.toolbar.mirror.min);
-
-const mirrorSegmentNames: Readonly> =
- appConfig.toolbar.mirror.names;
+ (count - MIRROR_SEGMENT_MIN) / (MIRROR_SEGMENT_MAX - MIRROR_SEGMENT_MIN);
const formatMirrorSegmentCount = (count: number): string =>
- count === appConfig.toolbar.mirror.default
- ? appConfig.toolbar.mirror.offLabel
- : `${count} ${mirrorSegmentNames[count] ?? appConfig.toolbar.mirror.fallbackSegmentName}`;
+ count === MIRROR_SEGMENT_DEFAULT
+ ? MIRROR_SEGMENT_OFF_LABEL
+ : `${count} ${MIRROR_SEGMENT_LABEL_SUFFIX}`;
const clampAudioVolume = (value: number): number => {
- const safeValue = Number.isFinite(value) ? value : appConfig.toolbar.volume.default;
- return clamp(safeValue, appConfig.toolbar.volume.min, appConfig.toolbar.volume.max);
+ const safeValue = Number.isFinite(value) ? value : DEFAULT_AUDIO_VOLUME;
+ return clamp01(safeValue);
};
-const getAudioVolumeRatio = (volume: number): number =>
- (volume - appConfig.toolbar.volume.min) /
- (appConfig.toolbar.volume.max - appConfig.toolbar.volume.min);
-
const getAudioVolumePercent = (volume: number): number =>
- Math.round(getAudioVolumeRatio(volume) * 100);
+ Math.round(clampAudioVolume(volume) * 100);
const readInitialAudioVolume = (): number => {
- const storedVolume = readBrowserStorage(appConfig.storage.audioVolumeKey);
+ const storedVolume = readBrowserStorage(APP_STORAGE_KEYS.audioVolume);
return storedVolume === null
- ? appConfig.toolbar.volume.default
+ ? DEFAULT_AUDIO_VOLUME
: clampAudioVolume(Number(storedVolume));
};
@@ -82,13 +197,18 @@ type RuntimeUiError = Parameters<
>[0];
const renderRuntimeMessage = (container: HTMLElement, error: RuntimeUiError) => {
- const message = document.createElement('pre');
+ const message = document.createElement(ELEMENT_TAGS.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'
+ ARIA_ATTRIBUTES.role,
+ error.severity === Severity.ERROR ? ARIA_ROLES.alert : ARIA_ROLES.status
+ );
+ message.setAttribute(
+ ARIA_ATTRIBUTES.live,
+ error.severity === Severity.ERROR
+ ? ARIA_LIVE_VALUES.assertive
+ : ARIA_LIVE_VALUES.polite
);
container.append(message);
@@ -105,54 +225,69 @@ const getRuntimeUiError = (exception: unknown): RuntimeUiError => ({
});
const renderStartupException = (exception: unknown) => {
- const existingContainer = document.querySelector('.errors-container');
+ const existingContainer = document.querySelector(APP_SELECTORS.errorContainer);
const container =
existingContainer instanceof HTMLElement
? existingContainer
- : document.createElement('div');
+ : document.createElement(ELEMENT_TAGS.div);
if (!(existingContainer instanceof HTMLElement)) {
- container.className = 'errors-container';
+ container.className = CSS_CLASSES.errorsContainer;
document.body.append(container);
}
- container.setAttribute('aria-live', 'assertive');
+ container.setAttribute(ARIA_ATTRIBUTES.live, ARIA_LIVE_VALUES.assertive);
renderRuntimeMessage(container, getRuntimeUiError(exception));
};
const queryAppElements = () => ({
- aside: queryRequiredElement('aside', HTMLElement),
- toolbarRow: queryRequiredElement('.toolbar-row', HTMLElement),
- infoButton: queryRequiredElement('button.info', HTMLButtonElement),
- infoElement: queryRequiredElement('.info-page', HTMLElement),
+ aside: queryRequiredElement(APP_SELECTORS.aside, HTMLElement),
+ toolbarRow: queryRequiredElement(APP_SELECTORS.toolbarRow, HTMLElement),
+ infoButton: queryRequiredElement(APP_SELECTORS.infoButton, HTMLButtonElement),
+ infoElement: queryRequiredElement(APP_SELECTORS.infoElement, HTMLElement),
minimizeFullScreenButton: queryRequiredElement(
- 'button.minimize-full-screen',
+ APP_SELECTORS.minimizeFullScreenButton,
HTMLButtonElement
),
maximizeFullScreenButton: queryRequiredElement(
- 'button.maximize-full-screen',
+ APP_SELECTORS.maximizeFullScreenButton,
HTMLButtonElement
),
- settingsButton: queryRequiredElement('button.settings', HTMLButtonElement),
- soundButton: queryRequiredElement('button.sound', HTMLButtonElement),
- volumeControl: queryRequiredElement('.volume-control', HTMLLabelElement),
- volumeSlider: queryRequiredElement('.volume-slider', HTMLInputElement),
- restartButton: queryRequiredElement('button.restart', HTMLButtonElement),
- canvas: queryRequiredElement('canvas', HTMLCanvasElement),
- eraserPreview: queryRequiredElement('.eraser-preview', HTMLDivElement),
- errorContainer: queryRequiredElement('.errors-container', HTMLElement),
- previousVibe: queryRequiredElement('.previous-vibe', HTMLButtonElement),
- nextVibe: queryRequiredElement('.next-vibe', HTMLButtonElement),
- swatches: queryRequiredElements('.color-swatch', HTMLButtonElement),
- eraserSizeControl: queryRequiredElement('.eraser-size-control', HTMLLabelElement),
- eraserSizeSlider: queryRequiredElement('.eraser-size-slider', HTMLInputElement),
- mirrorSegmentControl: queryRequiredElement('.mirror-segment-control', HTMLLabelElement),
- mirrorSegmentSlider: queryRequiredElement('.mirror-segment-slider', HTMLInputElement),
- export4k: queryRequiredElement('.export-4k', HTMLButtonElement),
- exportStatus: queryRequiredElement('.export-status', HTMLSpanElement),
- prompt: queryRequiredElement('.garden-prompt', HTMLDivElement),
- loadingStatus: queryRequiredElement('.loading-status', HTMLDivElement),
- loadingProgress: queryRequiredElement('.loading-progress', HTMLDivElement),
+ settingsButton: queryRequiredElement(APP_SELECTORS.settingsButton, HTMLButtonElement),
+ soundButton: queryRequiredElement(APP_SELECTORS.soundButton, HTMLButtonElement),
+ volumeControl: queryRequiredElement(APP_SELECTORS.volumeControl, HTMLLabelElement),
+ volumeSlider: queryRequiredElement(APP_SELECTORS.volumeSlider, HTMLInputElement),
+ restartButton: queryRequiredElement(APP_SELECTORS.restartButton, HTMLButtonElement),
+ canvas: queryRequiredElement(APP_SELECTORS.canvas, HTMLCanvasElement),
+ eraserPreview: queryRequiredElement(APP_SELECTORS.eraserPreview, HTMLDivElement),
+ errorContainer: queryRequiredElement(APP_SELECTORS.errorContainer, HTMLElement),
+ previousVibe: queryRequiredElement(APP_SELECTORS.previousVibe, HTMLButtonElement),
+ nextVibe: queryRequiredElement(APP_SELECTORS.nextVibe, HTMLButtonElement),
+ swatches: queryRequiredElements(APP_SELECTORS.swatches, HTMLButtonElement),
+ eraserSizeControl: queryRequiredElement(
+ APP_SELECTORS.eraserSizeControl,
+ HTMLLabelElement
+ ),
+ eraserSizeSlider: queryRequiredElement(
+ APP_SELECTORS.eraserSizeSlider,
+ HTMLInputElement
+ ),
+ mirrorSegmentControl: queryRequiredElement(
+ APP_SELECTORS.mirrorSegmentControl,
+ HTMLLabelElement
+ ),
+ mirrorSegmentSlider: queryRequiredElement(
+ APP_SELECTORS.mirrorSegmentSlider,
+ HTMLInputElement
+ ),
+ export4k: queryRequiredElement(APP_SELECTORS.export4k, HTMLButtonElement),
+ exportStatus: queryRequiredElement(APP_SELECTORS.exportStatus, HTMLSpanElement),
+ prompt: queryRequiredElement(APP_SELECTORS.prompt, HTMLDivElement),
+ loadingStatus: queryRequiredElement(APP_SELECTORS.loadingStatus, HTMLDivElement),
+ loadingProgress: queryRequiredElement(APP_SELECTORS.loadingProgress, HTMLDivElement),
+ splash: queryRequiredElement(APP_SELECTORS.splash, HTMLDivElement),
+ loadingBar: queryRequiredElement(APP_SELECTORS.loadingBar, HTMLDivElement),
+ startButton: queryRequiredElement(APP_SELECTORS.startButton, HTMLButtonElement),
});
type AppElements = ReturnType;
@@ -160,52 +295,62 @@ type AppElements = ReturnType;
let elements: AppElements;
const setLoadingStage = (label: string, ratio: number) => {
- const percent = Math.round(Math.max(0, Math.min(1, ratio)) * 100);
+ const percent = Math.round(clamp01(ratio) * 100);
elements.loadingStatus.textContent = label;
- elements.loadingProgress.style.setProperty('--loading-progress', `${percent}%`);
- elements.loadingProgress.setAttribute('aria-valuenow', String(percent));
+ elements.loadingProgress.style.setProperty(
+ CSS_VARIABLES.loadingProgress,
+ `${percent}%`
+ );
+ elements.loadingProgress.setAttribute(ARIA_ATTRIBUTES.valueNow, String(percent));
};
let audioVolume = readInitialAudioVolume();
let isAudioMuted =
- readBrowserStorage(appConfig.storage.audioMutedKey) === '1' ||
- audioVolume <= appConfig.toolbar.volume.min;
+ readBrowserStorage(APP_STORAGE_KEYS.audioMuted) === ENABLED_FLAG_VALUE ||
+ audioVolume <= 0;
let isEraserActive = false;
const persistAudioUiState = () => {
- writeBrowserStorage(appConfig.storage.audioMutedKey, isAudioMuted ? '1' : '0');
writeBrowserStorage(
- appConfig.storage.audioVolumeKey,
- formatStoredAudioVolume(audioVolume)
+ APP_STORAGE_KEYS.audioMuted,
+ isAudioMuted ? ENABLED_FLAG_VALUE : DISABLED_FLAG_VALUE
);
+ writeBrowserStorage(APP_STORAGE_KEYS.audioVolume, formatStoredAudioVolume(audioVolume));
};
const renderAudioUi = (game: GameLoop | null) => {
audioVolume = clampAudioVolume(audioVolume);
- const isEffectivelyMuted = isAudioMuted || audioVolume <= appConfig.toolbar.volume.min;
+ const isEffectivelyMuted = isAudioMuted || audioVolume <= 0;
const volumePercent = getAudioVolumePercent(audioVolume);
- elements.soundButton.classList.toggle('muted', isEffectivelyMuted);
- elements.soundButton.setAttribute('aria-pressed', String(isEffectivelyMuted));
+ elements.soundButton.classList.toggle(CSS_CLASSES.muted, isEffectivelyMuted);
+ elements.soundButton.setAttribute(ARIA_ATTRIBUTES.pressed, String(isEffectivelyMuted));
elements.soundButton.setAttribute(
- 'aria-label',
- isEffectivelyMuted ? 'Unmute audio' : 'Mute audio'
+ ARIA_ATTRIBUTES.label,
+ isEffectivelyMuted ? AUDIO_LABELS.unmute : AUDIO_LABELS.mute
);
- elements.soundButton.title = isEffectivelyMuted ? 'Unmute audio' : 'Mute audio';
+ elements.soundButton.title = isEffectivelyMuted
+ ? AUDIO_LABELS.unmute
+ : AUDIO_LABELS.mute;
- elements.volumeSlider.min = appConfig.toolbar.volume.min.toString();
- elements.volumeSlider.max = appConfig.toolbar.volume.max.toString();
- elements.volumeSlider.step = appConfig.toolbar.volume.step.toString();
+ elements.volumeSlider.min = UNIT_INTERVAL_INPUT_MIN;
+ elements.volumeSlider.max = UNIT_INTERVAL_INPUT_MAX;
+ elements.volumeSlider.step = AUDIO_VOLUME_STEP.toString();
elements.volumeSlider.value = formatStoredAudioVolume(audioVolume);
elements.volumeSlider.setAttribute(
- 'aria-valuetext',
- isEffectivelyMuted ? `Muted, ${volumePercent}%` : `${volumePercent}%`
+ ARIA_ATTRIBUTES.valueText,
+ isEffectivelyMuted
+ ? `${AUDIO_LABELS.mutedPrefix}, ${volumePercent}%`
+ : `${volumePercent}%`
);
- elements.volumeControl.classList.toggle('muted', isEffectivelyMuted);
+ elements.volumeControl.classList.toggle(CSS_CLASSES.muted, isEffectivelyMuted);
elements.volumeControl.title = isEffectivelyMuted
- ? `Muted, ${volumePercent}% volume`
- : `${volumePercent}% volume`;
- elements.volumeControl.style.setProperty('--volume-progress', `${volumePercent}%`);
+ ? `${AUDIO_LABELS.mutedPrefix}, ${volumePercent}% ${AUDIO_LABELS.volumeSuffix}`
+ : `${volumePercent}% ${AUDIO_LABELS.volumeSuffix}`;
+ elements.volumeControl.style.setProperty(
+ CSS_VARIABLES.volumeProgress,
+ `${volumePercent}%`
+ );
game?.setAudioVolume(audioVolume);
game?.setAudioMuted(isEffectivelyMuted);
@@ -215,14 +360,14 @@ const renderPaletteUi = (game: GameLoop | null) => {
elements.swatches.forEach((swatch, index) => {
swatch.style.backgroundColor = activeVibe.colors[index];
swatch.classList.toggle(
- 'active',
+ CSS_CLASSES.active,
settings.selectedColorIndex === index && !isEraserActive
);
});
- elements.eraserSizeControl.classList.toggle('active', isEraserActive);
+ elements.eraserSizeControl.classList.toggle(CSS_CLASSES.active, isEraserActive);
game?.setEraseMode(isEraserActive);
document.documentElement.style.setProperty(
- '--garden-background',
+ CSS_VARIABLES.gardenBackground,
activeVibe.backgroundColor
);
};
@@ -233,21 +378,22 @@ const renderEraserSizeUi = (game: GameLoop | null) => {
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.min = ERASER_SIZE_MIN.toString();
+ elements.eraserSizeSlider.max = ERASER_SIZE_MAX.toString();
+ elements.eraserSizeSlider.step = ERASER_SIZE_STEP.toString();
elements.eraserSizeSlider.value = size.toString();
- elements.eraserSizeSlider.setAttribute('aria-valuetext', `${size}px`);
+ elements.eraserSizeSlider.setAttribute(ARIA_ATTRIBUTES.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}%`);
+ ERASER_CONTROL_SCALE_MIN +
+ (ERASER_CONTROL_SCALE_MAX - ERASER_CONTROL_SCALE_MIN) * ratio;
elements.eraserSizeControl.style.setProperty(
- '--eraser-control-scale',
+ CSS_VARIABLES.eraserProgress,
+ `${ratio * 100}%`
+ );
+ elements.eraserSizeControl.style.setProperty(
+ CSS_VARIABLES.eraserControlScale,
scale.toFixed(3)
);
game?.updateEraserPreview();
@@ -259,19 +405,22 @@ const renderMirrorSegmentUi = () => {
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.min = MIRROR_SEGMENT_MIN.toString();
+ elements.mirrorSegmentSlider.max = MIRROR_SEGMENT_MAX.toString();
+ elements.mirrorSegmentSlider.step = MIRROR_SEGMENT_STEP.toString();
elements.mirrorSegmentSlider.value = count.toString();
const label = formatMirrorSegmentCount(count);
const ratio = getMirrorSegmentRatio(count);
- elements.mirrorSegmentSlider.setAttribute('aria-valuetext', label);
+ elements.mirrorSegmentSlider.setAttribute(ARIA_ATTRIBUTES.valueText, label);
elements.mirrorSegmentControl.title = label;
- elements.mirrorSegmentControl.classList.toggle('active', count > 1);
- elements.mirrorSegmentControl.style.setProperty('--mirror-progress', `${ratio * 100}%`);
+ elements.mirrorSegmentControl.classList.toggle(CSS_CLASSES.active, count > 1);
elements.mirrorSegmentControl.style.setProperty(
- '--mirror-angle',
+ CSS_VARIABLES.mirrorProgress,
+ `${ratio * 100}%`
+ );
+ elements.mirrorSegmentControl.style.setProperty(
+ CSS_VARIABLES.mirrorAngle,
`${(360 / count).toFixed(3)}deg`
);
};
@@ -286,11 +435,14 @@ const main = async () => {
let configPane: ConfigPane | null = null;
elements = queryAppElements();
- elements.errorContainer.setAttribute('aria-live', 'assertive');
+ elements.errorContainer.setAttribute(
+ ARIA_ATTRIBUTES.live,
+ ARIA_LIVE_VALUES.assertive
+ );
ErrorHandler.addOnErrorListener((error) => {
renderRuntimeMessage(elements.errorContainer, error);
if (error.severity === Severity.ERROR) {
- document.body.classList.remove('is-loading');
+ document.body.classList.remove(CSS_CLASSES.isLoading);
game?.destroy();
shouldStop = true;
}
@@ -325,6 +477,7 @@ const main = async () => {
const startAudioFromUserGesture = (event: Event) => {
if (
isAudioMuted ||
+ (event.target instanceof Node && elements.startButton.contains(event.target)) ||
(event.target instanceof Node && elements.soundButton.contains(event.target))
) {
return;
@@ -333,22 +486,34 @@ const main = async () => {
game?.startAudio(true);
};
- window.addEventListener('touchend', startAudioFromUserGesture, {
+ window.addEventListener(DOM_EVENTS.touchStart, startAudioFromUserGesture, {
capture: true,
passive: true,
});
- window.addEventListener('pointerup', startAudioFromUserGesture, {
+ window.addEventListener(DOM_EVENTS.pointerDown, startAudioFromUserGesture, {
capture: true,
passive: true,
});
- window.addEventListener('click', startAudioFromUserGesture, { capture: true });
- window.addEventListener('keydown', startAudioFromUserGesture, { capture: true });
+ window.addEventListener(DOM_EVENTS.touchEnd, startAudioFromUserGesture, {
+ capture: true,
+ passive: true,
+ });
+ window.addEventListener(DOM_EVENTS.pointerUp, startAudioFromUserGesture, {
+ capture: true,
+ passive: true,
+ });
+ window.addEventListener(DOM_EVENTS.click, startAudioFromUserGesture, {
+ capture: true,
+ });
+ window.addEventListener(DOM_EVENTS.keydown, startAudioFromUserGesture, {
+ capture: true,
+ });
- elements.restartButton.addEventListener('click', () => game?.destroy());
- elements.soundButton.addEventListener('click', () => {
- const shouldUnmute = isAudioMuted || audioVolume <= appConfig.toolbar.volume.min;
- if (shouldUnmute && audioVolume <= appConfig.toolbar.volume.min) {
- audioVolume = appConfig.toolbar.volume.default;
+ elements.restartButton.addEventListener(DOM_EVENTS.click, () => game?.destroy());
+ elements.soundButton.addEventListener(DOM_EVENTS.click, () => {
+ const shouldUnmute = isAudioMuted || audioVolume <= 0;
+ if (shouldUnmute && audioVolume <= 0) {
+ audioVolume = DEFAULT_AUDIO_VOLUME;
}
isAudioMuted = !shouldUnmute;
persistAudioUiState();
@@ -357,9 +522,9 @@ const main = async () => {
game?.startAudio(true);
}
});
- elements.volumeSlider.addEventListener('input', () => {
+ elements.volumeSlider.addEventListener(DOM_EVENTS.input, () => {
audioVolume = clampAudioVolume(Number(elements.volumeSlider.value));
- isAudioMuted = audioVolume <= appConfig.toolbar.volume.min;
+ isAudioMuted = audioVolume <= 0;
persistAudioUiState();
renderAudioUi(game);
if (!isAudioMuted) {
@@ -383,16 +548,16 @@ const main = async () => {
game?.playVibeChangeAudio(true);
};
- elements.previousVibe.addEventListener('click', () =>
- selectRelativeVibe(-1, 'previous-button')
+ elements.previousVibe.addEventListener(DOM_EVENTS.click, () =>
+ selectRelativeVibe(-1, VIBE_CHANGE_SOURCES.previousButton)
);
- elements.nextVibe.addEventListener('click', () =>
- selectRelativeVibe(1, 'next-button')
+ elements.nextVibe.addEventListener(DOM_EVENTS.click, () =>
+ selectRelativeVibe(1, VIBE_CHANGE_SOURCES.nextButton)
);
elements.swatches.forEach((swatch, index) => {
- swatch.addEventListener('click', () => {
+ swatch.addEventListener(DOM_EVENTS.click, () => {
settings.selectedColorIndex = index;
isEraserActive = false;
renderPaletteUi(game);
@@ -405,11 +570,11 @@ const main = async () => {
renderPaletteUi(game);
};
- elements.eraserSizeControl.addEventListener('pointerdown', activateEraser);
- elements.eraserSizeControl.addEventListener('click', activateEraser);
- elements.eraserSizeSlider.addEventListener('focus', activateEraser);
+ elements.eraserSizeControl.addEventListener(DOM_EVENTS.pointerDown, activateEraser);
+ elements.eraserSizeControl.addEventListener(DOM_EVENTS.click, activateEraser);
+ elements.eraserSizeSlider.addEventListener(DOM_EVENTS.focus, activateEraser);
- elements.eraserSizeSlider.addEventListener('input', () => {
+ elements.eraserSizeSlider.addEventListener(DOM_EVENTS.input, () => {
settings.eraserSize = clampEraserSize(Number(elements.eraserSizeSlider.value));
isEraserActive = true;
renderEraserSizeUi(game);
@@ -417,7 +582,7 @@ const main = async () => {
configPane?.refresh();
});
- elements.mirrorSegmentSlider.addEventListener('input', () => {
+ elements.mirrorSegmentSlider.addEventListener(DOM_EVENTS.input, () => {
settings.mirrorSegmentCount = clampMirrorSegmentCount(
Number(elements.mirrorSegmentSlider.value)
);
@@ -427,7 +592,7 @@ const main = async () => {
configPane?.refresh();
});
- elements.export4k.addEventListener('click', async () => {
+ elements.export4k.addEventListener(DOM_EVENTS.click, async () => {
if (!game || elements.export4k.disabled) {
return;
}
@@ -448,14 +613,36 @@ const main = async () => {
renderMirrorSegmentUi();
renderAudioUi(game);
+ // Loading runs in the background while the splash (title + description +
+ // Start button) is shown. The Start tap is the user gesture that unlocks
+ // the AudioContext on iOS, and gates the intro.
const fontsReady = document.fonts.ready.catch((error) => {
ErrorHandler.addException(error, {
- fallbackMessage: 'Could not load fonts.',
+ fallbackMessage: LOADING_MESSAGES.fontsError,
severity: Severity.WARNING,
});
});
- setLoadingStage('Connecting to GPU…', 0.1);
- const gpu = await initializeGpu();
+ const gpuPromise = initializeGpu();
+
+ let isPreloadComplete = false;
+ const preloadPromise = preloadPianoSamples(({ loadedCount, totalCount }) => {
+ const ratio = totalCount > 0 ? loadedCount / totalCount : 0;
+ setLoadingStage(`Loading piano samples ${loadedCount}/${totalCount}…`, ratio);
+ }).then(
+ () => {
+ isPreloadComplete = true;
+ setLoadingStage(LOADING_MESSAGES.ready, 1);
+ },
+ (error: unknown) => {
+ isPreloadComplete = true;
+ ErrorHandler.addException(error, {
+ fallbackMessage: LOADING_MESSAGES.pianoSamplesError,
+ severity: Severity.WARNING,
+ });
+ }
+ );
+
+ const gpu = await gpuPromise;
configPane = new ConfigPane({
settingsButton: elements.settingsButton,
onConfigChange: () => {
@@ -480,7 +667,7 @@ const main = async () => {
trackVibeChange({
vibeId: activePreset.id,
vibeName: activePreset.name,
- source: 'settings',
+ source: VIBE_CHANGE_SOURCES.settings,
});
game?.onVibeChanged();
syncRuntimeUi();
@@ -488,17 +675,7 @@ const main = async () => {
},
});
infoPageHandler.onOpen = configPane.close.bind(configPane);
- setLoadingStage('Loading fonts…', 0.3);
await fontsReady;
- setLoadingStage('Loading piano samples…', 0.45);
- await preloadPianoSamples(({ loadedCount, totalCount }) => {
- const sampleRatio = totalCount > 0 ? loadedCount / totalCount : 1;
- setLoadingStage(
- `Loading piano samples ${loadedCount}/${totalCount}…`,
- 0.45 + sampleRatio * 0.3
- );
- });
- setLoadingStage('Compiling shaders…', 0.8);
const deltaTimeCalculator = new DeltaTimeCalculator();
@@ -515,18 +692,48 @@ const main = async () => {
renderMirrorSegmentUi();
renderAudioUi(game);
- const startPromise = game.start();
if (isFirstStart) {
isFirstStart = false;
- setLoadingStage('Ready', 1);
+
+ // Splash is in the DOM by default; enable the button now that the
+ // audio system (GameLoop) is constructed and ready to be unlocked.
+ elements.startButton.disabled = false;
+ await new Promise((resolve) => {
+ const onClick = () => {
+ elements.startButton.removeEventListener(DOM_EVENTS.click, onClick);
+ game?.startAudio(true);
+ trackStart();
+ elements.splash.hidden = true;
+ resolve();
+ };
+ elements.startButton.addEventListener(DOM_EVENTS.click, onClick);
+ });
+
+ if (!isPreloadComplete) {
+ elements.loadingBar.hidden = false;
+ void preloadPromise.finally(() => {
+ elements.loadingBar.hidden = true;
+ });
+ }
+
+ // Keep the toolbar/dock hidden until the user actually starts drawing.
+ document.body.classList.add(CSS_CLASSES.preDrawing);
+ elements.canvas.addEventListener(
+ DOM_EVENTS.pointerDown,
+ () => document.body.classList.remove(CSS_CLASSES.preDrawing),
+ { once: true }
+ );
+
requestAnimationFrame(() =>
- requestAnimationFrame(() => document.body.classList.remove('is-loading'))
+ requestAnimationFrame(() =>
+ document.body.classList.remove(CSS_CLASSES.isLoading)
+ )
);
}
- await startPromise;
+ await game.start();
}
} catch (e) {
- document.body.classList.remove('is-loading');
+ document.body.classList.remove(CSS_CLASSES.isLoading);
if (hasRuntimeErrorListener) {
ErrorHandler.addException(e);
} else {
diff --git a/src/page/collapsible-panel-animator.test.ts b/src/page/collapsible-panel-animator.test.ts
deleted file mode 100644
index 6d32a4e..0000000
--- a/src/page/collapsible-panel-animator.test.ts
+++ /dev/null
@@ -1,137 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-
-import { CollapsiblePanelAnimator } from './collapsible-panel-animator';
-
-type Listener = (event: Record) => void;
-
-class FakeClassList {
- private readonly classes = new Set();
-
- public add(className: string): void {
- this.classes.add(className);
- }
-
- public contains(className: string): boolean {
- return this.classes.has(className);
- }
-
- public remove(className: string): void {
- this.classes.delete(className);
- }
-
- public toggle(className: string, force?: boolean): boolean {
- const shouldAdd = force ?? !this.classes.has(className);
- if (shouldAdd) {
- this.add(className);
- } else {
- this.remove(className);
- }
- return shouldAdd;
- }
-}
-
-class FakeElement {
- public readonly classList = new FakeClassList();
- public inert = false;
-
- private readonly attributes = new Map();
- private readonly children = new Set();
- private readonly listeners = new Map>();
-
- public addChild(child: FakeElement): void {
- this.children.add(child);
- }
-
- public addEventListener(type: string, listener: Listener): void {
- this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]);
- }
-
- public contains(target: unknown): boolean {
- return target === this || this.children.has(target as FakeElement);
- }
-
- public dispatch(type: string, event: Record = {}): void {
- this.listeners.get(type)?.forEach((listener) => listener({ target: this, ...event }));
- }
-
- public focus(): void {
- fakeDocument.activeElement = this;
- }
-
- public getAttribute(name: string): string | null {
- return this.attributes.get(name) ?? null;
- }
-
- public setAttribute(name: string, value: string): void {
- this.attributes.set(name, value);
- }
-}
-
-const windowListeners = new Map>();
-const fakeDocument: { activeElement: FakeElement | null } = {
- activeElement: null,
-};
-
-const dispatchWindowEvent = (type: string, event: Record = {}) => {
- windowListeners.get(type)?.forEach((listener) => listener(event));
-};
-
-describe('CollapsiblePanelAnimator', () => {
- beforeEach(() => {
- windowListeners.clear();
- fakeDocument.activeElement = null;
- vi.stubGlobal('HTMLElement', FakeElement);
- vi.stubGlobal('document', fakeDocument);
- vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
- callback(0);
- return 1;
- });
- vi.stubGlobal('window', {
- addEventListener: (type: string, listener: Listener) => {
- windowListeners.set(type, [...(windowListeners.get(type) ?? []), listener]);
- },
- });
- });
-
- afterEach(() => {
- vi.unstubAllGlobals();
- });
-
- it('syncs About panel accessibility when toggled and closed with Escape', () => {
- const button = new FakeElement();
- const panel = new FakeElement();
- const dock = new FakeElement();
- dock.addChild(button);
- dock.addChild(panel);
-
- new CollapsiblePanelAnimator(
- button as unknown as HTMLButtonElement,
- panel as unknown as HTMLElement,
- dock as unknown as HTMLElement
- );
-
- expect(button.getAttribute('aria-expanded')).toBe('false');
- expect(panel.getAttribute('aria-hidden')).toBe('true');
- expect(panel.inert).toBe(true);
- expect(panel.classList.contains('hidden')).toBe(true);
-
- fakeDocument.activeElement = button;
- button.dispatch('click');
-
- expect(button.getAttribute('aria-expanded')).toBe('true');
- expect(button.classList.contains('active')).toBe(true);
- expect(panel.getAttribute('aria-hidden')).toBe('false');
- expect(panel.inert).toBe(false);
- expect(panel.classList.contains('hidden')).toBe(false);
- expect(fakeDocument.activeElement).toBe(panel);
-
- const preventDefault = vi.fn();
- dispatchWindowEvent('keydown', { key: 'Escape', preventDefault });
-
- expect(preventDefault).toHaveBeenCalledOnce();
- expect(button.getAttribute('aria-expanded')).toBe('false');
- expect(panel.getAttribute('aria-hidden')).toBe('true');
- expect(panel.inert).toBe(true);
- expect(fakeDocument.activeElement).toBe(button);
- });
-});
diff --git a/src/page/config-pane.ts b/src/page/config-pane.ts
index 84831c0..defadee 100644
--- a/src/page/config-pane.ts
+++ b/src/page/config-pane.ts
@@ -12,20 +12,22 @@ import { isVibeId, VIBE_PRESETS, type VibeId } from '../vibes';
type PaneContainer = Pick;
type ColorReactionKey = (typeof colorReactionRows)[number]['keys'][number];
+const COLOR_REACTION_LABELS = ['1', '2', '3'] as const;
+
const colorReactionRows = [
{
colorIndex: 0,
- label: '1',
+ label: COLOR_REACTION_LABELS[0],
keys: ['color1ToColor1', 'color1ToColor2', 'color1ToColor3'],
},
{
colorIndex: 1,
- label: '2',
+ label: COLOR_REACTION_LABELS[1],
keys: ['color2ToColor1', 'color2ToColor2', 'color2ToColor3'],
},
{
colorIndex: 2,
- label: '3',
+ label: COLOR_REACTION_LABELS[2],
keys: ['color3ToColor1', 'color3ToColor2', 'color3ToColor3'],
},
] as const;
@@ -70,24 +72,34 @@ const normalizeNumber = (value: number, config: NumberControlConfig): number =>
if (optionValues.includes(value)) {
return value;
}
- return optionValues.includes(0) ? 0 : (optionValues[0] ?? config.min);
+ return optionValues.includes(0) ? 0 : (optionValues[0] ?? config.min ?? 0);
}
- const finiteValue = Number.isFinite(value) ? value : config.min;
- const clampedValue = Math.min(config.max, Math.max(config.min, finiteValue));
+ const min = config.min ?? Number.NEGATIVE_INFINITY;
+ const max = config.max ?? Number.POSITIVE_INFINITY;
+ const fallbackValue = config.min ?? 0;
+ const finiteValue = Number.isFinite(value) ? value : fallbackValue;
+ const clampedValue = Math.min(max, Math.max(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,
- options: config.options,
- step: config.step,
-});
+): BindingParams => {
+ const params: BindingParams = {
+ label: config.label ?? toLabel(key),
+ options: config.options,
+ step: config.step,
+ };
+ if (config.min !== undefined) {
+ params.min = config.min;
+ }
+ if (config.max !== undefined) {
+ params.max = config.max;
+ }
+ return params;
+};
export class ConfigPane {
private readonly container: HTMLDivElement;
diff --git a/src/page/menu-hider.test.ts b/src/page/menu-hider.test.ts
deleted file mode 100644
index 4f5cffd..0000000
--- a/src/page/menu-hider.test.ts
+++ /dev/null
@@ -1,138 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-
-import { appConfig } from '../config';
-import { MenuHider } from './menu-hider';
-
-type Listener> = (event: T) => void;
-
-class FakeClassList {
- private readonly classes = new Set();
-
- public add(className: string): void {
- this.classes.add(className);
- }
-
- public contains(className: string): boolean {
- return this.classes.has(className);
- }
-
- public remove(className: string): void {
- this.classes.delete(className);
- }
-}
-
-class FakeDockElement {
- public readonly classList = new FakeClassList();
- public inert = false;
-
- private readonly attributes = new Map();
- private readonly listeners = new Map>();
-
- public addEventListener(type: string, listener: Listener): void {
- this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]);
- }
-
- public contains(target: unknown): boolean {
- return target === this;
- }
-
- public dispatch(type: string, event: Record = {}): void {
- this.listeners.get(type)?.forEach((listener) => listener(event));
- }
-
- public getAttribute(name: string): string | null {
- return this.attributes.get(name) ?? null;
- }
-
- public getBoundingClientRect(): DOMRect {
- return {
- bottom: 720,
- height: 120,
- left: 0,
- right: 1280,
- toJSON: () => ({}),
- top: 600,
- width: 1280,
- x: 0,
- y: 600,
- } as DOMRect;
- }
-
- public setAttribute(name: string, value: string): void {
- this.attributes.set(name, value);
- }
-}
-
-const windowListeners = new Map>();
-let isDesktop = true;
-
-const dispatchWindowEvent = >(
- type: string,
- event: T
-) => {
- windowListeners.get(type)?.forEach((listener) => listener(event));
-};
-
-describe('MenuHider', () => {
- beforeEach(() => {
- vi.useFakeTimers();
- windowListeners.clear();
- isDesktop = true;
-
- vi.stubGlobal('document', {
- activeElement: null,
- addEventListener: vi.fn(),
- documentElement: {
- clientHeight: 720,
- },
- });
- vi.stubGlobal('window', {
- addEventListener: (type: string, listener: Listener) => {
- windowListeners.set(type, [...(windowListeners.get(type) ?? []), listener]);
- },
- clearTimeout,
- innerHeight: 720,
- matchMedia: () => ({
- addEventListener: vi.fn(),
- matches: isDesktop,
- }),
- setTimeout,
- });
- });
-
- afterEach(() => {
- vi.useRealTimers();
- vi.unstubAllGlobals();
- });
-
- it('hides the dock after the desktop fullscreen pointer leaves it', () => {
- const dock = new FakeDockElement();
-
- new MenuHider(dock as unknown as HTMLElement, () => true);
- dock.dispatch('pointerleave');
- vi.advanceTimersByTime(appConfig.menuHider.hideDelayMs);
-
- expect(dock.classList.contains('menu-hidden')).toBe(true);
- expect(dock.getAttribute('aria-hidden')).toBe('true');
- expect(dock.inert).toBe(true);
-
- dispatchWindowEvent('pointermove', { clientX: 640, clientY: 710 });
-
- expect(dock.classList.contains('menu-hidden')).toBe(false);
- expect(dock.getAttribute('aria-hidden')).toBe('false');
- expect(dock.inert).toBe(false);
- });
-
- it('keeps the dock visible outside the desktop auto-hide breakpoint', () => {
- isDesktop = false;
- const dock = new FakeDockElement();
-
- new MenuHider(dock as unknown as HTMLElement, () => true);
- dock.dispatch('pointerleave');
- vi.advanceTimersByTime(appConfig.menuHider.hideDelayMs);
-
- expect(dock.classList.contains('menu-hidden')).toBe(false);
- expect(dock.getAttribute('aria-hidden')).toBe('false');
- expect(dock.inert).toBe(false);
- });
-});
diff --git a/src/pipelines/agents/agent-generation/agent-compaction.wgsl b/src/pipelines/agents/agent-generation/agent-compaction.wgsl
index 7ae140a..d71ddb2 100644
--- a/src/pipelines/agents/agent-generation/agent-compaction.wgsl
+++ b/src/pipelines/agents/agent-generation/agent-compaction.wgsl
@@ -16,12 +16,17 @@ struct Counters {
var workgroupAliveCount: atomic;
var workgroupCompactedOffset: u32;
+fn dead_agent() -> Agent {
+ return Agent(vec2(0.0, 0.0), 0.0, -1.0, vec2(-1.0, -1.0), 0.0, 0.0);
+}
+
@compute @workgroup_size(64)
fn main(
@builtin(global_invocation_id) global_id: vec3,
- @builtin(local_invocation_id) local_id: vec3
+ @builtin(local_invocation_id) local_id: vec3,
+ @builtin(num_workgroups) num_workgroups: vec3
) {
- let id = get_id(global_id);
+ let id = get_id(global_id, num_workgroups);
if local_id.x == 0u {
atomicStore(&workgroupAliveCount, 0u);
@@ -30,7 +35,7 @@ fn main(
workgroupBarrier();
var localCompactedIndex = 0u;
- var agent = Agent(vec2(0.0, 0.0), 0.0, -1.0, vec2(-1.0, -1.0), 0.0, 0.0);
+ var agent = dead_agent();
var isAlive = false;
if id < settings.agentCount {
agent = agents[id];
@@ -57,3 +62,20 @@ fn main(
compactedAgents[workgroupCompactedOffset + localCompactedIndex] = agent;
}
}
+
+@compute @workgroup_size(64)
+fn clearCompactedTail(
+ @builtin(global_invocation_id) global_id: vec3,
+ @builtin(num_workgroups) num_workgroups: vec3
+) {
+ let id = get_id(global_id, num_workgroups);
+
+ if id >= settings.agentCount {
+ return;
+ }
+
+ let aliveAgentCount = atomicLoad(&counters.aliveAgentCount);
+ if id >= aliveAgentCount {
+ compactedAgents[id] = dead_agent();
+ }
+}
diff --git a/src/pipelines/agents/agent-generation/agent-generation-pipeline.test.ts b/src/pipelines/agents/agent-generation/agent-generation-pipeline.test.ts
deleted file mode 100644
index 0758b4c..0000000
--- a/src/pipelines/agents/agent-generation/agent-generation-pipeline.test.ts
+++ /dev/null
@@ -1,210 +0,0 @@
-import { describe, expect, it, vi } from 'vitest';
-
-import { AGENT_SIZE_IN_BYTES } from './agent';
-import { AgentGenerationPipeline } from './agent-generation-pipeline';
-
-const installGpuConstants = () => {
- Object.defineProperties(globalThis, {
- GPUBufferUsage: {
- configurable: true,
- value: {
- MAP_READ: 1,
- COPY_DST: 2,
- COPY_SRC: 4,
- STORAGE: 8,
- UNIFORM: 16,
- },
- },
- GPUMapMode: {
- configurable: true,
- value: {
- READ: 1,
- },
- },
- GPUShaderStage: {
- configurable: true,
- value: {
- COMPUTE: 1,
- },
- },
- });
-};
-
-type CopyCall = {
- source: GPUBuffer;
- sourceOffset: number;
- destination: GPUBuffer;
- destinationOffset: number;
- size: number;
-};
-
-type DispatchCall = {
- entryPoint: string;
- workgroups: [number, number, number];
-};
-
-type FakePipeline = {
- entryPoint: string;
-};
-
-class FakeBuffer {
- private readonly mappedRange: ArrayBuffer;
-
- public readonly destroy = vi.fn();
- public readonly mapAsync = vi.fn(async () => undefined);
- public readonly getMappedRange = vi.fn(() => this.mappedRange);
- public readonly unmap = vi.fn();
-
- public constructor(
- public readonly label: string,
- size: number,
- mappedValue = 0
- ) {
- this.mappedRange = new ArrayBuffer(Math.max(size, Uint32Array.BYTES_PER_ELEMENT));
- new Uint32Array(this.mappedRange)[0] = mappedValue;
- }
-}
-
-class FakeComputePass {
- private pipeline: FakePipeline | null = null;
-
- public readonly setPipeline = vi.fn((pipeline: GPUComputePipeline) => {
- this.pipeline = pipeline as unknown as FakePipeline;
- });
- public readonly setBindGroup = vi.fn(() => undefined);
- public readonly dispatchWorkgroups = vi.fn((x: number, y = 1, z = 1) => {
- this.device.dispatchCalls.push({
- entryPoint: this.pipeline?.entryPoint ?? 'unset',
- workgroups: [x, y, z],
- });
- });
- public readonly end = vi.fn();
-
- public constructor(private readonly device: FakeDevice) {}
-}
-
-class FakeCommandEncoder {
- public readonly beginComputePass = vi.fn(() => new FakeComputePass(this.device));
- public readonly copyBufferToBuffer = vi.fn(
- (
- source: GPUBuffer,
- sourceOffset: number,
- destination: GPUBuffer,
- destinationOffset: number,
- size: number
- ) => {
- this.device.copyCalls.push({
- source,
- sourceOffset,
- destination,
- destinationOffset,
- size,
- });
- }
- );
- public readonly finish = vi.fn(() => ({}) as GPUCommandBuffer);
-
- public constructor(private readonly device: FakeDevice) {}
-}
-
-class FakeQueue {
- public readonly writeBuffer = vi.fn(() => undefined);
- public readonly submit = vi.fn(() => undefined);
-}
-
-class FakeShaderModule {
- public readonly getCompilationInfo = vi.fn(async () => ({
- messages: [],
- }));
-}
-
-class FakeDevice {
- public readonly copyCalls: Array = [];
- public readonly dispatchCalls: Array = [];
- public readonly createdComputeEntryPoints: Array = [];
- public readonly limits = {
- maxBufferSize: 1024 * 1024 * 1024,
- maxComputeWorkgroupsPerDimension: 65_535,
- };
- public readonly queue = new FakeQueue();
-
- private bufferIndex = 0;
-
- public readonly createBindGroupLayout = vi.fn(() => ({}) as GPUBindGroupLayout);
- public readonly createBuffer = vi.fn((descriptor: GPUBufferDescriptor) => {
- const label =
- ['agents', 'compactedAgents', 'counters', 'countersStaging', 'uniforms'][
- this.bufferIndex
- ] ?? `buffer${this.bufferIndex}`;
- this.bufferIndex += 1;
-
- const isMappedReadBuffer = (Number(descriptor.usage) & GPUBufferUsage.MAP_READ) !== 0;
-
- return new FakeBuffer(
- label,
- Number(descriptor.size),
- isMappedReadBuffer ? this.compactedCount : 0
- ) as unknown as GPUBuffer;
- });
- public readonly createBindGroup = vi.fn(() => ({}) as GPUBindGroup);
- public readonly createPipelineLayout = vi.fn(() => ({}) as GPUPipelineLayout);
- public readonly createShaderModule = vi.fn(
- () => new FakeShaderModule() as unknown as GPUShaderModule
- );
- public readonly createComputePipeline = vi.fn(
- (descriptor: GPUComputePipelineDescriptor) => {
- const pipeline = {
- entryPoint: descriptor.compute.entryPoint ?? 'main',
- };
- this.createdComputeEntryPoints.push(pipeline.entryPoint);
- return pipeline as unknown as GPUComputePipeline;
- }
- );
- public readonly createCommandEncoder = vi.fn(() => new FakeCommandEncoder(this));
-
- public constructor(private readonly compactedCount: number) {}
-}
-
-const createPipeline = (compactedCount: number) => {
- installGpuConstants();
-
- const device = new FakeDevice(compactedCount);
-
- return {
- device,
- pipeline: new AgentGenerationPipeline(device as unknown as GPUDevice, 1024),
- };
-};
-
-describe('AgentGenerationPipeline compaction', () => {
- it('swaps compacted agents into the active buffer without a copy-back dispatch', async () => {
- const agentCount = 10;
- const { device, pipeline } = createPipeline(3);
-
- await expect(pipeline.compactAgents(agentCount)).resolves.toBe(3);
-
- expect(device.createdComputeEntryPoints).not.toContain('copyCompactedAgents');
- expect(device.dispatchCalls.map((call) => call.entryPoint)).toEqual(['main']);
- expect(device.copyCalls.map((call) => call.size)).toEqual([
- Uint32Array.BYTES_PER_ELEMENT,
- ]);
- expect(
- device.copyCalls.some((call) => call.size === agentCount * AGENT_SIZE_IN_BYTES)
- ).toBe(false);
- expect(device.queue.submit).toHaveBeenCalledTimes(1);
-
- pipeline.destroy();
- });
-
- it('does not encode work for empty compaction requests', async () => {
- const { device, pipeline } = createPipeline(0);
-
- await expect(pipeline.compactAgents(0)).resolves.toBe(0);
-
- expect(device.dispatchCalls).toEqual([]);
- expect(device.copyCalls).toEqual([]);
- expect(device.queue.submit).not.toHaveBeenCalled();
-
- pipeline.destroy();
- });
-});
diff --git a/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts b/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts
index 79abf51..f243441 100644
--- a/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts
+++ b/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts
@@ -1,14 +1,19 @@
import { vec2 } from 'gl-matrix';
-import { getWorkgroupCount } from '../../../utils/graphics/get-workgroup-count';
import { smartCompile } from '../../../utils/graphics/smart-compile';
-import { AGENT_SIZE_IN_BYTES } from './agent';
+import {
+ AGENT_MAX_DISPATCHABLE_COUNT,
+ dispatchAgentWorkgroups,
+} from '../agent-dispatch';
import compactionShader from './agent-compaction.wgsl?raw';
import resizeShader from './agent-resize.wgsl?raw';
import agentSchema from './agent-schema.wgsl?raw';
+export const AGENT_FLOAT_COUNT = 8;
+export const AGENT_SIZE_IN_BYTES =
+ AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT;
+
export class AgentGenerationPipeline {
- private static readonly WORKGROUP_SIZE = 64;
private static readonly UNIFORM_COUNT = 4;
private static readonly COUNTER_COUNT = 1;
@@ -21,9 +26,11 @@ export class AgentGenerationPipeline {
private readonly resizePipeline: GPUComputePipeline;
private readonly compactionPipeline: GPUComputePipeline;
+ private readonly clearCompactedTailPipeline: GPUComputePipeline;
private activeAgentsBuffer: GPUBuffer;
private inactiveAgentsBuffer: GPUBuffer;
+ private allocatedMaxAgentCount: number;
private readonly countersBuffer: GPUBuffer;
private readonly countersStagingBuffer: GPUBuffer;
private readonly counterClearValues = new Uint32Array(
@@ -35,8 +42,10 @@ export class AgentGenerationPipeline {
public constructor(
private readonly device: GPUDevice,
- private readonly maxAgentCountUpperLimit: number
+ initialMaxAgentCount: number,
+ private readonly maxAgentCountUpperLimit = Number.POSITIVE_INFINITY
) {
+ this.allocatedMaxAgentCount = this.clampMaxAgentCount(initialMaxAgentCount);
const emptyBindGroupLayout = device.createBindGroupLayout({ entries: [] });
this.bindGroupLayout = device.createBindGroupLayout({
entries: [
@@ -72,7 +81,7 @@ export class AgentGenerationPipeline {
});
this.activeAgentsBuffer = this.createAgentsBuffer();
- this.inactiveAgentsBuffer = this.createAgentsBuffer();
+ this.inactiveAgentsBuffer = this.createInactivePlaceholderBuffer();
this.countersBuffer = this.device.createBuffer({
size: AgentGenerationPipeline.COUNTER_COUNT * Uint32Array.BYTES_PER_ELEMENT,
@@ -110,6 +119,16 @@ export class AgentGenerationPipeline {
entryPoint: 'main',
},
});
+
+ this.clearCompactedTailPipeline = device.createComputePipeline({
+ layout: device.createPipelineLayout({
+ bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout],
+ }),
+ compute: {
+ module: compactionModule,
+ entryPoint: 'clearCompactedTail',
+ },
+ });
}
public get agentsBuffer(): GPUBuffer {
@@ -118,23 +137,86 @@ export class AgentGenerationPipeline {
private createAgentsBuffer(): GPUBuffer {
return this.device.createBuffer({
- size: this.maxAgentCount * AGENT_SIZE_IN_BYTES,
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
+ size: this.allocatedMaxAgentCount * AGENT_SIZE_IN_BYTES,
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
+ });
+ }
+
+ // The inactive slot only needs a real allocation during compaction. The rest of
+ // the time we keep a one-agent placeholder so the bind group at binding 3 stays
+ // valid for resize without holding a second N-agent buffer in GPU memory.
+ private createInactivePlaceholderBuffer(): GPUBuffer {
+ return this.device.createBuffer({
+ size: AGENT_SIZE_IN_BYTES,
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
});
}
public get maxAgentCount(): number {
+ return this.allocatedMaxAgentCount;
+ }
+
+ public get maxSupportedAgentCount(): number {
+ return this.clampMaxAgentCount(Number.POSITIVE_INFINITY);
+ }
+
+ public ensureMaxAgentCount(
+ requestedMaxAgentCount: number,
+ activeAgentCount: number
+ ): number {
+ const nextMaxAgentCount = this.clampMaxAgentCount(requestedMaxAgentCount);
+ if (nextMaxAgentCount <= this.allocatedMaxAgentCount) {
+ return this.allocatedMaxAgentCount;
+ }
+
+ const previousActiveAgentsBuffer = this.activeAgentsBuffer;
+ const previousMaxAgentCount = this.allocatedMaxAgentCount;
+ this.allocatedMaxAgentCount = nextMaxAgentCount;
+ this.activeAgentsBuffer = this.createAgentsBuffer();
+
+ const copyAgentCount = Math.min(
+ Math.max(0, Math.floor(activeAgentCount)),
+ previousMaxAgentCount,
+ nextMaxAgentCount
+ );
+ if (copyAgentCount > 0) {
+ const commandEncoder = this.device.createCommandEncoder();
+ commandEncoder.copyBufferToBuffer(
+ previousActiveAgentsBuffer,
+ 0,
+ this.activeAgentsBuffer,
+ 0,
+ copyAgentCount * AGENT_SIZE_IN_BYTES
+ );
+ this.device.queue.submit([commandEncoder.finish()]);
+ }
+
+ // GPUBuffer.destroy() defers actual freeing until pending submissions
+ // finish, so calling it synchronously after submit is safe and avoids the
+ // transient 4-buffers-live spike that pushes iOS Safari past its per-tab
+ // memory ceiling.
+ previousActiveAgentsBuffer.destroy();
+ return this.allocatedMaxAgentCount;
+ }
+
+ private clampMaxAgentCount(value: number): number {
+ const requestedMaxAgentCount =
+ value === Number.POSITIVE_INFINITY
+ ? Number.POSITIVE_INFINITY
+ : Number.isFinite(value)
+ ? Math.floor(value)
+ : 0;
return Math.min(
Number.isFinite(this.maxAgentCountUpperLimit)
? this.maxAgentCountUpperLimit
: Number.POSITIVE_INFINITY,
- Math.floor(this.device.limits.maxBufferSize / AGENT_SIZE_IN_BYTES) - 1,
+ Math.floor(this.device.limits.maxBufferSize / AGENT_SIZE_IN_BYTES),
Math.floor(
((this.device.limits as GPUSupportedLimits).maxStorageBufferBindingSize ??
this.device.limits.maxBufferSize) / AGENT_SIZE_IN_BYTES
- ) - 1,
- this.device.limits.maxComputeWorkgroupsPerDimension *
- AgentGenerationPipeline.WORKGROUP_SIZE
+ ),
+ AGENT_MAX_DISPATCHABLE_COUNT,
+ Math.max(0, requestedMaxAgentCount)
);
}
@@ -161,9 +243,7 @@ export class AgentGenerationPipeline {
const passEncoder = commandEncoder.beginComputePass();
passEncoder.setPipeline(this.resizePipeline);
passEncoder.setBindGroup(1, this.getBindGroup());
- passEncoder.dispatchWorkgroups(
- getWorkgroupCount(agentCount, AgentGenerationPipeline.WORKGROUP_SIZE)
- );
+ dispatchAgentWorkgroups(passEncoder, agentCount);
passEncoder.end();
this.device.queue.submit([commandEncoder.finish()]);
@@ -174,6 +254,12 @@ export class AgentGenerationPipeline {
return 0;
}
+ // Stash the placeholder, swap in a real N-agent destination buffer just
+ // for this compaction so the rest of the time we only carry one full
+ // agent buffer in memory.
+ const placeholder = this.inactiveAgentsBuffer;
+ this.inactiveAgentsBuffer = this.createAgentsBuffer();
+
this.agentCountUniformValues[0] = agentCount;
this.device.queue.writeBuffer(this.countersBuffer, 0, this.counterClearValues);
this.device.queue.writeBuffer(this.uniforms, 0, this.agentCountUniformValues);
@@ -182,9 +268,9 @@ export class AgentGenerationPipeline {
const passEncoder = commandEncoder.beginComputePass();
passEncoder.setPipeline(this.compactionPipeline);
passEncoder.setBindGroup(1, this.getBindGroup());
- passEncoder.dispatchWorkgroups(
- getWorkgroupCount(agentCount, AgentGenerationPipeline.WORKGROUP_SIZE)
- );
+ dispatchAgentWorkgroups(passEncoder, agentCount);
+ passEncoder.setPipeline(this.clearCompactedTailPipeline);
+ dispatchAgentWorkgroups(passEncoder, agentCount);
passEncoder.end();
commandEncoder.copyBufferToBuffer(
@@ -196,6 +282,14 @@ export class AgentGenerationPipeline {
);
this.device.queue.submit([commandEncoder.finish()]);
+ this.swapAgentBuffers();
+
+ // After swap, inactive is the previous active (full size). Destroy it and
+ // restore the placeholder; the destroy is deferred by WebGPU until the
+ // submitted compaction work has finished.
+ const previousActiveAgentsBuffer = this.inactiveAgentsBuffer;
+ this.inactiveAgentsBuffer = placeholder;
+ previousActiveAgentsBuffer.destroy();
await this.countersStagingBuffer.mapAsync(GPUMapMode.READ);
const compactedCount = new Uint32Array(
@@ -204,7 +298,6 @@ export class AgentGenerationPipeline {
1
)[0];
this.countersStagingBuffer.unmap();
- this.swapAgentBuffers();
return compactedCount;
}
diff --git a/src/pipelines/agents/agent-generation/agent-resize.wgsl b/src/pipelines/agents/agent-generation/agent-resize.wgsl
index 601a260..3e160de 100644
--- a/src/pipelines/agents/agent-generation/agent-resize.wgsl
+++ b/src/pipelines/agents/agent-generation/agent-resize.wgsl
@@ -8,9 +8,10 @@ struct ResizeSettings {
@compute @workgroup_size(64)
fn main(
- @builtin(global_invocation_id) global_id: vec3
+ @builtin(global_invocation_id) global_id: vec3,
+ @builtin(num_workgroups) num_workgroups: vec3
) {
- let id = get_id(global_id);
+ let id = get_id(global_id, num_workgroups);
if id >= u32(resizeSettings.agentCount) {
return;
diff --git a/src/pipelines/agents/agent-generation/agent-schema.test.ts b/src/pipelines/agents/agent-generation/agent-schema.test.ts
deleted file mode 100644
index 13aee8d..0000000
--- a/src/pipelines/agents/agent-generation/agent-schema.test.ts
+++ /dev/null
@@ -1,89 +0,0 @@
-import { describe, expect, it } from 'vitest';
-
-import { AGENT_FLOAT_COUNT, AGENT_SIZE_IN_BYTES } from './agent';
-import compactionShader from './agent-compaction.wgsl?raw';
-import resizeShader from './agent-resize.wgsl?raw';
-import agentSchema from './agent-schema.wgsl?raw';
-
-const wgslFloatCountByType: Record = {
- f32: 1,
- 'vec2': 2,
-};
-
-const getAgentStructFields = () => {
- const match = /struct Agent\s*\{(?[\s\S]*?)\n\}/.exec(agentSchema);
- if (!match?.groups?.body) {
- throw new Error('Agent struct was not found in agent-schema.wgsl');
- }
-
- return match.groups.body
- .split('\n')
- .map((line) => line.trim().replace(/,$/, ''))
- .filter(Boolean)
- .map((line) => {
- const fieldMatch = /^(?\w+):\s*(?[^,]+)$/.exec(line);
- if (!fieldMatch?.groups) {
- throw new Error(`Unsupported Agent field syntax: ${line}`);
- }
-
- return {
- name: fieldMatch.groups.name,
- type: fieldMatch.groups.type,
- };
- });
-};
-
-describe('Agent TS/WGSL contract', () => {
- it('keeps the TypeScript float count aligned with the WGSL Agent struct', () => {
- const fields = getAgentStructFields();
- const wgslFloatCount = fields.reduce((sum, field) => {
- const count = wgslFloatCountByType[field.type];
- if (!count) {
- throw new Error(`Unsupported WGSL Agent field type: ${field.type}`);
- }
-
- return sum + count;
- }, 0);
-
- expect(fields.map((field) => field.name)).toEqual([
- 'position',
- 'angle',
- 'colorIndex',
- 'targetPosition',
- 'targetAngle',
- 'introDelay',
- ]);
- expect(wgslFloatCount).toBe(AGENT_FLOAT_COUNT);
- expect(AGENT_SIZE_IN_BYTES).toBe(AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT);
- });
-
- it('keeps generation shader workgroup sizes aligned with agent indexing', () => {
- [resizeShader, compactionShader].forEach((shader) => {
- expect(shader).toMatch(/@workgroup_size\(64\)/);
- });
-
- expect(agentSchema).toContain('return global_id.x;');
- expect(compactionShader).toContain('let id = get_id(global_id);');
- expect(compactionShader).toContain('if id < settings.agentCount');
- });
-
- it('keeps compaction as a ping-pong write without copy-back shader work', () => {
- expect(compactionShader).not.toContain('fn copyCompactedAgents');
- expect(compactionShader).not.toContain('agents[id] = compactedAgents[id];');
- expect(compactionShader).toContain(
- 'compactedAgents[workgroupCompactedOffset + localCompactedIndex] = agent;'
- );
- });
-
- it('uses workgroup-local counting before allocating global compacted ranges', () => {
- expect(compactionShader).toContain(
- 'var workgroupAliveCount: atomic;'
- );
- expect(compactionShader).toContain(
- 'localCompactedIndex = atomicAdd(&workgroupAliveCount, 1u);'
- );
- expect(
- compactionShader.match(/atomicAdd\(&counters\.aliveAgentCount/g) ?? []
- ).toHaveLength(1);
- });
-});
diff --git a/src/pipelines/agents/agent-generation/agent-schema.wgsl b/src/pipelines/agents/agent-generation/agent-schema.wgsl
index 9262591..94431be 100644
--- a/src/pipelines/agents/agent-generation/agent-schema.wgsl
+++ b/src/pipelines/agents/agent-generation/agent-schema.wgsl
@@ -9,6 +9,8 @@ struct Agent {
@group(1) @binding(1) var agents: array;
-fn get_id(global_id: vec3) -> u32 {
- return global_id.x;
+const agentWorkgroupSize = 64u;
+
+fn get_id(global_id: vec3, num_workgroups: vec3) -> u32 {
+ return global_id.x + global_id.y * num_workgroups.x * agentWorkgroupSize;
}
diff --git a/src/pipelines/agents/agent-generation/agent.ts b/src/pipelines/agents/agent-generation/agent.ts
deleted file mode 100644
index 630e017..0000000
--- a/src/pipelines/agents/agent-generation/agent.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export const AGENT_FLOAT_COUNT = 8;
-export const AGENT_SIZE_IN_BYTES = AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT;
diff --git a/src/pipelines/agents/agent-pipeline.ts b/src/pipelines/agents/agent-pipeline.ts
index f49c287..3e1ac46 100644
--- a/src/pipelines/agents/agent-pipeline.ts
+++ b/src/pipelines/agents/agent-pipeline.ts
@@ -2,15 +2,14 @@ import {
createCachedFloat32BufferWrite,
writeFloat32BufferIfChanged,
} from '../../utils/graphics/cached-buffer-write';
-import { getWorkgroupCount } from '../../utils/graphics/get-workgroup-count';
import { smartCompile } from '../../utils/graphics/smart-compile';
import { CommonState } from '../common-state/common-state';
+import { dispatchAgentWorkgroups } from './agent-dispatch';
import agentSchema from './agent-generation/agent-schema.wgsl?raw';
import { AgentSettings } from './agent-settings';
import shader from './agent.wgsl?raw';
export class AgentPipeline {
- private static readonly WORKGROUP_SIZE = 64;
private static readonly UNIFORM_COUNT = 33;
private readonly bindGroupLayout: GPUBindGroupLayout;
@@ -151,9 +150,7 @@ export class AgentPipeline {
passEncoder.setPipeline(this.pipeline);
this.commonState.execute(passEncoder);
passEncoder.setBindGroup(1, bindGroup);
- passEncoder.dispatchWorkgroups(
- getWorkgroupCount(this.agentCount, AgentPipeline.WORKGROUP_SIZE)
- );
+ dispatchAgentWorkgroups(passEncoder, this.agentCount);
passEncoder.end();
}
diff --git a/src/pipelines/agents/agent.wgsl b/src/pipelines/agents/agent.wgsl
index 491b04a..f1b7412 100644
--- a/src/pipelines/agents/agent.wgsl
+++ b/src/pipelines/agents/agent.wgsl
@@ -41,9 +41,10 @@ struct Settings {
@compute @workgroup_size(64)
fn main(
- @builtin(global_invocation_id) global_id: vec3
+ @builtin(global_invocation_id) global_id: vec3,
+ @builtin(num_workgroups) num_workgroups: vec3
) {
- let id = get_id(global_id);
+ let id = get_id(global_id, num_workgroups);
if id >= u32(settings.agentCount) {
return;
diff --git a/src/pipelines/brush/brush-pipeline.test.ts b/src/pipelines/brush/brush-pipeline.test.ts
deleted file mode 100644
index 889a2bf..0000000
--- a/src/pipelines/brush/brush-pipeline.test.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import { describe, expect, it } from 'vitest';
-
-import { getSafeDevicePixelRatio, setBrushUniformValues } from './brush-pipeline';
-
-const brushSettings = {
- brushAlpha: 0.75,
- brushCoarseNoiseScale: 100,
- brushDiscardThreshold: 0.02,
- brushFeatherRatio: 0.25,
- brushGrainMaxStrength: 1,
- brushGrainMinStrength: 0.4,
- brushGrainNoiseOffsetX: 0.1,
- brushGrainNoiseOffsetY: 0.2,
- brushGrainNoiseScale: 25,
- brushMinimumFeather: 2,
- brushSize: 10,
- brushSizeVariation: 0.5,
- selectedColorIndex: 1,
-};
-
-describe('brush pipeline parameters', () => {
- it('scales pixel-space brush uniforms by device pixel ratio', () => {
- const uniformValues = new Float32Array(16);
-
- setBrushUniformValues(uniformValues, {
- ...brushSettings,
- devicePixelRatio: 2,
- });
-
- expect(uniformValues[0]).toBe(10);
- expect(uniformValues[1]).toBe(5);
- expect(uniformValues[3]).toBe(4);
- expect(uniformValues[5]).toBe(1);
- expect(uniformValues[8]).toBe(200);
- expect(uniformValues[9]).toBe(50);
- expect(uniformValues[15]).toBe(19);
- });
-
- it('falls back to a 1x pixel ratio for invalid values', () => {
- expect(getSafeDevicePixelRatio(0)).toBe(1);
- expect(getSafeDevicePixelRatio(Number.NaN)).toBe(1);
- expect(getSafeDevicePixelRatio(undefined)).toBe(1);
- expect(getSafeDevicePixelRatio(1.5)).toBe(1.5);
- });
-});
diff --git a/src/pipelines/brush/brush-pipeline.ts b/src/pipelines/brush/brush-pipeline.ts
index dd70285..ecce302 100644
--- a/src/pipelines/brush/brush-pipeline.ts
+++ b/src/pipelines/brush/brush-pipeline.ts
@@ -16,15 +16,13 @@ interface LineSegment {
}
interface BrushParameterSettings extends BrushSettings {
- devicePixelRatio?: number;
+ pixelRatio?: number;
selectedColorIndex: number;
}
-export const getSafeDevicePixelRatio = (devicePixelRatio: number | undefined): number =>
- typeof devicePixelRatio === 'number' &&
- Number.isFinite(devicePixelRatio) &&
- devicePixelRatio > 0
- ? devicePixelRatio
+export const getSafePixelRatio = (pixelRatio: number | undefined): number =>
+ typeof pixelRatio === 'number' && Number.isFinite(pixelRatio) && pixelRatio > 0
+ ? pixelRatio
: 1;
export const setBrushUniformValues = (
@@ -43,13 +41,13 @@ export const setBrushUniformValues = (
brushGrainMinStrength,
brushGrainMaxStrength,
selectedColorIndex,
- devicePixelRatio,
+ pixelRatio,
}: BrushParameterSettings
): void => {
- const pixelRatio = getSafeDevicePixelRatio(devicePixelRatio);
- const brushRadius = (brushSize * pixelRatio) / 2;
+ const safePixelRatio = getSafePixelRatio(pixelRatio);
+ const brushRadius = (brushSize * safePixelRatio) / 2;
const brushRadiusVariation = Math.floor(brushRadius * brushSizeVariation);
- const brushMinimumFeatherPixels = brushMinimumFeather * pixelRatio;
+ const brushMinimumFeatherPixels = brushMinimumFeather * safePixelRatio;
const brushFeather = Math.max(
brushMinimumFeatherPixels,
brushRadius * brushFeatherRatio
@@ -65,8 +63,8 @@ export const setBrushUniformValues = (
target[5] = selectedColorIndex === 1 ? 1 : 0;
target[6] = selectedColorIndex === 2 ? 1 : 0;
target[7] = brushAlpha;
- target[8] = brushCoarseNoiseScale * pixelRatio;
- target[9] = brushGrainNoiseScale * pixelRatio;
+ target[8] = brushCoarseNoiseScale * safePixelRatio;
+ target[9] = brushGrainNoiseScale * safePixelRatio;
target[10] = brushGrainNoiseOffsetX;
target[11] = brushGrainNoiseOffsetY;
target[12] = brushDiscardThreshold;
diff --git a/src/pipelines/diffusion/diffusion-pipeline.test.ts b/src/pipelines/diffusion/diffusion-pipeline.test.ts
deleted file mode 100644
index c352d28..0000000
--- a/src/pipelines/diffusion/diffusion-pipeline.test.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import { describe, expect, it } from 'vitest';
-
-import shader from './diffuse.wgsl?raw';
-import {
- getSafeInverseDiffusionRate,
- setDiffusionUniformValues,
-} from './diffusion-pipeline';
-
-describe('diffusion pipeline parameters', () => {
- it('keeps zero diffusion rates finite before writing shader uniforms', () => {
- const uniformValues = new Float32Array(8);
-
- setDiffusionUniformValues(uniformValues, {
- brushDecayAlphaOffset: 1.001,
- decayRateBrush: 900,
- decayRateTrails: 970,
- diffusionDecayRateDivisor: 1000,
- diffusionNeighborDivisor: 8,
- diffusionRateBrush: 0,
- diffusionRateTrails: 0,
- });
-
- expect(Number.isFinite(uniformValues[0])).toBe(true);
- expect(Number.isFinite(uniformValues[2])).toBe(true);
- expect(uniformValues[0]).toBeGreaterThan(0);
- expect(uniformValues[2]).toBeGreaterThan(0);
- });
-
- it('passes valid diffusion rates through as inverse values', () => {
- expect(getSafeInverseDiffusionRate(2)).toBe(0.5);
- expect(getSafeInverseDiffusionRate(0.25)).toBe(4);
- });
-
- it('keeps the diffusion shader on the tiled compute sampling path', () => {
- expect(shader).toContain('@compute @workgroup_size(16, 16)');
- expect(shader).toContain('var tile');
- expect(shader).toContain('textureLoad');
- expect(shader).not.toContain('textureSample');
- expect(shader).not.toContain('pow(');
- expect(shader).not.toContain('noise');
- });
-
- it('keeps shader resource groups aligned with the simplified pipeline layout', () => {
- expect(shader).toContain('@group(0) @binding(0) var settings');
- expect(shader).toContain('@group(0) @binding(1) var trailMap');
- expect(shader).toContain('@group(0) @binding(2) var trailMapOut');
- expect(shader).not.toContain('@group(1)');
- });
-});
diff --git a/src/pipelines/eraser/eraser-agent-pipeline.ts b/src/pipelines/eraser/eraser-agent-pipeline.ts
index 145a783..392fbd4 100644
--- a/src/pipelines/eraser/eraser-agent-pipeline.ts
+++ b/src/pipelines/eraser/eraser-agent-pipeline.ts
@@ -2,13 +2,12 @@ import {
createCachedFloat32BufferWrite,
writeFloat32BufferIfChanged,
} from '../../utils/graphics/cached-buffer-write';
-import { getWorkgroupCount } from '../../utils/graphics/get-workgroup-count';
import { smartCompile } from '../../utils/graphics/smart-compile';
+import { dispatchAgentWorkgroups } from '../agents/agent-dispatch';
import agentSchema from '../agents/agent-generation/agent-schema.wgsl?raw';
import shader from './eraser-agent.wgsl?raw';
export class EraserAgentPipeline {
- private static readonly WORKGROUP_SIZE = 64;
private static readonly UNIFORM_COUNT = 4;
private readonly bindGroupLayout: GPUBindGroupLayout;
@@ -118,9 +117,7 @@ export class EraserAgentPipeline {
const passEncoder = commandEncoder.beginComputePass();
passEncoder.setPipeline(this.pipeline);
passEncoder.setBindGroup(1, this.getBindGroup(eraserMask));
- passEncoder.dispatchWorkgroups(
- getWorkgroupCount(this.agentCount, EraserAgentPipeline.WORKGROUP_SIZE)
- );
+ dispatchAgentWorkgroups(passEncoder, this.agentCount);
passEncoder.end();
}
diff --git a/src/pipelines/eraser/eraser-agent.wgsl b/src/pipelines/eraser/eraser-agent.wgsl
index 9c7b57c..1abec62 100644
--- a/src/pipelines/eraser/eraser-agent.wgsl
+++ b/src/pipelines/eraser/eraser-agent.wgsl
@@ -10,9 +10,10 @@ struct Settings {
@compute @workgroup_size(64)
fn main(
- @builtin(global_invocation_id) global_id: vec3
+ @builtin(global_invocation_id) global_id: vec3,
+ @builtin(num_workgroups) num_workgroups: vec3
) {
- let id = get_id(global_id);
+ let id = get_id(global_id, num_workgroups);
if id >= u32(settings.agentCount) {
return;
diff --git a/src/pipelines/render/render.wgsl b/src/pipelines/render/render.wgsl
index 05f5c20..959e5f6 100644
--- a/src/pipelines/render/render.wgsl
+++ b/src/pipelines/render/render.wgsl
@@ -58,19 +58,24 @@ fn renderColor(traces: vec4, sources: vec4, pixel: vec2) -> vec4<
strengths.r * settings.colorA
+ strengths.g * settings.colorB
+ strengths.b * settings.colorC;
- let normalizedTraceColor =
- traceColor / max(settings.traceNormalizationFloor, strengths.r + strengths.g + strengths.b);
+ let normalizedTraceColor = normalizeColorIntensity(traceColor);
let brushColor =
sourceStrengths.r * settings.colorA
+ sourceStrengths.g * settings.colorB
+ sourceStrengths.b * settings.colorC;
+ let normalizedBrushColor = normalizeColorIntensity(brushColor);
let brushStrength = max(max(sourceStrengths.r, sourceStrengths.g), sourceStrengths.b);
- let color = max(
- normalizedTraceColor,
- brushColor * (settings.brushColorBase + brushStrength * settings.brushColorStrengthMultiplier)
+ let brushVisibility = clamp(
+ brushStrength * (
+ settings.brushColorBase +
+ brushStrength * settings.brushColorStrengthMultiplier
+ ),
+ 0,
+ 1
);
+ let color = max(normalizedTraceColor, normalizedBrushColor);
- let strength = max(max(strengths.r, strengths.g), strengths.b);
+ let strength = max(max(max(strengths.r, strengths.g), strengths.b), brushVisibility);
return vec4(mix(background, clamp(color, vec3(0), vec3(1)), strength), 1);
}
@@ -78,6 +83,11 @@ fn clarity(strength: f32) -> f32 {
return pow(clamp(strength, 0, 1), settings.clarity);
}
+fn normalizeColorIntensity(color: vec3) -> vec3 {
+ let brightestChannel = max(max(color.r, color.g), color.b);
+ return color / max(settings.traceNormalizationFloor, brightestChannel);
+}
+
fn getTexturedBackground(pixel: vec2) -> vec3 {
let noiseSize = vec2(textureDimensions(noise, 0));
let noiseCoord = pixel % noiseSize;
diff --git a/src/pipelines/wgsl-uniform-layout.test.ts b/src/pipelines/wgsl-uniform-layout.test.ts
deleted file mode 100644
index 1ccc1a9..0000000
--- a/src/pipelines/wgsl-uniform-layout.test.ts
+++ /dev/null
@@ -1,233 +0,0 @@
-import { describe, expect, it } from 'vitest';
-
-import compactionShader from './agents/agent-generation/agent-compaction.wgsl?raw';
-import { AgentGenerationPipeline } from './agents/agent-generation/agent-generation-pipeline';
-import resizeShader from './agents/agent-generation/agent-resize.wgsl?raw';
-import { AgentPipeline } from './agents/agent-pipeline';
-import agentShader from './agents/agent.wgsl?raw';
-import { BrushPipeline } from './brush/brush-pipeline';
-import brushShader from './brush/brush.wgsl?raw';
-import { CommonState } from './common-state/common-state';
-import diffusionShader from './diffusion/diffuse.wgsl?raw';
-import { DiffusionPipeline } from './diffusion/diffusion-pipeline';
-import { EraserAgentPipeline } from './eraser/eraser-agent-pipeline';
-import eraserAgentShader from './eraser/eraser-agent.wgsl?raw';
-import { EraserTexturePipeline } from './eraser/eraser-texture-pipeline';
-import eraserTextureShader from './eraser/eraser-texture.wgsl?raw';
-import { RenderPipeline } from './render/render-pipeline';
-import renderShader from './render/render.wgsl?raw';
-
-const wgslFloatCountsByType: Record = {
- f32: 1,
- u32: 1,
- 'vec2': 2,
- 'vec3': 3,
- 'vec4': 4,
-};
-
-const stripComments = (source: string): string =>
- source.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
-
-const getStructFields = (source: string, structName: string) => {
- const match = new RegExp(
- `struct ${structName}\\s*\\{(?[\\s\\S]*?)\\n\\s*\\}`
- ).exec(stripComments(source));
- if (!match?.groups?.body) {
- throw new Error(`${structName} struct was not found`);
- }
-
- return match.groups.body
- .split('\n')
- .map((line) => line.trim().replace(/,$/, ''))
- .filter(Boolean)
- .map((line) => {
- const fieldMatch = /^(?\w+):\s*(?[^,]+)$/.exec(line);
- if (!fieldMatch?.groups) {
- throw new Error(`Unsupported WGSL struct field syntax: ${line}`);
- }
-
- return {
- name: fieldMatch.groups.name,
- type: fieldMatch.groups.type,
- };
- });
-};
-
-const countUniformScalars = (source: string, structName: string): number =>
- getStructFields(source, structName).reduce((sum, field) => {
- const count = wgslFloatCountsByType[field.type];
- if (!count) {
- throw new Error(`Unsupported WGSL uniform field type: ${field.type}`);
- }
-
- return sum + count;
- }, 0);
-
-const getUniformCount = (pipeline: unknown): number =>
- (pipeline as { UNIFORM_COUNT: number }).UNIFORM_COUNT;
-
-const expectStructUniformLayout = ({
- pipeline,
- source,
- structName,
- fieldNames,
-}: {
- pipeline: unknown;
- source: string;
- structName: string;
- fieldNames: Array;
-}) => {
- const fields = getStructFields(source, structName);
-
- expect(fields.map((field) => field.name)).toEqual(fieldNames);
- expect(countUniformScalars(source, structName)).toBe(getUniformCount(pipeline));
-};
-
-describe('WGSL uniform layout contracts', () => {
- it('keeps shared common-state uniforms aligned with WGSL', () => {
- expectStructUniformLayout({
- pipeline: CommonState,
- source: CommonState.shaderCode,
- structName: 'State',
- fieldNames: ['size', 'time', 'padding0'],
- });
- });
-
- it('keeps render and simulation uniforms aligned with WGSL', () => {
- expectStructUniformLayout({
- pipeline: AgentPipeline,
- source: agentShader,
- structName: 'Settings',
- fieldNames: [
- 'moveRate',
- 'turnRate',
- 'sensorAngleSin',
- 'sensorAngleCos',
- 'sensorOffset',
- 'turnWhenLost',
- 'individualTrailWeight',
- 'agentCount',
- 'introProgress',
- 'color1ToColor1',
- 'color1ToColor2',
- 'color1ToColor3',
- 'color2ToColor1',
- 'color2ToColor2',
- 'color2ToColor3',
- 'color3ToColor1',
- 'color3ToColor2',
- 'color3ToColor3',
- 'sourceAttractionWeight',
- 'sourceSlowMoveRate',
- 'sourceTrailWeightMultiplier',
- 'forwardRotationScale',
- 'introNearDistanceInner',
- 'introNearDistanceMin',
- 'introNearSensorOffsetMultiplier',
- 'introTargetAngleBlend',
- 'introProgressCutoff',
- 'introTurnRateMultiplier',
- 'introRandomTurnMultiplier',
- 'introFarMoveMultiplier',
- 'introNearMoveMultiplier',
- 'introStepStopDistance',
- 'randomTimeScale',
- ],
- });
- expectStructUniformLayout({
- pipeline: BrushPipeline,
- source: brushShader,
- structName: 'Settings',
- fieldNames: [
- 'brushSize',
- 'brushSizeVariation',
- 'brushFeatherRatio',
- 'brushMinimumFeather',
- 'brushValue',
- 'brushCoarseNoiseScale',
- 'brushGrainNoiseScale',
- 'brushGrainNoiseOffsetX',
- 'brushGrainNoiseOffsetY',
- 'brushDiscardThreshold',
- 'brushGrainMinStrength',
- 'brushGrainMaxStrength',
- 'brushGeometryRadius',
- ],
- });
- expectStructUniformLayout({
- pipeline: DiffusionPipeline,
- source: diffusionShader,
- structName: 'Settings',
- fieldNames: [
- 'inverseDiffusionRateTrails',
- 'decayRateTrails',
- 'inverseDiffusionRateBrush',
- 'decayRateBrush',
- 'diffusionNeighborDivisor',
- 'brushDecayAlphaOffset',
- 'padding0',
- 'padding1',
- ],
- });
- expectStructUniformLayout({
- pipeline: RenderPipeline,
- source: renderShader,
- structName: 'Settings',
- fieldNames: [
- 'colorA',
- 'backgroundColorPadding0',
- 'colorB',
- 'backgroundColorPadding1',
- 'colorC',
- 'backgroundColorPadding2',
- 'backgroundColor',
- 'clarity',
- 'traceNormalizationFloor',
- 'brushColorBase',
- 'brushColorStrengthMultiplier',
- 'backgroundGrainStrength',
- ],
- });
- });
-
- it('keeps eraser uniforms aligned with WGSL', () => {
- expectStructUniformLayout({
- pipeline: EraserAgentPipeline,
- source: eraserAgentShader,
- structName: 'Settings',
- fieldNames: ['agentCount', 'eraserMaskAlphaThreshold', 'padding1', 'padding2'],
- });
- expectStructUniformLayout({
- pipeline: EraserTexturePipeline,
- source: eraserTextureShader,
- structName: 'Settings',
- fieldNames: [
- 'eraserRadiusSquared',
- 'lineDistanceEpsilon',
- 'clearRed',
- 'clearGreen',
- 'clearBlue',
- 'clearAlpha',
- 'padding0',
- 'padding1',
- ],
- });
- });
-
- it('keeps agent-generation uniforms large enough for every generation shader', () => {
- const generationUniformCounts = [
- countUniformScalars(resizeShader, 'ResizeSettings'),
- countUniformScalars(compactionShader, 'Settings'),
- ];
-
- expect(Math.max(...generationUniformCounts)).toBe(
- getUniformCount(AgentGenerationPipeline)
- );
- });
-
- it('guards invalid high agent color indexes instead of treating them as color 3', () => {
- expect(agentShader).toContain('colorIndex < 0.0 || colorIndex >= 2.5');
- expect(agentShader).toContain('if colorIndex < 2.5');
- expect(agentShader).toContain('return vec3(0.0, 0.0, 0.0);');
- });
-});
diff --git a/src/settings.ts b/src/settings.ts
index 3ed6e48..dfd469b 100644
--- a/src/settings.ts
+++ b/src/settings.ts
@@ -25,6 +25,8 @@ export const applyVibeSettings = (vibe: VibePreset) => {
Object.assign(settings, {
...buildSettings(vibe),
eraserSize: settings.eraserSize,
+ internalRenderAreaMegapixels: settings.internalRenderAreaMegapixels,
+ maxAgentCount: settings.maxAgentCount,
mirrorSegmentCount: settings.mirrorSegmentCount,
selectedColorIndex: Math.min(settings.selectedColorIndex, vibe.colors.length - 1),
});
diff --git a/src/style/_app-shell.scss b/src/style/_app-shell.scss
index b415f56..bf17e11 100644
--- a/src/style/_app-shell.scss
+++ b/src/style/_app-shell.scss
@@ -1,3 +1,7 @@
+html > body.pre-drawing .dev-stats-overlay {
+ display: none;
+}
+
html > body {
width: 100%;
height: 100vh;
@@ -47,6 +51,28 @@ html > body {
}
}
+ > .dev-stats-overlay {
+ position: absolute;
+ top: max(8px, env(safe-area-inset-top));
+ left: max(8px, env(safe-area-inset-left));
+ z-index: 6;
+ padding: 6px 8px;
+ border: 1px solid rgb(255 255 255 / 18%);
+ border-radius: 6px;
+ background: rgb(0 0 0 / 62%);
+ color: rgb(255 255 255 / 92%);
+ font:
+ 600 12px/1.35 ui-monospace,
+ SFMono-Regular,
+ Menlo,
+ Consolas,
+ monospace;
+ white-space: pre;
+ pointer-events: none;
+ user-select: none;
+ box-shadow: 0 8px 24px rgb(0 0 0 / 28%);
+ }
+
> .errors-container {
position: absolute;
top: 0;
diff --git a/src/style/_loading.scss b/src/style/_loading.scss
index 2f0d6f9..ac83f11 100644
--- a/src/style/_loading.scss
+++ b/src/style/_loading.scss
@@ -4,77 +4,139 @@
left: 50%;
display: flex;
flex-direction: column;
- gap: 18px;
+ gap: 22px;
align-items: center;
justify-content: center;
z-index: 3;
- width: min(78vw, 320px);
+ width: min(86vw, 380px);
transform: translate(-50%, -50%);
opacity: 0;
pointer-events: none;
transition: opacity var(--transition-time-long);
- > .loading-dots {
+ > .splash {
display: flex;
- gap: 14px;
+ flex-direction: column;
+ gap: 16px;
align-items: center;
- justify-content: center;
+ pointer-events: auto;
- > .loading-dot {
- width: 14px;
- height: 14px;
- border-radius: 50%;
- background: rgb(255 255 255 / 92%);
+ &[hidden] {
+ display: none;
+ }
+
+ > .splash-title {
+ margin: 0;
+ color: rgb(255 255 255 / 96%);
+ font-size: clamp(28px, 6vw, 42px);
+ font-weight: 700;
+ line-height: 1.1;
+ text-align: center;
+ letter-spacing: 0.01em;
+ text-shadow:
+ 0 2px 18px rgb(0 0 0 / 60%),
+ 0 0 32px rgb(255 255 255 / 10%);
+ }
+
+ > .splash-description {
+ margin: 0;
+ max-width: 28ch;
+ color: rgb(255 255 255 / 80%);
+ font-size: 15px;
+ font-weight: 400;
+ line-height: 1.45;
+ text-align: center;
+ text-shadow: 0 1px 12px rgb(0 0 0 / 60%);
+ }
+
+ > .start-button {
+ margin-top: 8px;
+ padding: 14px 40px;
+ border: 1px solid rgb(255 255 255 / 38%);
+ border-radius: 999px;
+ background: rgb(255 255 255 / 8%);
+ color: rgb(255 255 255 / 96%);
+ font-size: 16px;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ cursor: pointer;
+ backdrop-filter: blur(6px);
box-shadow:
- 0 0 18px rgb(255 255 255 / 38%),
- 0 0 4px rgb(255 255 255 / 60%);
- transform: scale(0.5);
- opacity: 0.4;
- animation: loading-bloom 1.4s ease-in-out infinite;
+ 0 0 24px rgb(255 255 255 / 14%),
+ 0 1px 6px rgb(0 0 0 / 28%);
+ transition:
+ opacity var(--transition-time),
+ transform var(--transition-time),
+ background var(--transition-time);
- &:nth-child(2) {
- animation-delay: 0.18s;
+ &[disabled] {
+ opacity: 0.5;
+ cursor: progress;
}
- &:nth-child(3) {
- animation-delay: 0.36s;
+ &:not([disabled]):hover,
+ &:not([disabled]):focus-visible {
+ background: rgb(255 255 255 / 16%);
+ transform: scale(1.04);
+ outline: none;
+ }
+
+ &:not([disabled]):active {
+ transform: scale(0.98);
}
}
}
- > .loading-status {
- color: rgb(255 255 255 / 88%);
- font-size: 16px;
- font-weight: 400;
- line-height: 1.25;
- text-align: center;
- text-shadow: 0 1px 12px rgb(0 0 0 / 60%);
- letter-spacing: 0.01em;
- min-height: 1.25em;
- }
-
- > .loading-progress {
- --loading-progress: 0%;
-
- position: relative;
+ > .loading-bar {
+ display: flex;
+ flex-direction: column;
+ gap: 18px;
+ align-items: center;
width: 100%;
- height: 3px;
- overflow: hidden;
- border-radius: 999px;
- background: rgb(255 255 255 / 14%);
- box-shadow: 0 1px 6px rgb(0 0 0 / 28%);
- &::before {
- content: '';
- position: absolute;
- top: 0;
- left: 0;
- bottom: 0;
- width: var(--loading-progress);
- border-radius: inherit;
- background: linear-gradient(90deg, rgb(255 255 255 / 72%), rgb(255 255 255 / 96%));
- box-shadow: 0 0 12px rgb(255 255 255 / 38%);
- transition: width var(--transition-time-long) ease-out;
+ &[hidden] {
+ display: none;
+ }
+
+ > .loading-status {
+ color: rgb(255 255 255 / 88%);
+ font-size: 16px;
+ font-weight: 400;
+ line-height: 1.25;
+ text-align: center;
+ text-shadow: 0 1px 12px rgb(0 0 0 / 60%);
+ letter-spacing: 0.01em;
+ min-height: 1.25em;
+ }
+
+ > .loading-progress {
+ --loading-progress: 0%;
+
+ position: relative;
+ width: 100%;
+ height: 3px;
+ overflow: hidden;
+ border-radius: 999px;
+ background: rgb(255 255 255 / 14%);
+ box-shadow: 0 1px 6px rgb(0 0 0 / 28%);
+
+ &::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ width: var(--loading-progress);
+ border-radius: inherit;
+ background: linear-gradient(
+ 90deg,
+ rgb(255 255 255 / 72%),
+ rgb(255 255 255 / 96%)
+ );
+ box-shadow: 0 0 12px rgb(255 255 255 / 38%);
+ transition: width var(--transition-time-long) ease-out;
+ }
}
}
}
@@ -94,22 +156,3 @@ html > body.is-loading {
}
}
-@keyframes loading-bloom {
- 0%,
- 100% {
- transform: scale(0.5);
- opacity: 0.35;
- }
-
- 50% {
- transform: scale(1);
- opacity: 1;
- }
-}
-
-@media (prefers-reduced-motion: reduce) {
- .loading-indicator > .loading-dots > .loading-dot {
- transform: scale(0.85);
- opacity: 0.85;
- }
-}
diff --git a/src/style/_motion.scss b/src/style/_motion.scss
index 9a885e8..20d1e66 100644
--- a/src/style/_motion.scss
+++ b/src/style/_motion.scss
@@ -7,7 +7,7 @@
transform: none;
}
- > .toolbar-shell > nav.buttons > button:hover::after {
+ > nav.buttons > button:hover::after {
transform: none;
}
}
diff --git a/src/style/_toolbar.scss b/src/style/_toolbar.scss
index b2ca779..1e3615d 100644
--- a/src/style/_toolbar.scss
+++ b/src/style/_toolbar.scss
@@ -39,14 +39,19 @@ html > body > aside.control-dock > .toolbar-row {
--toolbar-background-opacity: 0%;
--toolbar-background-strength: 0;
- display: flex;
+ display: grid;
+ grid-template-areas:
+ 'previous controls next'
+ 'previous buttons next';
+ grid-template-columns: auto minmax(0, 1fr) auto;
align-items: stretch;
justify-content: center;
- width: fit-content;
+ width: 100%;
max-width: 100%;
margin: 0 auto;
padding-inline: clamp(8px, 1.4vw, 14px);
- gap: clamp(6px, 1.8vw, 14px);
+ column-gap: 0;
+ row-gap: clamp(6px, 1.8vw, 14px);
border-radius: 12px;
color: rgb(245 250 244 / 92%);
background-color: rgb(5 8 13 / var(--toolbar-background-opacity));
@@ -91,16 +96,15 @@ html > body > aside.control-dock > .toolbar-row {
}
> .toolbar-shell {
+ grid-area: controls;
display: grid;
- grid-template-areas:
- 'swatches'
- 'nav';
+ grid-template-areas: 'swatches';
grid-template-columns: minmax(0, 1fr);
align-items: center;
justify-content: center;
- gap: 8px;
+ justify-self: center;
+ width: min(100%, max-content);
min-width: 0;
- min-height: 86px;
padding: 8px 9px;
}
@@ -150,13 +154,22 @@ html > body > aside.control-dock > .toolbar-row {
}
}
- > .toolbar-shell > nav.buttons {
- grid-area: nav;
+ > .previous-vibe {
+ grid-area: previous;
+ }
+
+ > .next-vibe {
+ grid-area: next;
+ }
+
+ > nav.buttons {
+ grid-area: buttons;
display: flex;
- flex-wrap: wrap;
+ flex-wrap: nowrap;
align-items: center;
- justify-content: center;
+ justify-content: space-between;
gap: 4px;
+ width: 100%;
min-width: 0;
padding-top: 7px;
border-top: 1px solid rgb(255 255 255 / 12%);
@@ -166,6 +179,9 @@ html > body > aside.control-dock > .toolbar-row {
position: relative;
width: 44px;
height: 44px;
+ flex: 1 1 44px;
+ max-width: 54px;
+ min-width: 0;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
@@ -235,7 +251,8 @@ html > body > aside.control-dock > .toolbar-row {
align-items: center;
width: 132px;
height: 44px;
- flex: 0 0 132px;
+ flex: 2 1 132px;
+ max-width: 150px;
min-width: 0;
padding-right: 10px;
border: 1px solid transparent;
@@ -252,18 +269,13 @@ html > body > aside.control-dock > .toolbar-row {
background: rgb(255 255 255 / 7%);
}
- &:focus-within {
- outline: 2px solid white;
- outline-offset: 2px;
- }
-
> button {
flex: 0 0 42px;
min-width: 42px;
border-color: transparent;
&:focus-visible {
- outline: none;
+ outline-offset: -4px;
}
}
@@ -296,7 +308,9 @@ html > body > aside.control-dock > .toolbar-row {
touch-action: pan-y;
&:focus-visible {
- outline: none;
+ border-radius: 8px;
+ outline: 2px solid white;
+ outline-offset: -4px;
}
&::-webkit-slider-runnable-track {
@@ -558,11 +572,12 @@ html > body > aside.control-dock > .toolbar-row {
@include on-small-screen {
width: 100%;
- padding-inline: 6px;
- gap: 6px;
+ padding-inline: 4px;
+ column-gap: 0;
+ row-gap: 4px;
> .vibe-button {
- width: 44px;
+ width: 36px;
min-height: 44px;
&::before {
@@ -572,46 +587,52 @@ html > body > aside.control-dock > .toolbar-row {
}
> .toolbar-shell {
- flex: 1 1 auto;
- padding: 4px 8px;
+ padding: 4px;
+ }
- > nav.buttons {
- gap: 2px;
- padding-top: 3px;
+ > nav.buttons {
+ gap: clamp(1px, 0.55vw, 2px);
+ padding-top: 3px;
- > button {
- height: 38px;
- min-height: 38px;
+ > button {
+ width: auto;
+ height: 38px;
+ flex: 1 1 clamp(28px, 8vw, 38px);
+ max-width: 38px;
+ min-height: 38px;
- &::after {
- width: 17px;
- height: 17px;
- }
- }
-
- > .audio-control {
- width: 118px;
- height: 38px;
- flex-basis: 118px;
- padding-right: 9px;
-
- > button {
- flex-basis: 38px;
- min-width: 38px;
- }
-
- > .volume-control {
- height: 38px;
- }
- }
-
- > .export-status {
- flex-basis: 100%;
- max-width: 100%;
- text-align: center;
+ &::after {
+ width: 17px;
+ height: 17px;
}
}
+ > .audio-control {
+ width: auto;
+ height: 38px;
+ flex: 2 1 clamp(58px, 18vw, 118px);
+ max-width: 118px;
+ padding-right: clamp(4px, 1.8vw, 9px);
+
+ > button {
+ width: auto;
+ flex: 1 1 clamp(28px, 8vw, 38px);
+ min-width: 0;
+ }
+
+ > .volume-control {
+ height: 38px;
+ }
+ }
+
+ > .export-status {
+ flex-basis: 0;
+ max-width: 0;
+ text-align: center;
+ }
+ }
+
+ > .toolbar-shell {
> .garden-controls {
padding: 2px 4px;
diff --git a/src/utils/browser-storage.ts b/src/utils/browser-storage.ts
index b02db6c..834744b 100644
--- a/src/utils/browser-storage.ts
+++ b/src/utils/browser-storage.ts
@@ -11,7 +11,10 @@ export const writeBrowserStorage = (key: string, value: string): void => {
if (typeof localStorage !== 'undefined') {
localStorage.setItem(key, value);
}
- } catch {
- // Storage can be unavailable in private browsing or embedded contexts.
+ } catch (error) {
+ console.warn(
+ 'Storage can be unavailable in private browsing or embedded contexts.',
+ error,
+ );
}
};
diff --git a/src/utils/clamp.ts b/src/utils/clamp.ts
deleted file mode 100644
index 45da555..0000000
--- a/src/utils/clamp.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export const clamp = (value: number, min: number, max: number): number =>
- Math.min(max, Math.max(min, value));
-
-export const clamp01 = (value: number): number => Math.min(1, Math.max(0, value));
diff --git a/src/utils/delta-time-calculator.ts b/src/utils/delta-time-calculator.ts
index 9603bea..200a7f6 100644
--- a/src/utils/delta-time-calculator.ts
+++ b/src/utils/delta-time-calculator.ts
@@ -1,5 +1,5 @@
import { appConfig } from '../config';
-import { clamp } from './clamp';
+import { clamp } from './math';
export class DeltaTimeCalculator {
private previousTime: DOMHighResTimeStamp | null = null;
diff --git a/src/utils/graphics/cached-buffer-write.test.ts b/src/utils/graphics/cached-buffer-write.test.ts
deleted file mode 100644
index 3fb15b3..0000000
--- a/src/utils/graphics/cached-buffer-write.test.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-import { describe, expect, it, vi } from 'vitest';
-
-import {
- createCachedFloat32BufferWrite,
- writeFloat32BufferIfChanged,
-} from './cached-buffer-write';
-
-const createGpuWriteStub = () => {
- const writeBuffer = vi.fn();
- const device = {
- queue: {
- writeBuffer,
- },
- } as unknown as GPUDevice;
-
- return { device, writeBuffer };
-};
-
-describe('cached float32 buffer writes', () => {
- it('writes the first value set and skips unchanged values', () => {
- const { device, writeBuffer } = createGpuWriteStub();
- const buffer = {} as GPUBuffer;
- const cache = createCachedFloat32BufferWrite(3);
- const values = new Float32Array([1, 2, 3]);
-
- expect(writeFloat32BufferIfChanged(device, buffer, values, cache)).toBe(true);
- expect(writeBuffer).toHaveBeenCalledTimes(1);
- expect(writeBuffer).toHaveBeenLastCalledWith(buffer, 0, values);
-
- expect(writeFloat32BufferIfChanged(device, buffer, values, cache)).toBe(false);
- expect(writeBuffer).toHaveBeenCalledTimes(1);
- });
-
- it('writes again when any float changes', () => {
- const { device, writeBuffer } = createGpuWriteStub();
- const buffer = {} as GPUBuffer;
- const cache = createCachedFloat32BufferWrite(3);
-
- expect(
- writeFloat32BufferIfChanged(device, buffer, new Float32Array([1, 2, 3]), cache)
- ).toBe(true);
- expect(
- writeFloat32BufferIfChanged(device, buffer, new Float32Array([1, 2, 4]), cache)
- ).toBe(true);
- expect(writeBuffer).toHaveBeenCalledTimes(2);
- });
-
- it('rejects cache length mismatches before writing', () => {
- const { device, writeBuffer } = createGpuWriteStub();
- const buffer = {} as GPUBuffer;
- const cache = createCachedFloat32BufferWrite(2);
-
- expect(() =>
- writeFloat32BufferIfChanged(device, buffer, new Float32Array([1]), cache)
- ).toThrow('Cached buffer write length mismatch');
- expect(writeBuffer).not.toHaveBeenCalled();
- });
-});
diff --git a/src/utils/graphics/get-workgroup-count.test.ts b/src/utils/graphics/get-workgroup-count.test.ts
deleted file mode 100644
index e539f2d..0000000
--- a/src/utils/graphics/get-workgroup-count.test.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { describe, expect, it } from 'vitest';
-
-import { getWorkgroupCount } from './get-workgroup-count';
-
-describe('getWorkgroupCount', () => {
- it('returns at least one workgroup for positive invocation counts', () => {
- expect(getWorkgroupCount(1, 64)).toBe(1);
- expect(getWorkgroupCount(65, 64)).toBe(2);
- });
-
- it('rejects zero and non-finite dispatch inputs', () => {
- expect(() => getWorkgroupCount(0, 64)).toThrow(/positive finite/);
- expect(() => getWorkgroupCount(-1, 64)).toThrow(/positive finite/);
- expect(() => getWorkgroupCount(Number.POSITIVE_INFINITY, 64)).toThrow(
- /positive finite/
- );
- expect(() => getWorkgroupCount(1, 0)).toThrow(/positive finite/);
- });
-});
diff --git a/src/utils/graphics/initialize-gpu.test.ts b/src/utils/graphics/initialize-gpu.test.ts
deleted file mode 100644
index cdabd2b..0000000
--- a/src/utils/graphics/initialize-gpu.test.ts
+++ /dev/null
@@ -1,253 +0,0 @@
-import { afterEach, describe, expect, it, vi } from 'vitest';
-
-import { ErrorCode, ErrorHandler, RuntimeError, Severity } from '../error-handler';
-import { initializeGpu } from './initialize-gpu';
-
-const gpuLimits = {
- maxBufferSize: 256 * 1024 * 1024,
- maxComputeWorkgroupsPerDimension: 65_535,
- maxStorageBufferBindingSize: 128 * 1024 * 1024,
-} as GPUSupportedLimits;
-
-const observedErrors: Array<{
- code?: string;
- message: string;
- severity: Severity;
-}> = [];
-
-ErrorHandler.addOnErrorListener((error) => {
- observedErrors.push(error);
-});
-
-const defer = () => {
- let resolve!: (value: T) => void;
- const promise = new Promise((nextResolve) => {
- resolve = nextResolve;
- });
-
- return { promise, resolve };
-};
-
-const stubBrowser = ({
- gpu,
- isSecureContext = true,
-}: {
- gpu?: GPU;
- isSecureContext?: boolean;
-}) => {
- vi.stubGlobal('window', { isSecureContext });
- vi.stubGlobal('navigator', { gpu });
-};
-
-const createDevice = (
- lost: Promise = new Promise(() => {})
-) => {
- const listeners = new Map();
- const device = {
- addEventListener: vi.fn((type: string, listener: EventListener) => {
- listeners.set(type, listener);
- }),
- lost,
- } as unknown as GPUDevice;
-
- return { device, listeners };
-};
-
-const createAdapter = ({
- requestDevice = vi.fn(),
-}: {
- requestDevice?: ReturnType;
-} = {}) =>
- ({
- features: new Set(),
- info: {
- architecture: 'test',
- description: 'unit-test adapter',
- device: 'test-device',
- isFallbackAdapter: false,
- subgroupMaxSize: 0,
- subgroupMinSize: 0,
- vendor: 'test-vendor',
- },
- limits: gpuLimits,
- requestDevice,
- }) as unknown as GPUAdapter;
-
-const captureInitializeGpuError = async (): Promise => {
- try {
- await initializeGpu();
- } catch (error) {
- expect(error).toBeInstanceOf(RuntimeError);
- return error as RuntimeError;
- }
-
- throw new Error('Expected initializeGpu to reject.');
-};
-
-describe('initializeGpu', () => {
- afterEach(() => {
- observedErrors.length = 0;
- vi.restoreAllMocks();
- vi.unstubAllGlobals();
- });
-
- it('rejects insecure contexts before touching WebGPU', async () => {
- stubBrowser({ isSecureContext: false });
-
- const error = await captureInitializeGpuError();
-
- expect(error.code).toBe(ErrorCode.WEBGPU_INSECURE_CONTEXT);
- expect(error.message).toContain('WebGPU requires a secure context');
- });
-
- it('rejects browsers without navigator.gpu', async () => {
- stubBrowser({});
-
- const error = await captureInitializeGpuError();
-
- expect(error.code).toBe(ErrorCode.WEBGPU_UNSUPPORTED);
- expect(error.message).toContain('Fleeting Garden needs WebGPU');
- expect(error.details).toMatchObject({
- hasNavigatorGpu: false,
- isSecureContext: true,
- });
- });
-
- it('wraps adapter request exceptions with adapter diagnostics', async () => {
- const requestAdapter = vi.fn(async () => {
- throw new Error('adapter request failed');
- });
- stubBrowser({ gpu: { requestAdapter } as unknown as GPU });
-
- const error = await captureInitializeGpuError();
-
- expect(requestAdapter).toHaveBeenCalledOnce();
- expect(requestAdapter).toHaveBeenCalledWith({ powerPreference: 'high-performance' });
- expect(error.code).toBe(ErrorCode.WEBGPU_ADAPTER_UNAVAILABLE);
- expect(error.message).toBe('Could not request a WebGPU adapter.');
- expect(error.details).toMatchObject({
- causeMessage: 'adapter request failed',
- powerPreference: 'high-performance',
- });
- });
-
- it('tries the default adapter before reporting adapter unavailability', async () => {
- const requestAdapter = vi.fn(async () => null);
- stubBrowser({ gpu: { requestAdapter } as unknown as GPU });
-
- const error = await captureInitializeGpuError();
-
- expect(requestAdapter).toHaveBeenNthCalledWith(1, {
- powerPreference: 'high-performance',
- });
- expect(requestAdapter).toHaveBeenNthCalledWith(2, undefined);
- expect(error.code).toBe(ErrorCode.WEBGPU_ADAPTER_UNAVAILABLE);
- expect(error.message).toContain('could not provide a compatible GPU adapter');
- });
-
- it('requests the device with the adapter limits needed by the pipelines', async () => {
- const { device } = createDevice();
- const requestDevice = vi.fn(async () => device);
- const adapter = createAdapter({ requestDevice });
- stubBrowser({
- gpu: { requestAdapter: vi.fn(async () => adapter) } as unknown as GPU,
- });
-
- await expect(initializeGpu()).resolves.toBe(device);
-
- expect(requestDevice).toHaveBeenCalledWith({
- requiredLimits: {
- maxBufferSize: gpuLimits.maxBufferSize,
- maxComputeWorkgroupsPerDimension: gpuLimits.maxComputeWorkgroupsPerDimension,
- maxStorageBufferBindingSize: gpuLimits.maxStorageBufferBindingSize,
- },
- });
- expect(device.addEventListener).toHaveBeenCalledWith(
- 'uncapturederror',
- expect.any(Function)
- );
- });
-
- it('wraps device request failures with required limit details', async () => {
- const requestDevice = vi.fn(async () => {
- throw new Error('device request failed');
- });
- const adapter = createAdapter({ requestDevice });
- stubBrowser({
- gpu: { requestAdapter: vi.fn(async () => adapter) } as unknown as GPU,
- });
-
- const error = await captureInitializeGpuError();
-
- expect(error.code).toBe(ErrorCode.WEBGPU_DEVICE_UNAVAILABLE);
- expect(error.message).toBe('Could not create a WebGPU device for this adapter.');
- expect(error.details).toMatchObject({
- causeMessage: 'device request failed',
- requiredLimits: {
- maxBufferSize: gpuLimits.maxBufferSize,
- maxComputeWorkgroupsPerDimension: gpuLimits.maxComputeWorkgroupsPerDimension,
- maxStorageBufferBindingSize: gpuLimits.maxStorageBufferBindingSize,
- },
- });
- });
-
- it('routes uncaptured GPU errors through the runtime error handler', async () => {
- const { device, listeners } = createDevice();
- const adapter = createAdapter({ requestDevice: vi.fn(async () => device) });
- stubBrowser({
- gpu: { requestAdapter: vi.fn(async () => adapter) } as unknown as GPU,
- });
-
- await initializeGpu();
- listeners.get('uncapturederror')?.({
- error: new Error('uncaptured GPU validation failure'),
- } as unknown as GPUUncapturedErrorEvent);
-
- expect(observedErrors.at(-1)).toMatchObject({
- code: ErrorCode.WEBGPU_UNCAPTURED_ERROR,
- message: 'uncaptured GPU validation failure',
- severity: Severity.ERROR,
- });
- });
-
- it('reports unexpected device loss but ignores intentional destruction', async () => {
- const unexpectedLoss = defer();
- const { device } = createDevice(unexpectedLoss.promise);
- const adapter = createAdapter({ requestDevice: vi.fn(async () => device) });
- stubBrowser({
- gpu: { requestAdapter: vi.fn(async () => adapter) } as unknown as GPU,
- });
-
- await initializeGpu();
- unexpectedLoss.resolve({
- message: 'device lost during rendering',
- reason: 'unknown',
- } as GPUDeviceLostInfo);
- await Promise.resolve();
-
- expect(observedErrors.at(-1)).toMatchObject({
- code: ErrorCode.WEBGPU_DEVICE_LOST,
- message: 'device lost during rendering',
- severity: Severity.ERROR,
- });
-
- observedErrors.length = 0;
- const destroyedLoss = defer();
- const { device: destroyedDevice } = createDevice(destroyedLoss.promise);
- const destroyedAdapter = createAdapter({
- requestDevice: vi.fn(async () => destroyedDevice),
- });
- stubBrowser({
- gpu: { requestAdapter: vi.fn(async () => destroyedAdapter) } as unknown as GPU,
- });
-
- await initializeGpu();
- destroyedLoss.resolve({
- message: 'device destroyed intentionally',
- reason: 'destroyed',
- } as GPUDeviceLostInfo);
- await Promise.resolve();
-
- expect(observedErrors).toEqual([]);
- });
-});
diff --git a/src/utils/math.test.ts b/src/utils/math.test.ts
deleted file mode 100644
index 3e0f367..0000000
--- a/src/utils/math.test.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import { describe, expect, it } from 'vitest';
-
-import { clamp, clamp01 } from './clamp';
-
-describe('clamp', () => {
- it('returns value when within bounds', () => {
- expect(clamp(5, 0, 10)).toBe(5);
- });
- it('clamps below to lower bound', () => {
- expect(clamp(-3, 0, 10)).toBe(0);
- });
- it('clamps above to upper bound', () => {
- expect(clamp(42, 0, 10)).toBe(10);
- });
-});
-
-describe('clamp01', () => {
- it('passes through values in [0, 1]', () => {
- expect(clamp01(0.25)).toBe(0.25);
- });
- it('clamps negatives to 0', () => {
- expect(clamp01(-1)).toBe(0);
- });
- it('clamps above 1 to 1', () => {
- expect(clamp01(2)).toBe(1);
- });
-});
diff --git a/src/vibes.test.ts b/src/vibes.test.ts
deleted file mode 100644
index 6a6cde5..0000000
--- a/src/vibes.test.ts
+++ /dev/null
@@ -1,134 +0,0 @@
-import { afterEach, describe, expect, it, vi } from 'vitest';
-
-import { gardenAudioConfig } from './audio/garden-audio-config';
-import { getInitialVibe, hexToRgb, VIBE_PRESETS, VibeId } from './vibes';
-
-const originalLocalStorage = globalThis.localStorage;
-
-const setBrowserVibeState = ({
- storedVibeId = null,
-}: {
- storedVibeId?: string | null;
-}) => {
- Object.defineProperty(globalThis, 'localStorage', {
- configurable: true,
- value: {
- getItem: vi.fn((key: string) =>
- key === 'fleeting-garden:vibe' ? storedVibeId : null
- ),
- },
- });
-};
-
-describe('vibe selection', () => {
- afterEach(() => {
- Object.defineProperty(globalThis, 'localStorage', {
- configurable: true,
- value: originalLocalStorage,
- });
- });
-
- it('uses a valid stored vibe id', () => {
- setBrowserVibeState({ storedVibeId: VibeId.SunlitMoss });
-
- expect(getInitialVibe().id).toBe(VibeId.SunlitMoss);
- });
-
- it('falls back to the default preset for an unknown stored vibe id', () => {
- setBrowserVibeState({ storedVibeId: 'unknown' });
-
- expect(getInitialVibe()).toBe(VIBE_PRESETS[0]);
- });
-});
-
-describe('vibe and audio config contract', () => {
- it('keeps preset ids unique and URL-safe', () => {
- const vibeIds = VIBE_PRESETS.map((vibe) => vibe.id);
-
- expect(new Set(vibeIds).size).toBe(vibeIds.length);
- expect(vibeIds.every((id) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id))).toBe(true);
- });
-
- it('keeps each vibe palette and audio profile complete', () => {
- VIBE_PRESETS.forEach((vibe) => {
- expect(vibe.colors).toHaveLength(3);
- vibe.colors.forEach((color) => {
- expect(color).toMatch(/^#[0-9a-f]{6}$/i);
- hexToRgb(color).forEach((channel) => {
- expect(channel).toBeGreaterThanOrEqual(0);
- expect(channel).toBeLessThanOrEqual(1);
- });
- });
-
- const profile = vibe.audio;
- expect(Number.isFinite(profile.rootMidi)).toBe(true);
- expect(profile.scale.length).toBeGreaterThan(0);
- expect(profile.scale.every((degree) => Number.isFinite(degree))).toBe(true);
- expect(profile.brightness).toBeGreaterThan(0);
- expect(profile.delayTimeMultiplier).toBeGreaterThan(0);
- expect(profile.progression.length).toBeGreaterThan(0);
- profile.progression.forEach((chord) => {
- expect(Number.isFinite(chord.rootOffset)).toBe(true);
- expect(['major', 'minor']).toContain(chord.quality);
- });
- });
- });
-
- it('keeps audio style voices aligned with the rotating style pools', () => {
- expect(gardenAudioConfig.styleVoices).toHaveLength(
- gardenAudioConfig.generativePiano.stylePools.length
- );
- gardenAudioConfig.styleVoices.forEach((voice) => {
- expect(Number.isFinite(voice.scaleDegreeOffset)).toBe(true);
- expect(voice.velocityMultiplier).toBeGreaterThan(0);
- expect(Math.abs(voice.panOffset)).toBeLessThanOrEqual(1);
- });
- });
-
- it('keeps audio timing, graph, and density settings bounded', () => {
- const { delay, generativePiano, graph, piano, rhythm } = gardenAudioConfig;
-
- expect(rhythm.bpm).toBeGreaterThan(0);
- expect(rhythm.stepsPerBeat).toBeGreaterThan(0);
- expect(rhythm.stepsPerBar).toBeGreaterThanOrEqual(rhythm.stepsPerBeat);
- expect(rhythm.lookaheadSeconds).toBeGreaterThanOrEqual(piano.scheduleAheadSeconds);
-
- expect(delay.feedbackMin).toBeLessThanOrEqual(delay.feedback);
- expect(delay.feedback).toBeLessThanOrEqual(delay.feedbackMax);
- expect(delay.feedbackHighPassHz).toBeLessThan(delay.feedbackLowPassHz);
- expect(delay.returnLowPassHz).toBeGreaterThan(delay.feedbackHighPassHz);
-
- generativePiano.stylePools.forEach((register) => {
- expect(register.midiMin).toBeLessThan(register.preferredMidi);
- expect(register.preferredMidi).toBeLessThan(register.midiMax);
- });
- generativePiano.padRegisters.forEach((register) => {
- expect(register.midiMin).toBeLessThan(register.preferredMidi);
- expect(register.preferredMidi).toBeLessThan(register.midiMax);
- });
-
- expect(generativePiano.brushStreamIdleIntervalBeats).toBeGreaterThanOrEqual(
- generativePiano.brushStreamActiveIntervalBeats
- );
- expect(generativePiano.brushStreamActiveIntervalBeats).toBeGreaterThanOrEqual(
- generativePiano.brushStreamIntenseIntervalBeats
- );
- expect(generativePiano.brushStreamIntenseIntervalBeats).toBeGreaterThanOrEqual(
- generativePiano.brushStreamManicIntervalBeats
- );
- expect(generativePiano.maxBrushPhraseLayers).toBeLessThanOrEqual(3);
- expect(generativePiano.maxBrushStreamNotesPerBar).toBeLessThanOrEqual(
- rhythm.stepsPerBar
- );
-
- Object.values(graph.pianoBusGains).forEach((gain) => {
- expect(gain).toBeGreaterThan(0);
- expect(gain).toBeLessThanOrEqual(1.2);
- });
- });
-
- it('falls back to finite RGB channels for malformed hex colors', () => {
- expect(hexToRgb('not-a-color')).toEqual([0, 0, 0]);
- expect(hexToRgb('#abcdzz')).toEqual([0, 0, 0]);
- });
-});
diff --git a/src/vibes.ts b/src/vibes.ts
index 42db194..47a3314 100644
--- a/src/vibes.ts
+++ b/src/vibes.ts
@@ -7,19 +7,6 @@ export type { VibePreset } from './config';
export const VIBE_PRESETS: Array = appConfig.vibes.presets;
const VIBE_IDS = new Set(VIBE_PRESETS.map((vibe) => vibe.id));
-const HEX_COLOR_PATTERN =
- /^#?(?[0-9a-f]{2})(?[0-9a-f]{2})(?[0-9a-f]{2})$/i;
-
-export const hexToRgb = (hex: string): [number, number, number] => {
- const match = HEX_COLOR_PATTERN.exec(hex);
- if (!match?.groups) {
- return [0, 0, 0];
- }
-
- const { red, green, blue } = match.groups;
- return [parseInt(red, 16) / 255, parseInt(green, 16) / 255, parseInt(blue, 16) / 255];
-};
-
export const isVibeId = (value: unknown): value is VibeId =>
typeof value === 'string' && VIBE_IDS.has(value as VibeId);
diff --git a/tsconfig.playwright.json b/tsconfig.playwright.json
index 139efca..fca3dfe 100644
--- a/tsconfig.playwright.json
+++ b/tsconfig.playwright.json
@@ -1,7 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
- "types": ["node", "@playwright/test"]
+ "types": ["node"]
},
"include": ["playwright.config.ts", "e2e/**/*.ts"]
}
diff --git a/vite.config.ts b/vite.config.ts
index 795e630..cfe208b 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,13 +1,15 @@
import basicSsl from '@vitejs/plugin-basic-ssl';
import browserslist from 'browserslist';
+import browserslistToEsbuild from 'browserslist-to-esbuild';
import { browserslistToTargets } from 'lightningcss';
import { viteSingleFile } from 'vite-plugin-singlefile';
import { defineConfig } from 'vitest/config';
const cssTargets = browserslistToTargets(browserslist());
+const esbuildTargets = browserslistToEsbuild();
export default defineConfig(({ command }) => ({
- base: command === 'build' ? './' : '/',
+ base: './',
plugins: [
viteSingleFile({ useRecommendedBuildConfig: false }),
...(command === 'serve' ? [basicSsl()] : []),
@@ -19,7 +21,7 @@ export default defineConfig(({ command }) => ({
},
},
build: {
- target: 'es2022',
+ target: esbuildTargets,
cssCodeSplit: false,
cssMinify: 'lightningcss',
assetsInlineLimit: Number.MAX_SAFE_INTEGER,