This commit is contained in:
Andras Schmelczer 2026-05-09 22:27:51 +01:00
parent b1acdff594
commit 4e92913925
8 changed files with 743 additions and 124 deletions

View file

@ -0,0 +1,254 @@
export type GardenAudioChordQuality = 'major' | 'minor';
export interface GardenAudioChord {
rootOffset: number;
quality: GardenAudioChordQuality;
}
export interface GardenAudioColorVoice {
scaleDegreeOffset: number;
octaveOffset: number;
velocityMultiplier: number;
panOffset: number;
}
export interface GardenAudioVibeProfile {
rootMidi: number;
scale: Array<number>;
brightness: number;
delayTimeMultiplier: number;
progression: Array<GardenAudioChord>;
}
export interface GardenAudioConfig {
enabled: boolean;
masterVolume: number;
fadeInSeconds: number;
updateRampSeconds: number;
highPassFrequencyHz: number;
fallbackVibeId: string;
startup: {
calmDurationSeconds: number;
initialTempoMultiplier: number;
initialEnergyMultiplier: number;
initialActivityCeiling: number;
initialTapIntervalMultiplier: number;
};
compressor: {
thresholdDb: number;
kneeDb: number;
ratio: number;
attackSeconds: number;
releaseSeconds: number;
};
delay: {
enabled: boolean;
timeSeconds: number;
feedback: number;
wetGain: number;
};
piano: {
maxVoices: number;
gain: number;
sustainSeconds: number;
sustainLevel: number;
releaseSeconds: number;
lowpassHz: number;
preloadOnStart: boolean;
};
input: {
pressureFallback: number;
};
rhythm: {
bpm: number;
stepsPerBeat: number;
stepsPerBar: number;
lookaheadSeconds: number;
swing: number;
minTailSeconds: number;
maxTailSeconds: number;
tailDistanceForMaxPixels: number;
tailDurationForMaxSeconds: number;
tailDecayPower: number;
minTapIntervalSeconds: number;
speedForFullEnergyPixelsPerSecond: number;
sparseActivity: number;
arpeggioActivity: number;
fullChordActivity: number;
bassActivity: number;
melodySteps: Array<number>;
chordSteps: Array<number>;
bassSteps: Array<number>;
melodyPattern: Array<number>;
};
eraser: {
enabled: boolean;
minIntervalSeconds: number;
noiseGain: number;
filterMinHz: number;
filterMaxHz: number;
};
colorVoices: [GardenAudioColorVoice, GardenAudioColorVoice, GardenAudioColorVoice];
vibes: Record<string, GardenAudioVibeProfile>;
}
const majorProgression: Array<GardenAudioChord> = [
{ rootOffset: 0, quality: 'major' },
{ rootOffset: 9, quality: 'minor' },
{ rootOffset: 5, quality: 'major' },
{ rootOffset: 7, quality: 'major' },
];
const minorProgression: Array<GardenAudioChord> = [
{ rootOffset: 0, quality: 'minor' },
{ rootOffset: 8, quality: 'major' },
{ rootOffset: 3, quality: 'major' },
{ rootOffset: 10, quality: 'major' },
];
const majorPentatonic = [0, 2, 4, 7, 9];
const minorPentatonic = [0, 3, 5, 7, 10];
export const gardenAudioConfig: GardenAudioConfig = {
enabled: true,
masterVolume: 0.32,
fadeInSeconds: 0.45,
updateRampSeconds: 0.08,
highPassFrequencyHz: 45,
fallbackVibeId: 'candy-rain',
startup: {
calmDurationSeconds: 6,
initialTempoMultiplier: 1.18,
initialEnergyMultiplier: 0.62,
initialActivityCeiling: 0.52,
initialTapIntervalMultiplier: 2.2,
},
compressor: {
thresholdDb: -18,
kneeDb: 18,
ratio: 2.4,
attackSeconds: 0.006,
releaseSeconds: 0.18,
},
delay: {
enabled: true,
timeSeconds: 0.42,
feedback: 0.12,
wetGain: 0.048,
},
piano: {
maxVoices: 32,
gain: 0.42,
sustainSeconds: 0.52,
sustainLevel: 0.34,
releaseSeconds: 0.16,
lowpassHz: 9000,
preloadOnStart: true,
},
input: {
pressureFallback: 0.48,
},
rhythm: {
bpm: 82,
stepsPerBeat: 4,
stepsPerBar: 16,
lookaheadSeconds: 0.14,
swing: 0.08,
minTailSeconds: 0.45,
maxTailSeconds: 7.2,
tailDistanceForMaxPixels: 1400,
tailDurationForMaxSeconds: 3.8,
tailDecayPower: 1.85,
minTapIntervalSeconds: 0.16,
speedForFullEnergyPixelsPerSecond: 1800,
sparseActivity: 0.1,
arpeggioActivity: 0.32,
fullChordActivity: 0.62,
bassActivity: 0.48,
melodySteps: [0, 3, 6, 10, 12, 14],
chordSteps: [0, 8],
bassSteps: [0],
melodyPattern: [0, 2, 4, 5, 4, 2, 1, 3],
},
eraser: {
enabled: true,
minIntervalSeconds: 0.12,
noiseGain: 0.028,
filterMinHz: 650,
filterMaxHz: 3600,
},
colorVoices: [
{
scaleDegreeOffset: 0,
octaveOffset: 0,
velocityMultiplier: 0.92,
panOffset: -0.14,
},
{
scaleDegreeOffset: 1,
octaveOffset: 0,
velocityMultiplier: 1,
panOffset: 0,
},
{
scaleDegreeOffset: 2,
octaveOffset: 1,
velocityMultiplier: 0.86,
panOffset: 0.14,
},
],
vibes: {
'candy-rain': {
rootMidi: 57,
scale: majorPentatonic,
brightness: 1.04,
delayTimeMultiplier: 0.92,
progression: majorProgression,
},
'sunlit-moss': {
rootMidi: 53,
scale: majorPentatonic,
brightness: 0.92,
delayTimeMultiplier: 1.08,
progression: [
{ rootOffset: 0, quality: 'major' },
{ rootOffset: 7, quality: 'major' },
{ rootOffset: 9, quality: 'minor' },
{ rootOffset: 5, quality: 'major' },
],
},
'coral-tide': {
rootMidi: 50,
scale: minorPentatonic,
brightness: 1,
delayTimeMultiplier: 1.12,
progression: minorProgression,
},
'moon-orchid': {
rootMidi: 49,
scale: minorPentatonic,
brightness: 0.9,
delayTimeMultiplier: 1.24,
progression: minorProgression,
},
'peach-neon': {
rootMidi: 56,
scale: majorPentatonic,
brightness: 1.08,
delayTimeMultiplier: 0.86,
progression: majorProgression,
},
'frost-bloom': {
rootMidi: 62,
scale: majorPentatonic,
brightness: 0.88,
delayTimeMultiplier: 1.32,
progression: [
{ rootOffset: 0, quality: 'major' },
{ rootOffset: 5, quality: 'major' },
{ rootOffset: 9, quality: 'minor' },
{ rootOffset: 7, quality: 'major' },
],
},
},
};

View file

@ -1,8 +1,10 @@
export interface GameLoopSettings {
maxAgentCountUpperLimit: number;
agentBudgetMax: number;
agentCount: number;
renderSpeed: number;
simulatedDelayMs: number;
selectedColorIndex: number;
spawnPerPixel: number;
startColorHue: number;
}

View file

@ -6,32 +6,29 @@ export const setUpSettingsPage = (
settingsPage: HTMLDivElement,
maxAgentCount: number
): Array<SettingsSlider<any>> => {
const sliders: Array<SettingsSlider<any>> = [
...(isProduction
? []
: [
new SettingsSlider(settings, 'renderSpeed', {
min: 1,
max: 10,
rounding: Math.round,
}),
]),
const params = new URLSearchParams(window.location.search);
const shouldShowAdvancedSettings = !isProduction && params.get('dev') !== '0';
new SettingsSlider(settings, 'agentCount', {
min: 1,
const sliders: Array<SettingsSlider<any>> = [
new SettingsSlider(settings, 'brushEffectDuration', {
min: 0.5,
max: 20,
unit: 's',
scaling: ValueScaling.Quadratic,
}),
...(shouldShowAdvancedSettings
? [
new SettingsSlider(settings, 'agentBudgetMax', {
min: 1_000,
max: maxAgentCount,
scaling: ValueScaling.Quadratic,
rounding: Math.round,
}),
new SettingsSlider(settings, 'currentGenerationAggression', {
min: -5,
max: 5,
}),
new SettingsSlider(settings, 'nextGenerationAggression', {
min: -5,
max: 5,
new SettingsSlider(settings, 'spawnPerPixel', {
min: 0.01,
max: 1,
}),
new SettingsSlider(settings, 'moveSpeed', {
@ -92,15 +89,32 @@ export const setUpSettingsPage = (
max: 100,
}),
new SettingsSlider(settings, 'anisotropy', {
min: 0,
max: 1,
}),
new SettingsSlider(settings, 'brushSize', {
min: 1,
max: 30,
max: 60,
}),
new SettingsSlider(settings, 'clarity', {
min: 0.00001,
max: 1,
}),
]
: []),
...(shouldShowAdvancedSettings
? [
new SettingsSlider(settings, 'renderSpeed', {
min: 1,
max: 10,
rounding: Math.round,
}),
]
: []),
];
const sliderContainerElement = document.createElement('div');

View file

@ -0,0 +1,28 @@
struct ResizeSettings {
scale: vec2<f32>,
agentCount: f32,
padding: f32,
};
@group(1) @binding(0) var<uniform> resizeSettings: ResizeSettings;
@compute @workgroup_size(64)
fn main(
@builtin(global_invocation_id) global_id: vec3<u32>,
@builtin(num_workgroups) workgroup_count: vec3<u32>
) {
let id = get_id(global_id, workgroup_count);
if id >= u32(resizeSettings.agentCount) {
return;
}
var agent = agents[id];
agent.position *= resizeSettings.scale;
if agent.targetPosition.x >= 0.0 && agent.targetPosition.y >= 0.0 {
agent.targetPosition *= resizeSettings.scale;
}
agents[id] = agent;
}

View file

@ -1,7 +1,10 @@
struct Agent {
position: vec2<f32>,
angle: f32,
generation: f32,
colorIndex: f32,
targetPosition: vec2<f32>,
targetAngle: f32,
introDelay: f32,
}
@group(1) @binding(1) var<storage, read_write> agents: array<Agent>;

View file

@ -1,39 +1,56 @@
struct Settings {
brushColor: vec3<f32>,
evenGenerationColor: vec3<f32>,
oddGenerationColor: vec3<f32>,
colorA: vec3<f32>,
backgroundColorPadding0: f32,
colorB: vec3<f32>,
backgroundColorPadding1: f32,
colorC: vec3<f32>,
backgroundColorPadding2: f32,
backgroundColor: vec3<f32>,
clarity: f32,
cameraCenter: vec2<f32>,
cameraZoom: f32,
padding0: f32,
};
@group(1) @binding(0) var<uniform> settings: Settings;
@group(1) @binding(1) var Sampler: sampler;
@group(1) @binding(2) var trailMap: texture_2d<f32>;
@group(1) @binding(3) var sourceMap: texture_2d<f32>;
@fragment
fn fragment(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
let traces = textureSample(trailMap, Sampler, uv);
let random = textureSample(noise, noiseSampler, uv);
let cameraUv = settings.cameraCenter / state.size;
let viewUv = (uv - vec2(0.5)) / settings.cameraZoom + cameraUv;
let traces = textureSample(trailMap, Sampler, viewUv);
let sources = textureSample(sourceMap, Sampler, viewUv);
let backgroundColor = vec3(0.9) + 0.075 * random.r;
let evenGenerationStrength = clarity(traces.r);
let oddGenerationStrength = clarity(traces.g);
let brushStrength = traces.a;
let color = max(
mix(
evenGenerationStrength * settings.evenGenerationColor,
oddGenerationStrength * settings.oddGenerationColor,
oddGenerationStrength / (evenGenerationStrength + oddGenerationStrength + 0.000001)
),
brushStrength * settings.brushColor
let traceStrengths = vec3(
clarity(traces.r),
clarity(traces.g),
clarity(traces.b)
);
let sourceStrengths = vec3(
clarity(sources.r),
clarity(sources.g),
clarity(sources.b)
);
let strengths = max(traceStrengths, sourceStrengths);
let traceColor =
strengths.r * settings.colorA
+ strengths.g * settings.colorB
+ strengths.b * settings.colorC;
let brushColor =
sourceStrengths.r * settings.colorA
+ sourceStrengths.g * settings.colorB
+ sourceStrengths.b * settings.colorC;
let brushStrength = clamp(max(max(sourceStrengths.r, sourceStrengths.g), sourceStrengths.b), 0, 1);
let color = max(traceColor, brushColor * (1.2 + brushStrength * 1.6));
let strength = max(evenGenerationStrength, max(oddGenerationStrength, brushStrength));
let strength = clamp(max(max(max(strengths.r, strengths.g), strengths.b), brushStrength), 0, 1);
return vec4(mix(backgroundColor, color, strength), 1);
return vec4(mix(settings.backgroundColor, clamp(color, vec3(0), vec3(1)), strength), 1);
}
fn clarity(strength: f32) -> f32 {
return pow(strength, settings.clarity);
return pow(clamp(strength, 0, 1), settings.clarity);
}

View file

@ -4,12 +4,175 @@ export enum Severity {
ERROR = 'error',
}
export enum ErrorCode {
UNKNOWN = 'unknown',
WEBGPU_INSECURE_CONTEXT = 'webgpu-insecure-context',
WEBGPU_UNSUPPORTED = 'webgpu-unsupported',
WEBGPU_ADAPTER_UNAVAILABLE = 'webgpu-adapter-unavailable',
WEBGPU_DEVICE_UNAVAILABLE = 'webgpu-device-unavailable',
WEBGPU_CONTEXT_UNAVAILABLE = 'webgpu-context-unavailable',
WEBGPU_CONTEXT_CONFIGURATION_FAILED = 'webgpu-context-configuration-failed',
WEBGPU_UNCAPTURED_ERROR = 'webgpu-uncaptured-error',
WEBGPU_DEVICE_LOST = 'webgpu-device-lost',
}
type ErrorMetadataPrimitive = string | number | boolean | null;
export type ErrorMetadataValue =
| ErrorMetadataPrimitive
| Array<ErrorMetadataValue>
| { [key: string]: ErrorMetadataValue };
export type ErrorMetadata = { [key: string]: ErrorMetadataValue };
export interface RuntimeErrorOptions {
cause?: unknown;
details?: Record<string, unknown>;
}
export class RuntimeError extends Error {
public readonly code: ErrorCode | string;
public readonly details: ErrorMetadata;
public constructor(
code: ErrorCode | string,
message: string,
{ cause, details = {} }: RuntimeErrorOptions = {}
) {
super(message);
this.name = 'RuntimeError';
this.code = code;
this.details = serializeMetadataValue(details) as ErrorMetadata;
if (cause !== undefined) {
this.cause = cause;
}
}
}
export interface ErrorHandlerError {
severity: Severity;
message: string;
code?: ErrorCode | string;
details?: ErrorMetadata;
}
export type ErrorMetadata = { [key: string]: any };
export interface ErrorHandlerErrorOptions {
code?: ErrorCode | string;
details?: Record<string, unknown>;
}
export interface ErrorHandlerExceptionOptions extends ErrorHandlerErrorOptions {
fallbackMessage?: string;
severity?: Severity;
}
const MAX_METADATA_DEPTH = 4;
const UNREADABLE_VALUE = '[Unreadable]';
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;
const safelyRead = (value: Record<string, unknown>, key: string): unknown => {
try {
return value[key];
} catch {
return undefined;
}
};
const isIterable = (value: unknown): value is Iterable<unknown> =>
isRecord(value) && Symbol.iterator in value;
const serializeMetadataValue = (value: unknown, depth = 0): ErrorMetadataValue => {
if (value === null) {
return null;
}
switch (typeof value) {
case 'string':
case 'boolean':
return value;
case 'number':
return Number.isFinite(value) ? value : value.toString();
case 'bigint':
return value.toString();
case 'undefined':
return null;
case 'symbol':
return value.toString();
case 'function':
return `[Function ${value.name || 'anonymous'}]`;
}
if (depth >= MAX_METADATA_DEPTH) {
return '[Object]';
}
if (Array.isArray(value)) {
return value.map((item) => serializeMetadataValue(item, depth + 1));
}
if (isIterable(value)) {
try {
return Array.from(value, (item) => serializeMetadataValue(item, depth + 1));
} catch {
return UNREADABLE_VALUE;
}
}
const serialized: ErrorMetadata = {};
const record = value as Record<string, unknown>;
for (const key of Object.keys(record)) {
try {
serialized[key] = serializeMetadataValue(record[key], depth + 1);
} catch {
serialized[key] = UNREADABLE_VALUE;
}
}
return serialized;
};
export const getErrorMessage = (
exception: unknown,
fallbackMessage = 'Unknown error'
): string => {
if (typeof exception === 'string') {
return exception || fallbackMessage;
}
if (exception instanceof Error) {
const record = exception as unknown as Record<string, unknown>;
const message = safelyRead(record, 'message');
if (typeof message === 'string' && message.length > 0) {
return message;
}
const name = safelyRead(record, 'name');
if (typeof name === 'string' && name.length > 0) {
return name;
}
return fallbackMessage;
}
if (isRecord(exception)) {
const message = safelyRead(exception, 'message');
if (typeof message === 'string' && message.length > 0) {
return message;
}
}
if (
typeof exception === 'number' ||
typeof exception === 'boolean' ||
typeof exception === 'bigint' ||
typeof exception === 'symbol'
) {
return exception.toString();
}
return fallbackMessage;
};
export class ErrorHandler {
private static readonly errors: Array<ErrorHandlerError> = [];
@ -18,23 +181,46 @@ export class ErrorHandler {
(error: ErrorHandlerError, metadata: ErrorMetadata) => void
> = [];
public static addException(exception: Error) {
ErrorHandler.addError(Severity.ERROR, exception.message);
public static addException(
exception: unknown,
{
severity = Severity.ERROR,
fallbackMessage,
code,
details,
}: ErrorHandlerExceptionOptions = {}
) {
const runtimeError = exception instanceof RuntimeError ? exception : undefined;
ErrorHandler.addError(severity, getErrorMessage(exception, fallbackMessage), {
code: code ?? runtimeError?.code,
details: {
...(runtimeError?.details ?? {}),
...(details ?? {}),
},
});
}
public static addError(severity: Severity, message: string) {
ErrorHandler.errors.push({ severity, message });
public static addError(
severity: Severity,
message: string,
{ code, details }: ErrorHandlerErrorOptions = {}
) {
const error: ErrorHandlerError = {
severity,
message,
...(code === undefined ? {} : { code }),
...(details === undefined
? {}
: { details: serializeMetadataValue(details) as ErrorMetadata }),
};
ErrorHandler.errors.push(error);
ErrorHandler.onErrorListeners.forEach((listener) =>
listener({ severity, message }, ErrorHandler.metadata)
listener(error, ErrorHandler.metadata)
);
}
public static addMetadata(key: string, value: any) {
const serialized: Record<string, any> = {};
for (const k in value) {
serialized[k] = value[k];
}
ErrorHandler.metadata[key] = serialized;
public static addMetadata(key: string, value: unknown) {
ErrorHandler.metadata[key] = serializeMetadataValue(value);
}
public static addOnErrorListener(

115
src/vibes.test.ts Normal file
View file

@ -0,0 +1,115 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { gardenAudioConfig } from './audio/garden-audio-config';
import { getInitialVibe, hexToRgb, VIBE_PRESETS } from './vibes';
const originalLocalStorage = globalThis.localStorage;
const originalWindow = globalThis.window;
const setBrowserVibeState = ({
search = '',
storedVibeId = null,
}: {
search?: string;
storedVibeId?: string | null;
}) => {
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
location: new URL(`https://garden.test/${search}`),
},
});
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: vi.fn((key: string) =>
key === 'fleeting-garden:vibe' ? storedVibeId : null
),
},
});
};
describe('vibe URL selection', () => {
afterEach(() => {
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: originalWindow,
});
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: originalLocalStorage,
});
});
it('uses a valid vibe id from the URL before local storage', () => {
setBrowserVibeState({
search: '?vibe=moon-orchid',
storedVibeId: 'candy-rain',
});
expect(getInitialVibe().id).toBe('moon-orchid');
});
it('uses a valid stored vibe id when the URL does not provide one', () => {
setBrowserVibeState({ storedVibeId: 'sunlit-moss' });
expect(getInitialVibe().id).toBe('sunlit-moss');
});
it('falls back to the default preset for an unknown URL vibe id', () => {
setBrowserVibeState({
search: '?vibe=unknown',
storedVibeId: 'sunlit-moss',
});
expect(getInitialVibe()).toBe(VIBE_PRESETS[0]);
});
});
describe('vibe and audio config contract', () => {
it('keeps preset ids unique, URL-safe, and covered by audio profiles', () => {
const vibeIds = VIBE_PRESETS.map((vibe) => vibe.id);
const audioIds = Object.keys(gardenAudioConfig.vibes);
expect(new Set(vibeIds).size).toBe(vibeIds.length);
expect(vibeIds.every((id) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id))).toBe(true);
expect(audioIds.slice().sort()).toEqual(vibeIds.slice().sort());
expect(vibeIds).toContain(gardenAudioConfig.fallbackVibeId);
});
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 = gardenAudioConfig.vibes[vibe.id];
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 color voices aligned with the three vibe palette slots', () => {
expect(gardenAudioConfig.colorVoices).toHaveLength(3);
gardenAudioConfig.colorVoices.forEach((voice) => {
expect(Number.isFinite(voice.scaleDegreeOffset)).toBe(true);
expect(Number.isFinite(voice.octaveOffset)).toBe(true);
expect(voice.velocityMultiplier).toBeGreaterThan(0);
expect(Math.abs(voice.panOffset)).toBeLessThanOrEqual(1);
});
});
});