From 4e92913925b832091952befa0de469cb27b885aa Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 9 May 2026 22:27:51 +0100 Subject: [PATCH] LGTM --- src/audio/garden-audio-config.ts | 254 ++++++++++++++++++ src/game-loop/game-loop-settings.ts | 4 +- src/page/set-up-settings-page.ts | 192 +++++++------ .../agents/agent-generation/agent-resize.wgsl | 28 ++ .../agents/agent-generation/agent-schema.wgsl | 5 +- src/pipelines/render/render.wgsl | 59 ++-- src/utils/error-handler.ts | 210 ++++++++++++++- src/vibes.test.ts | 115 ++++++++ 8 files changed, 743 insertions(+), 124 deletions(-) create mode 100644 src/audio/garden-audio-config.ts create mode 100644 src/pipelines/agents/agent-generation/agent-resize.wgsl create mode 100644 src/vibes.test.ts diff --git a/src/audio/garden-audio-config.ts b/src/audio/garden-audio-config.ts new file mode 100644 index 0000000..c94541a --- /dev/null +++ b/src/audio/garden-audio-config.ts @@ -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; + brightness: number; + delayTimeMultiplier: number; + progression: Array; +} + +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; + chordSteps: Array; + bassSteps: Array; + melodyPattern: Array; + }; + eraser: { + enabled: boolean; + minIntervalSeconds: number; + noiseGain: number; + filterMinHz: number; + filterMaxHz: number; + }; + colorVoices: [GardenAudioColorVoice, GardenAudioColorVoice, GardenAudioColorVoice]; + vibes: Record; +} + +const majorProgression: Array = [ + { rootOffset: 0, quality: 'major' }, + { rootOffset: 9, quality: 'minor' }, + { rootOffset: 5, quality: 'major' }, + { rootOffset: 7, quality: 'major' }, +]; + +const minorProgression: Array = [ + { 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' }, + ], + }, + }, +}; diff --git a/src/game-loop/game-loop-settings.ts b/src/game-loop/game-loop-settings.ts index e71ebc8..bd3a756 100644 --- a/src/game-loop/game-loop-settings.ts +++ b/src/game-loop/game-loop-settings.ts @@ -1,8 +1,10 @@ export interface GameLoopSettings { - maxAgentCountUpperLimit: number; + agentBudgetMax: number; agentCount: number; renderSpeed: number; simulatedDelayMs: number; + selectedColorIndex: number; + spawnPerPixel: number; startColorHue: number; } diff --git a/src/page/set-up-settings-page.ts b/src/page/set-up-settings-page.ts index 238d704..8d63f3a 100644 --- a/src/page/set-up-settings-page.ts +++ b/src/page/set-up-settings-page.ts @@ -6,101 +6,115 @@ export const setUpSettingsPage = ( settingsPage: HTMLDivElement, maxAgentCount: number ): Array> => { + const params = new URLSearchParams(window.location.search); + const shouldShowAdvancedSettings = !isProduction && params.get('dev') !== '0'; + const sliders: Array> = [ - ...(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', { min: 1, max: 10, 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'); diff --git a/src/pipelines/agents/agent-generation/agent-resize.wgsl b/src/pipelines/agents/agent-generation/agent-resize.wgsl new file mode 100644 index 0000000..3f02c46 --- /dev/null +++ b/src/pipelines/agents/agent-generation/agent-resize.wgsl @@ -0,0 +1,28 @@ +struct ResizeSettings { + scale: vec2, + agentCount: f32, + padding: f32, +}; + +@group(1) @binding(0) var resizeSettings: ResizeSettings; + +@compute @workgroup_size(64) +fn main( + @builtin(global_invocation_id) global_id: vec3, + @builtin(num_workgroups) workgroup_count: vec3 +) { + 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; +} diff --git a/src/pipelines/agents/agent-generation/agent-schema.wgsl b/src/pipelines/agents/agent-generation/agent-schema.wgsl index 3b37725..d40471e 100644 --- a/src/pipelines/agents/agent-generation/agent-schema.wgsl +++ b/src/pipelines/agents/agent-generation/agent-schema.wgsl @@ -1,7 +1,10 @@ struct Agent { position: vec2, angle: f32, - generation: f32, + colorIndex: f32, + targetPosition: vec2, + targetAngle: f32, + introDelay: f32, } @group(1) @binding(1) var agents: array; diff --git a/src/pipelines/render/render.wgsl b/src/pipelines/render/render.wgsl index 8607d7c..5864693 100644 --- a/src/pipelines/render/render.wgsl +++ b/src/pipelines/render/render.wgsl @@ -1,39 +1,56 @@ struct Settings { - brushColor: vec3, - evenGenerationColor: vec3, - oddGenerationColor: vec3, + colorA: vec3, + backgroundColorPadding0: f32, + colorB: vec3, + backgroundColorPadding1: f32, + colorC: vec3, + backgroundColorPadding2: f32, + backgroundColor: vec3, clarity: f32, + cameraCenter: vec2, + cameraZoom: f32, + padding0: f32, }; @group(1) @binding(0) var settings: Settings; @group(1) @binding(1) var Sampler: sampler; @group(1) @binding(2) var trailMap: texture_2d; +@group(1) @binding(3) var sourceMap: texture_2d; @fragment fn fragment(@location(0) uv: vec2) -> @location(0) vec4 { - 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); } diff --git a/src/utils/error-handler.ts b/src/utils/error-handler.ts index ca91a8a..af24c3e 100644 --- a/src/utils/error-handler.ts +++ b/src/utils/error-handler.ts @@ -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 + | { [key: string]: ErrorMetadataValue }; +export type ErrorMetadata = { [key: string]: ErrorMetadataValue }; + +export interface RuntimeErrorOptions { + cause?: unknown; + details?: Record; +} + +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; +} + +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 => + typeof value === 'object' && value !== null; + +const safelyRead = (value: Record, key: string): unknown => { + try { + return value[key]; + } catch { + return undefined; + } +}; + +const isIterable = (value: unknown): value is Iterable => + 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; + 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; + 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 = []; @@ -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 = {}; - 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( diff --git a/src/vibes.test.ts b/src/vibes.test.ts new file mode 100644 index 0000000..99b28cc --- /dev/null +++ b/src/vibes.test.ts @@ -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); + }); + }); +});