Small improvements

This commit is contained in:
Andras Schmelczer 2026-05-24 09:34:46 +01:00
parent a7c04b2bd8
commit 05c8a39bd8
19 changed files with 119 additions and 95 deletions

2
.gitignore vendored
View file

@ -1,3 +1,5 @@
node_modules
dist
test-results
.DS_Store
*.log

View file

@ -6,5 +6,5 @@
"endOfLine": "lf",
"plugins": ["@ianvs/prettier-plugin-sort-imports"],
"importOrder": ["<BUILTIN_MODULES>", "<THIRD_PARTY_MODULES>", "", "^[./]"],
"importOrderTypeScriptVersion": "5.0.0"
"importOrderTypeScriptVersion": "5.6.0"
}

View file

@ -71,7 +71,7 @@ test('starts the WebGPU garden and accepts drawing input', async ({ page }) => {
const startButton = page.getByRole('button', { name: 'Start' });
await expect(startButton).toBeVisible();
await expect(startButton).toBeEnabled({ timeout: 30_000 });
await startButton.click();
await page.keyboard.press('Enter');
await expect(page.locator('body')).not.toHaveClass(/is-loading/, {
timeout: 30_000,
});
@ -146,7 +146,7 @@ test('syncs the selected vibe with the URI', async ({ page }) => {
await expect(page).toHaveURL(/vibe=bone-archive/);
await page.locator('.next-vibe').click();
await page.getByRole('button', { name: 'Next vibe' }).click();
await expect(page).toHaveURL(/vibe=pelagic-caustics/);
await page.goBack();
@ -160,8 +160,8 @@ test('keeps audio focus outlines scoped to the active control', async ({ page })
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');
const soundButton = page.getByRole('button', { name: /audio/i });
const volumeSlider = page.getByRole('slider', { name: 'Master volume' });
await soundButton.click();
await expect(audioControl).toHaveCSS('outline-style', 'none');
@ -201,7 +201,7 @@ test('keeps the config overlay scrollable and dismissible on mobile', async ({
timeout: 30_000,
});
const settingsButton = page.locator('button.settings');
const settingsButton = page.getByRole('button', { name: 'Show config overlay' });
await settingsButton.click();
const pane = page.locator('.config-pane');

View file

@ -22,7 +22,7 @@
"generate-icons": "pwa-assets-generator"
},
"engines": {
"node": ">=20"
"node": ">=22"
},
"repository": {
"type": "git",

View file

@ -1,6 +1,7 @@
import { DEFAULT_AUDIO_VOLUME } from '../consts';
import type { PianoNoteRole } from './garden-audio-types';
const DEFAULT_AUDIO_VOLUME = 0.5;
type GardenAudioChordQuality = 'major' | 'minor' | 'sus2' | 'sus4';
export interface GardenAudioChord {

View file

@ -23,8 +23,8 @@ const graphTuning = {
eventBusGain: 1,
noiseMax: 1,
noiseMin: -1,
latencyHint: 'interactive' as AudioContextLatencyCategory,
outputFilterType: 'highpass' as BiquadFilterType,
latencyHint: 'interactive',
outputFilterType: 'highpass',
compressor: {
thresholdDb: -18,
kneeDb: 18,
@ -32,7 +32,7 @@ const graphTuning = {
attackSeconds: 0.018,
releaseSeconds: 0.18,
},
};
} as const;
const delayFilterTuning = {
feedbackHighPassHz: 180,
feedbackLowPassHz: 5200,

View file

@ -17,6 +17,8 @@ import { PIANO_SCHEDULE_AHEAD_SECONDS } from './piano-sampler';
const GENERATIVE_LOOKAHEAD_SECONDS = 0.3;
const GENERATIVE_START_DELAY_SECONDS = 0.02;
const TEXTURE_ONSET_EXPRESSION = 0.15;
const SUPPORT_ONSET_EXPRESSION = 0.4;
const chordVoicings: Record<
GardenAudioChord['quality'],
@ -1087,6 +1089,9 @@ export class GenerativePianoEngine {
}
private shouldPlaySupport(expression: number, barIndex: number): boolean {
if (expression < SUPPORT_ONSET_EXPRESSION) {
return false;
}
if (expression >= generativePianoTuning.supportNote.expressionThreshold) {
return true;
}
@ -1098,19 +1103,17 @@ export class GenerativePianoEngine {
}
private shouldPlayTexture(expression: number, barIndex: number): boolean {
if (expression < TEXTURE_ONSET_EXPRESSION) {
return false;
}
if (expression >= generativePianoTuning.textureNote.mediumExpressionThreshold) {
return barIndex % generativePianoTuning.textureNote.intenseSpacing === 0;
}
const spacing =
expression < generativePianoTuning.textureNote.idleExpressionThreshold
? generativePianoTuning.idleTextureBarSpacing
: expression < generativePianoTuning.textureNote.mediumExpressionThreshold
? generativePianoTuning.mediumTextureBarSpacing
: generativePianoTuning.textureNote.intenseSpacing;
return (
barIndex % spacing ===
(spacing === generativePianoTuning.textureNote.intenseSpacing
? 0
: generativePianoTuning.textureNote.idlePhase)
);
: generativePianoTuning.mediumTextureBarSpacing;
return barIndex % spacing === generativePianoTuning.textureNote.idlePhase % spacing;
}
private getSupportOffsets(

View file

@ -8,8 +8,8 @@ const noiseBurstTuning = {
offsetRandomSeconds: 0.4,
scheduleAheadSeconds: 0.002,
silentGain: 0.0001,
filterType: 'bandpass' as BiquadFilterType,
};
filterType: 'bandpass',
} as const;
export class NoiseBurstPlayer {
public constructor(private readonly graph: GardenAudioGraph) {}

View file

@ -14,7 +14,7 @@ interface ActivePianoVoice {
}
const pianoSamplerTuning = {
filterType: 'lowpass' as BiquadFilterType,
filterType: 'lowpass',
filterQ: 0.7,
minDurationSeconds: 0.08,
minFadeSeconds: 0.08,
@ -22,7 +22,7 @@ const pianoSamplerTuning = {
tailStopExtraSeconds: 0.05,
voiceStealFadeSeconds: 0.025,
voiceStealStopSeconds: 0.05,
};
} as const;
export class PianoSampler {
private samples: Array<LoadedPianoSample> = [];
@ -178,7 +178,11 @@ export class PianoSampler {
this.trimActiveVoices(scheduledStart);
while (this.activeVoices.length >= this.config.piano.maxVoices) {
this.stopVoice(this.activeVoices.shift() as ActivePianoVoice, scheduledStart);
const oldest = this.activeVoices.shift();
if (!oldest) {
break;
}
this.stopVoice(oldest, scheduledStart);
}
filter.type = pianoSamplerTuning.filterType;
@ -220,11 +224,8 @@ export class PianoSampler {
);
}
private computeNoteGain(velocity: number, scale = 1): number {
return Math.max(
pianoSamplerTuning.minGain,
this.config.piano.gain * velocity * scale
);
private computeNoteGain(velocity: number): number {
return Math.max(pianoSamplerTuning.minGain, this.config.piano.gain * velocity);
}
private findNearestSample(midi: number): LoadedPianoSample | null {

View file

@ -3,7 +3,8 @@ 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';
import { APP_STORAGE_KEYS, DEFAULT_AUDIO_VOLUME } from './consts';
const DEFAULT_AUDIO_VOLUME = 0.5;
export {
normalizeNumberControlValue,
@ -114,9 +115,9 @@ export const appConfig = {
},
},
storage: {
audioMutedKey: APP_STORAGE_KEYS.audioMuted,
audioVolumeKey: APP_STORAGE_KEYS.audioVolume,
vibeKey: APP_STORAGE_KEYS.vibe,
audioMutedKey: 'fleeting-garden:audio-muted',
audioVolumeKey: 'fleeting-garden:audio-volume',
vibeKey: 'fleeting-garden:vibe',
},
toolbar: {
eraser: {

View file

@ -11,6 +11,19 @@ const FINAL_VIBE_NAMES = [
'Chrome Pollen',
];
const BLENDED_BRUSH_SIZE_MIN = 17;
const BLENDED_CLARITY_MAX = 0.56;
const SOFT_PARTICLE_BRUSH_SIZE_MAX = 5;
const SOFT_PARTICLE_CLARITY_MAX = 0.2;
// Performance guardrails — bumping any of these is an explicit perf trade-off.
const MAX_SPAWN_PER_PIXEL = 0.38;
const MAX_BRUSH_SIZE = 36;
const HIGH_DENSITY_SPAWN_THRESHOLD = 0.28;
const HIGH_DENSITY_DECAY_LIMIT = 940;
const HIGH_DENSITY_BRUSH_SIZE_LIMIT = 14;
const HIGH_DENSITY_TRAIL_WEIGHT_LIMIT = 0.055;
describe('vibePresets', () => {
it('keeps the classic preset set distinct', () => {
expect(vibePresets.map((preset) => preset.name)).toEqual(FINAL_VIBE_NAMES);
@ -22,12 +35,16 @@ describe('vibePresets', () => {
it('includes both blended and visibly particulate styles', () => {
const blendedNames = vibePresets
.filter(
(preset) => preset.settings.brushSize >= 17 && preset.settings.clarity <= 0.56
(preset) =>
preset.settings.brushSize >= BLENDED_BRUSH_SIZE_MIN &&
preset.settings.clarity <= BLENDED_CLARITY_MAX
)
.map((preset) => preset.name);
const softParticleNames = vibePresets
.filter(
(preset) => preset.settings.brushSize <= 5 && preset.settings.clarity <= 0.2
(preset) =>
preset.settings.brushSize <= SOFT_PARTICLE_BRUSH_SIZE_MAX &&
preset.settings.clarity <= SOFT_PARTICLE_CLARITY_MAX
)
.map((preset) => preset.name);
@ -40,17 +57,17 @@ describe('vibePresets', () => {
const { name, settings } = preset;
const presetViolations: Array<string> = [];
if (settings.spawnPerPixel > 0.38) {
presetViolations.push(`${name} density exceeds 0.38`);
if (settings.spawnPerPixel > MAX_SPAWN_PER_PIXEL) {
presetViolations.push(`${name} density exceeds ${MAX_SPAWN_PER_PIXEL}`);
}
if (settings.brushSize > 36) {
presetViolations.push(`${name} brush size exceeds 36`);
if (settings.brushSize > MAX_BRUSH_SIZE) {
presetViolations.push(`${name} brush size exceeds ${MAX_BRUSH_SIZE}`);
}
if (
settings.spawnPerPixel >= 0.28 &&
(settings.decayRateTrails > 940 ||
settings.brushSize > 14 ||
settings.individualTrailWeight > 0.055)
settings.spawnPerPixel >= HIGH_DENSITY_SPAWN_THRESHOLD &&
(settings.decayRateTrails > HIGH_DENSITY_DECAY_LIMIT ||
settings.brushSize > HIGH_DENSITY_BRUSH_SIZE_LIMIT ||
settings.individualTrailWeight > HIGH_DENSITY_TRAIL_WEIGHT_LIMIT)
) {
presetViolations.push(`${name} combines high density with too much persistence`);
}

View file

@ -155,20 +155,20 @@ export const vibePresets: Array<VibePreset> = [
moveSpeed: 270,
sensorOffsetAngle: 36,
sensorOffsetDistance: 51,
spawnPerPixel: 0.13999999999999999,
strokeAngleJitterRadians: 0.44999999999999996,
spawnPerPixel: 0.14,
strokeAngleJitterRadians: 0.45,
turnSpeed: 22,
turnWhenLost: 6.071532165918825e-17,
turnWhenLost: 0,
},
audio: {
...defaultGardenAudioVibeSettings,
idleIntensity: 0.12,
bpm: 60,
rampUpIntensity: 0.7000000000000001,
rampUpIntensity: 0.7,
rampUpTime: 0.14,
noteLength: 0.86,
notePitchOffset: -2,
brightness: 0.8400000000000001,
brightness: 0.84,
scale: musicScales.lydian,
progression: musicProgressions.aurora,
},
@ -188,23 +188,23 @@ export const vibePresets: Array<VibePreset> = [
brushSize: 9.75,
clarity: 0.437,
decayRateTrails: 915,
forwardRotationScale: 2.0816681711721685e-17,
forwardRotationScale: 0,
individualTrailWeight: 0.1,
moveSpeed: 216,
sensorOffsetAngle: 24,
sensorOffsetDistance: 17,
spawnPerPixel: 0.24,
strokeAngleJitterRadians: 0.16999999999999993,
strokeAngleJitterRadians: 0.17,
turnSpeed: 33,
turnWhenLost: 0.42000000000000004,
turnWhenLost: 0.42,
},
audio: {
...defaultGardenAudioVibeSettings,
idleIntensity: 0.55,
bpm: 72,
rampUpIntensity: 1.42,
rampUpTime: 0.07000000000000002,
noteLength: 0.7000000000000001,
rampUpTime: 0.07,
noteLength: 0.7,
notePitchOffset: 0,
brightness: 0.94,
scale: musicScales.naturalMinor,
@ -227,21 +227,21 @@ export const vibePresets: Array<VibePreset> = [
clarity: 0.74,
decayRateTrails: 962,
forwardRotationScale: 0.3,
individualTrailWeight: 0.052000000000000005,
individualTrailWeight: 0.052,
moveSpeed: 72,
sensorOffsetAngle: 42,
sensorOffsetDistance: 54,
spawnPerPixel: 0.15999999999999998,
strokeAngleJitterRadians: 3.1399999999999997,
spawnPerPixel: 0.16,
strokeAngleJitterRadians: 3.14,
turnSpeed: 44,
turnWhenLost: 0.9200000000000002,
turnWhenLost: 0.92,
},
audio: {
...defaultGardenAudioVibeSettings,
idleIntensity: 0.13,
bpm: 68,
rampUpIntensity: 1.46,
rampUpTime: 0.10000000000000002,
rampUpTime: 0.1,
noteLength: 0.6,
notePitchOffset: -3,
brightness: 1.21,
@ -307,7 +307,7 @@ export const vibePresets: Array<VibePreset> = [
moveSpeed: 28,
sensorOffsetAngle: 34,
sensorOffsetDistance: 66,
spawnPerPixel: 0.05499999999999998,
spawnPerPixel: 0.055,
strokeAngleJitterRadians: 0,
turnSpeed: 30,
turnWhenLost: 1.52,
@ -317,7 +317,7 @@ export const vibePresets: Array<VibePreset> = [
idleIntensity: 0.33,
bpm: 127,
rampUpIntensity: 0.66,
rampUpTime: 0.03000000000000001,
rampUpTime: 0.03,
noteLength: 0.92,
notePitchOffset: 10,
brightness: 1.42,
@ -341,7 +341,7 @@ export const vibePresets: Array<VibePreset> = [
clarity: 0.1,
decayRateTrails: 922,
forwardRotationScale: 0.5,
individualTrailWeight: 0.026000000000000002,
individualTrailWeight: 0.026,
moveSpeed: 86,
sensorOffsetAngle: 46,
sensorOffsetDistance: 14,
@ -355,7 +355,7 @@ export const vibePresets: Array<VibePreset> = [
idleIntensity: 0.11,
bpm: 150,
rampUpIntensity: 2,
rampUpTime: 0.06000000000000001,
rampUpTime: 0.06,
noteLength: 1.8,
notePitchOffset: -12,
brightness: 0.5,

View file

@ -1,10 +0,0 @@
export const ENABLED_FLAG_VALUE = '1';
export const DISABLED_FLAG_VALUE = '0';
export const DEFAULT_AUDIO_VOLUME = 0.5;
export const APP_STORAGE_KEYS = {
audioMuted: 'fleeting-garden:audio-muted',
audioVolume: 'fleeting-garden:audio-volume',
vibe: 'fleeting-garden:vibe',
} as const;

View file

@ -1,19 +1,18 @@
import { settings } from '../settings';
export class FramePerformance {
private readonly adaptiveRefreshTargetFps = 60;
private readonly initialFps = this.adaptiveRefreshTargetFps;
public smoothedFps = this.initialFps;
const ADAPTIVE_REFRESH_TARGET_FPS = 60;
const ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND = 200_000;
const FRAME_GAP_RESET_SECONDS = 1;
const FPS_HEADROOM = 0.9;
const FPS_SMOOTHING_NEW = 0.06;
const FPS_SMOOTHING_RETAIN = 1 - FPS_SMOOTHING_NEW;
export class FramePerformance {
public smoothedFps = ADAPTIVE_REFRESH_TARGET_FPS;
public measuredFps = 0;
public frameDeltaSeconds = 0;
public measuredFrameTimeMs = 0;
private readonly adaptiveCapDecreaseAgentsPerSecond = 200_000;
private readonly frameGapResetSeconds = 1;
private readonly fpsHeadroom = 0.9;
private readonly fpsSmoothingNew = 0.06;
private readonly fpsSmoothingRetain = 1 - this.fpsSmoothingNew;
private previousFrameTime: DOMHighResTimeStamp | null = null;
public get adaptiveCapInitial(): number {
@ -25,13 +24,13 @@ export class FramePerformance {
}
public get hasAdaptiveCapHeadroom(): boolean {
return this.smoothedFps >= this.adaptiveRefreshTargetFps * this.fpsHeadroom;
return this.smoothedFps >= ADAPTIVE_REFRESH_TARGET_FPS * FPS_HEADROOM;
}
public get adaptiveCapDecreaseAgents(): number {
return Math.max(
1,
Math.ceil(this.adaptiveCapDecreaseAgentsPerSecond * this.frameDeltaSeconds)
Math.ceil(ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND * this.frameDeltaSeconds)
);
}
@ -51,11 +50,10 @@ export class FramePerformance {
this.frameDeltaSeconds = deltaSeconds;
this.measuredFrameTimeMs = deltaSeconds * 1000;
this.measuredFps = fps;
if (deltaSeconds > this.frameGapResetSeconds) {
if (deltaSeconds > FRAME_GAP_RESET_SECONDS) {
return;
}
this.smoothedFps =
this.smoothedFps * this.fpsSmoothingRetain + fps * this.fpsSmoothingNew;
this.smoothedFps = this.smoothedFps * FPS_SMOOTHING_RETAIN + fps * FPS_SMOOTHING_NEW;
}
}

View file

@ -14,8 +14,7 @@ export class SimulationTextures {
public trailMapA: ResizableTexture;
public trailMapB: ResizableTexture;
// Per-frame deposit accumulator: cleared each frame, written sparsely by
// agents, then read by diffuse alongside trailMapA. Replaces the previous
// full-resolution copyTrailMapAToB seed.
// agents, then read by diffuse alongside trailMapA.
public readonly depositMap: ResizableTexture;
public readonly eraserMask: ResizableTexture;
public sourceMapA: ResizableTexture;

View file

@ -1,5 +1,4 @@
import { appConfig } from '../config';
import { DISABLED_FLAG_VALUE, ENABLED_FLAG_VALUE } from '../consts';
import type GameLoop from '../game-loop/game-loop';
import { readBrowserStorage, writeBrowserStorage } from '../utils/browser-storage';
import { queryRequiredElement } from '../utils/dom';
@ -21,6 +20,9 @@ const readInitialAudioVolume = (): number => {
const formatStoredAudioVolume = (volume: number): string =>
clampAudioVolume(volume).toFixed(2);
const STORED_MUTED_TRUE = '1';
const STORED_MUTED_FALSE = '0';
interface AudioControlOptions {
getGame: () => GameLoop | null;
hasStarted: () => boolean;
@ -43,7 +45,7 @@ export class AudioControl {
private audioVolume = readInitialAudioVolume();
private isMutedState =
readBrowserStorage(appConfig.storage.audioMutedKey) === ENABLED_FLAG_VALUE ||
readBrowserStorage(appConfig.storage.audioMutedKey) === STORED_MUTED_TRUE ||
this.audioVolume <= 0;
public constructor(private readonly options: AudioControlOptions) {
@ -140,7 +142,7 @@ export class AudioControl {
private persist(): void {
writeBrowserStorage(
appConfig.storage.audioMutedKey,
this.isMutedState ? ENABLED_FLAG_VALUE : DISABLED_FLAG_VALUE
this.isMutedState ? STORED_MUTED_TRUE : STORED_MUTED_FALSE
);
writeBrowserStorage(
appConfig.storage.audioVolumeKey,

View file

@ -30,13 +30,24 @@ export class SplashScreen {
public awaitStart(onStart: () => void): Promise<void> {
this.startButton.disabled = false;
return new Promise<void>((resolve) => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Enter' || event.defaultPrevented) {
return;
}
event.preventDefault();
this.startButton.click();
};
const onClick = () => {
this.startButton.removeEventListener('click', onClick);
document.removeEventListener('keydown', onKeyDown);
onStart();
this.setVisible(this.splash, false);
resolve();
};
this.startButton.addEventListener('click', onClick);
document.addEventListener('keydown', onKeyDown);
});
}

View file

@ -1,7 +1,7 @@
:root {
--transition-time: 200ms;
--transition-time-long: 350ms;
--accent-color: rgb(255, 93, 162);
--accent-color: rgb(255 93 162);
--main-color: #aaa;
--normal-margin: 2rem;
--small-margin: 1rem;

View file

@ -22,7 +22,6 @@ export default defineConfig(({ command }) => ({
},
server: {
host: true,
hmr: false,
},
test: {
environment: 'node',