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 {
|
||||
maxAgentCountUpperLimit: number;
|
||||
agentBudgetMax: number;
|
||||
agentCount: number;
|
||||
renderSpeed: number;
|
||||
simulatedDelayMs: number;
|
||||
selectedColorIndex: number;
|
||||
spawnPerPixel: number;
|
||||
|
||||
startColorHue: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
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 {
|
||||
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>;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
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