LGTM
This commit is contained in:
parent
b1acdff594
commit
4e92913925
8 changed files with 743 additions and 124 deletions
254
src/audio/garden-audio-config.ts
Normal file
254
src/audio/garden-audio-config.ts
Normal 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' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
export interface GameLoopSettings {
|
export interface GameLoopSettings {
|
||||||
maxAgentCountUpperLimit: number;
|
agentBudgetMax: number;
|
||||||
agentCount: number;
|
agentCount: number;
|
||||||
renderSpeed: number;
|
renderSpeed: number;
|
||||||
simulatedDelayMs: number;
|
simulatedDelayMs: number;
|
||||||
|
selectedColorIndex: number;
|
||||||
|
spawnPerPixel: number;
|
||||||
|
|
||||||
startColorHue: number;
|
startColorHue: number;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,101 +6,115 @@ export const setUpSettingsPage = (
|
||||||
settingsPage: HTMLDivElement,
|
settingsPage: HTMLDivElement,
|
||||||
maxAgentCount: number
|
maxAgentCount: number
|
||||||
): Array<SettingsSlider<any>> => {
|
): Array<SettingsSlider<any>> => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const shouldShowAdvancedSettings = !isProduction && params.get('dev') !== '0';
|
||||||
|
|
||||||
const sliders: Array<SettingsSlider<any>> = [
|
const sliders: Array<SettingsSlider<any>> = [
|
||||||
...(isProduction
|
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, 'spawnPerPixel', {
|
||||||
|
min: 0.01,
|
||||||
|
max: 1,
|
||||||
|
}),
|
||||||
|
|
||||||
|
new SettingsSlider(settings, 'moveSpeed', {
|
||||||
|
min: 10,
|
||||||
|
max: 500,
|
||||||
|
scaling: ValueScaling.Quadratic,
|
||||||
|
rounding: Math.round,
|
||||||
|
}),
|
||||||
|
|
||||||
|
new SettingsSlider(settings, 'turnSpeed', {
|
||||||
|
min: 1,
|
||||||
|
max: 200,
|
||||||
|
scaling: ValueScaling.Quadratic,
|
||||||
|
rounding: Math.round,
|
||||||
|
}),
|
||||||
|
|
||||||
|
new SettingsSlider(settings, 'sensorOffsetAngle', {
|
||||||
|
min: 0,
|
||||||
|
max: 90,
|
||||||
|
step: 1,
|
||||||
|
}),
|
||||||
|
|
||||||
|
new SettingsSlider(settings, 'sensorOffsetDistance', {
|
||||||
|
min: 0,
|
||||||
|
max: 200,
|
||||||
|
scaling: ValueScaling.Quadratic,
|
||||||
|
rounding: Math.round,
|
||||||
|
}),
|
||||||
|
|
||||||
|
new SettingsSlider(settings, 'turnWhenLost', {
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
}),
|
||||||
|
|
||||||
|
new SettingsSlider(settings, 'individualTrailWeight', {
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
}),
|
||||||
|
|
||||||
|
new SettingsSlider(settings, 'diffusionRateTrails', {
|
||||||
|
min: 0,
|
||||||
|
max: 2,
|
||||||
|
}),
|
||||||
|
|
||||||
|
new SettingsSlider(settings, 'decayRateTrails', {
|
||||||
|
min: 0.1,
|
||||||
|
max: 5000,
|
||||||
|
scaling: ValueScaling.Quadratic,
|
||||||
|
}),
|
||||||
|
|
||||||
|
new SettingsSlider(settings, 'diffusionRateBrush', {
|
||||||
|
min: 0.001,
|
||||||
|
max: 1,
|
||||||
|
}),
|
||||||
|
|
||||||
|
new SettingsSlider(settings, 'decayRateBrush', {
|
||||||
|
min: 0.1,
|
||||||
|
max: 100,
|
||||||
|
}),
|
||||||
|
|
||||||
|
new SettingsSlider(settings, 'anisotropy', {
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
}),
|
||||||
|
|
||||||
|
new SettingsSlider(settings, 'brushSize', {
|
||||||
|
min: 1,
|
||||||
|
max: 60,
|
||||||
|
}),
|
||||||
|
|
||||||
|
new SettingsSlider(settings, 'clarity', {
|
||||||
|
min: 0.00001,
|
||||||
|
max: 1,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
|
||||||
|
...(shouldShowAdvancedSettings
|
||||||
|
? [
|
||||||
new SettingsSlider(settings, 'renderSpeed', {
|
new SettingsSlider(settings, 'renderSpeed', {
|
||||||
min: 1,
|
min: 1,
|
||||||
max: 10,
|
max: 10,
|
||||||
rounding: Math.round,
|
rounding: Math.round,
|
||||||
}),
|
}),
|
||||||
]),
|
]
|
||||||
|
: []),
|
||||||
new SettingsSlider(settings, 'agentCount', {
|
|
||||||
min: 1,
|
|
||||||
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, 'moveSpeed', {
|
|
||||||
min: 10,
|
|
||||||
max: 500,
|
|
||||||
scaling: ValueScaling.Quadratic,
|
|
||||||
rounding: Math.round,
|
|
||||||
}),
|
|
||||||
|
|
||||||
new SettingsSlider(settings, 'turnSpeed', {
|
|
||||||
min: 1,
|
|
||||||
max: 200,
|
|
||||||
scaling: ValueScaling.Quadratic,
|
|
||||||
rounding: Math.round,
|
|
||||||
}),
|
|
||||||
|
|
||||||
new SettingsSlider(settings, 'sensorOffsetAngle', {
|
|
||||||
min: 0,
|
|
||||||
max: 90,
|
|
||||||
step: 1,
|
|
||||||
}),
|
|
||||||
|
|
||||||
new SettingsSlider(settings, 'sensorOffsetDistance', {
|
|
||||||
min: 0,
|
|
||||||
max: 200,
|
|
||||||
scaling: ValueScaling.Quadratic,
|
|
||||||
rounding: Math.round,
|
|
||||||
}),
|
|
||||||
|
|
||||||
new SettingsSlider(settings, 'turnWhenLost', {
|
|
||||||
min: 0,
|
|
||||||
max: 1,
|
|
||||||
}),
|
|
||||||
|
|
||||||
new SettingsSlider(settings, 'individualTrailWeight', {
|
|
||||||
min: 0,
|
|
||||||
max: 1,
|
|
||||||
}),
|
|
||||||
|
|
||||||
new SettingsSlider(settings, 'diffusionRateTrails', {
|
|
||||||
min: 0,
|
|
||||||
max: 2,
|
|
||||||
}),
|
|
||||||
|
|
||||||
new SettingsSlider(settings, 'decayRateTrails', {
|
|
||||||
min: 0.1,
|
|
||||||
max: 5000,
|
|
||||||
scaling: ValueScaling.Quadratic,
|
|
||||||
}),
|
|
||||||
|
|
||||||
new SettingsSlider(settings, 'diffusionRateBrush', {
|
|
||||||
min: 0.001,
|
|
||||||
max: 1,
|
|
||||||
}),
|
|
||||||
|
|
||||||
new SettingsSlider(settings, 'decayRateBrush', {
|
|
||||||
min: 0.1,
|
|
||||||
max: 100,
|
|
||||||
}),
|
|
||||||
|
|
||||||
new SettingsSlider(settings, 'brushSize', {
|
|
||||||
min: 1,
|
|
||||||
max: 30,
|
|
||||||
}),
|
|
||||||
|
|
||||||
new SettingsSlider(settings, 'clarity', {
|
|
||||||
min: 0.00001,
|
|
||||||
max: 1,
|
|
||||||
}),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const sliderContainerElement = document.createElement('div');
|
const sliderContainerElement = document.createElement('div');
|
||||||
|
|
|
||||||
28
src/pipelines/agents/agent-generation/agent-resize.wgsl
Normal file
28
src/pipelines/agents/agent-generation/agent-resize.wgsl
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
struct Agent {
|
struct Agent {
|
||||||
position: vec2<f32>,
|
position: vec2<f32>,
|
||||||
angle: 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>;
|
@group(1) @binding(1) var<storage, read_write> agents: array<Agent>;
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,56 @@
|
||||||
struct Settings {
|
struct Settings {
|
||||||
brushColor: vec3<f32>,
|
colorA: vec3<f32>,
|
||||||
evenGenerationColor: vec3<f32>,
|
backgroundColorPadding0: f32,
|
||||||
oddGenerationColor: vec3<f32>,
|
colorB: vec3<f32>,
|
||||||
|
backgroundColorPadding1: f32,
|
||||||
|
colorC: vec3<f32>,
|
||||||
|
backgroundColorPadding2: f32,
|
||||||
|
backgroundColor: vec3<f32>,
|
||||||
clarity: f32,
|
clarity: f32,
|
||||||
|
cameraCenter: vec2<f32>,
|
||||||
|
cameraZoom: f32,
|
||||||
|
padding0: f32,
|
||||||
};
|
};
|
||||||
|
|
||||||
@group(1) @binding(0) var<uniform> settings: Settings;
|
@group(1) @binding(0) var<uniform> settings: Settings;
|
||||||
@group(1) @binding(1) var Sampler: sampler;
|
@group(1) @binding(1) var Sampler: sampler;
|
||||||
@group(1) @binding(2) var trailMap: texture_2d<f32>;
|
@group(1) @binding(2) var trailMap: texture_2d<f32>;
|
||||||
|
@group(1) @binding(3) var sourceMap: texture_2d<f32>;
|
||||||
|
|
||||||
@fragment
|
@fragment
|
||||||
fn fragment(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
|
fn fragment(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
|
||||||
let traces = textureSample(trailMap, Sampler, uv);
|
let cameraUv = settings.cameraCenter / state.size;
|
||||||
let random = textureSample(noise, noiseSampler, uv);
|
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 traceStrengths = vec3(
|
||||||
|
clarity(traces.r),
|
||||||
let evenGenerationStrength = clarity(traces.r);
|
clarity(traces.g),
|
||||||
let oddGenerationStrength = clarity(traces.g);
|
clarity(traces.b)
|
||||||
let brushStrength = traces.a;
|
|
||||||
|
|
||||||
let color = max(
|
|
||||||
mix(
|
|
||||||
evenGenerationStrength * settings.evenGenerationColor,
|
|
||||||
oddGenerationStrength * settings.oddGenerationColor,
|
|
||||||
oddGenerationStrength / (evenGenerationStrength + oddGenerationStrength + 0.000001)
|
|
||||||
),
|
|
||||||
brushStrength * settings.brushColor
|
|
||||||
);
|
);
|
||||||
|
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 {
|
fn clarity(strength: f32) -> f32 {
|
||||||
return pow(strength, settings.clarity);
|
return pow(clamp(strength, 0, 1), settings.clarity);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,175 @@ export enum Severity {
|
||||||
ERROR = 'error',
|
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 {
|
export interface ErrorHandlerError {
|
||||||
severity: Severity;
|
severity: Severity;
|
||||||
message: string;
|
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 {
|
export class ErrorHandler {
|
||||||
private static readonly errors: Array<ErrorHandlerError> = [];
|
private static readonly errors: Array<ErrorHandlerError> = [];
|
||||||
|
|
@ -18,23 +181,46 @@ export class ErrorHandler {
|
||||||
(error: ErrorHandlerError, metadata: ErrorMetadata) => void
|
(error: ErrorHandlerError, metadata: ErrorMetadata) => void
|
||||||
> = [];
|
> = [];
|
||||||
|
|
||||||
public static addException(exception: Error) {
|
public static addException(
|
||||||
ErrorHandler.addError(Severity.ERROR, exception.message);
|
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) {
|
public static addError(
|
||||||
ErrorHandler.errors.push({ severity, message });
|
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) =>
|
ErrorHandler.onErrorListeners.forEach((listener) =>
|
||||||
listener({ severity, message }, ErrorHandler.metadata)
|
listener(error, ErrorHandler.metadata)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static addMetadata(key: string, value: any) {
|
public static addMetadata(key: string, value: unknown) {
|
||||||
const serialized: Record<string, any> = {};
|
ErrorHandler.metadata[key] = serializeMetadataValue(value);
|
||||||
for (const k in value) {
|
|
||||||
serialized[k] = value[k];
|
|
||||||
}
|
|
||||||
ErrorHandler.metadata[key] = serialized;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static addOnErrorListener(
|
public static addOnErrorListener(
|
||||||
|
|
|
||||||
115
src/vibes.test.ts
Normal file
115
src/vibes.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue