diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..d6ef9c4 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,30 @@ +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import prettierConfig from 'eslint-config-prettier'; +import globals from 'globals'; + +export default tseslint.config( + { + ignores: ['node_modules/**', 'dist/**', 'public/**'], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + prettierConfig, + { + files: ['**/*.ts'], + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + globals: { + ...globals.browser, + }, + }, + rules: { + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/no-empty-function': 'off', + '@typescript-eslint/no-non-null-assertion': 'error', + '@typescript-eslint/ban-ts-comment': 'error', + 'prefer-const': 'error', + }, + } +); diff --git a/src/app-constants.ts b/src/app-constants.ts new file mode 100644 index 0000000..e7ebffb --- /dev/null +++ b/src/app-constants.ts @@ -0,0 +1,13 @@ +export const ENABLED_FLAG_VALUE = '1'; +export const DISABLED_FLAG_VALUE = '0'; + +export const UNIT_INTERVAL_INPUT_MIN = '0'; +export const UNIT_INTERVAL_INPUT_MAX = '1'; + +export const DEFAULT_AUDIO_VOLUME = 0.42; + +export const APP_STORAGE_KEYS = { + audioMuted: 'fleeting-garden:audio-muted', + audioVolume: 'fleeting-garden:audio-volume', + vibe: 'fleeting-garden:vibe', +} as const; diff --git a/src/audio/audio-pan-node.ts b/src/audio/audio-pan-node.ts new file mode 100644 index 0000000..0d1942e --- /dev/null +++ b/src/audio/audio-pan-node.ts @@ -0,0 +1,38 @@ +import { clamp } from '../utils/math'; +import { isIosLike } from './audio-platform'; + +interface AudioPanNode { + input: AudioNode; + output: AudioNode; + disconnect: () => void; +} + +type AudioContextWithOptionalPanner = AudioContext & { + createStereoPanner?: () => StereoPannerNode; +}; + +export const createAudioPanNode = ( + context: AudioContext, + pan: number, + startTime: number +): AudioPanNode => { + const createStereoPanner = (context as AudioContextWithOptionalPanner) + .createStereoPanner; + + if (!isIosLike() && typeof createStereoPanner === 'function') { + const panner = createStereoPanner.call(context); + panner.pan.setValueAtTime(clamp(pan, -1, 1), startTime); + return { + input: panner, + output: panner, + disconnect: () => panner.disconnect(), + }; + } + + const passthrough = context.createGain(); + return { + input: passthrough, + output: passthrough, + disconnect: () => passthrough.disconnect(), + }; +}; diff --git a/src/audio/audio-platform.ts b/src/audio/audio-platform.ts new file mode 100644 index 0000000..642893b --- /dev/null +++ b/src/audio/audio-platform.ts @@ -0,0 +1,3 @@ +export const isIosLike = (): boolean => + /iPad|iPhone|iPod/.test(navigator.userAgent) || + (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); diff --git a/src/audio/garden-audio-scheduling.ts b/src/audio/garden-audio-scheduling.ts new file mode 100644 index 0000000..5174442 --- /dev/null +++ b/src/audio/garden-audio-scheduling.ts @@ -0,0 +1,3 @@ +export const GENERATIVE_LOOKAHEAD_SECONDS = 0.3; +export const GENERATIVE_START_DELAY_SECONDS = 0.02; +export const PIANO_SCHEDULE_AHEAD_SECONDS = 0.002; diff --git a/src/audio/generative-piano-tuning.ts b/src/audio/generative-piano-tuning.ts new file mode 100644 index 0000000..5d3625c --- /dev/null +++ b/src/audio/generative-piano-tuning.ts @@ -0,0 +1,427 @@ +export interface GardenAudioRegister { + midiMin: number; + midiMax: number; + preferredMidi: number; + pan: number; +} + +export interface GardenAudioStylePool extends GardenAudioRegister { + scaleDegrees: Array; +} + +interface GardenAudioStyleVoice { + scaleDegreeOffset: number; + velocityMultiplier: number; + panOffset: number; +} + +interface GenerativePianoTuning { + stylePools: [GardenAudioStylePool, GardenAudioStylePool, GardenAudioStylePool]; + padRegisters: [GardenAudioRegister, GardenAudioRegister, GardenAudioRegister]; + vibeChangeStinger: { + velocities: [number, number, number]; + pans: [number, number, number]; + delaySends: [number, number, number]; + lowpassExpression: number; + }; + highActivityExtra: { + barOffset: number; + expressionMultiplier: number; + }; + padChord: { + velocities: [number, number, number]; + expressionVelocityWeight: number; + delaySend: number; + lowpassExpressionWeight: number; + }; + supportNote: { + velocityBase: number; + velocityExpressionWeight: number; + durationBaseSeconds: number; + durationExpressionSeconds: number; + delaySendBase: number; + delaySendExpressionWeight: number; + lowpassExpressionWeight: number; + expressionThreshold: number; + offsetsByStyle: [Array, Array, Array]; + }; + textureNote: { + velocityBase: number; + velocityExpressionWeight: number; + durationBaseSeconds: number; + durationExpressionSeconds: number; + delaySendBase: number; + delaySendExpressionWeight: number; + idleExpressionThreshold: number; + mediumExpressionThreshold: number; + intenseSpacing: number; + idlePhase: number; + }; + gestureAccent: { + rotationStrengthMultiplier: number; + quantizeStepLookahead: number; + velocityBase: number; + velocityStrengthWeight: number; + durationBaseSeconds: number; + durationStrengthSeconds: number; + delaySend: number; + }; + touchNote: { + registerBiasManiaAmount: number; + velocityBase: number; + velocityStrengthWeight: number; + durationBaseSeconds: number; + durationStrengthSeconds: number; + delaySend: number; + lowpassBaseExpression: number; + lowpassStrengthWeight: number; + }; + brushPhrase: { + initialMotifOffset: number; + energyDecaySeconds: number; + maniaDecaySeconds: number; + fadeMinimumLifetimeSeconds: number; + layerIntensityBase: number; + layerIntensityManiaWeight: number; + frameActivityWeight: number; + frameManiaWeight: number; + }; + brushStream: { + inferredManiaThreshold: number; + inferredManiaRange: number; + registerManiaShift: number; + chordToneEverySteps: number; + durationBaseSeconds: number; + durationIntensitySeconds: number; + durationManiaSeconds: number; + durationMinSeconds: number; + durationMaxSeconds: number; + delaySendBase: number; + delaySendIntensityWeight: number; + delaySendManiaWeight: number; + delaySendMin: number; + delaySendMax: number; + velocityBase: number; + velocityIntensityWeight: number; + lowpassBaseExpression: number; + lowpassIntensityWeight: number; + lowpassManiaWeight: number; + manicThreshold: number; + intenseThreshold: number; + activeThreshold: number; + }; + brushStreamEcho: { + maniaThreshold: number; + stepModulo: number; + stepRemainder: number; + intensityThreshold: number; + octaveSemitones: number; + maxMidi: number; + velocityBase: number; + velocityIntensityWeight: number; + durationMinSeconds: number; + durationScale: number; + panScale: number; + delaySendMin: number; + delaySendScale: number; + lowpassBaseExpression: number; + lowpassManiaWeight: number; + }; + brushMotif: { + highThreshold: number; + mediumThreshold: number; + highOffset: number; + mediumOffset: number; + lowOffset: number; + minOffset: number; + maxOffset: number; + }; + registerBias: { + maniaShiftSemitones: number; + midiMin: number; + midiMaxForMin: number; + minimumSpan: number; + midiMax: number; + }; + candidateOctaveSearch: { + min: number; + max: number; + }; + stylePanOffsetScale: number; + lowpass: { + midiBase: number; + midiRange: number; + midiLiftHz: number; + expressionBase: number; + expressionWeight: number; + }; + styleRotationBars: number; + chordBars: number; + supportBarSpacing: number; + supportBarOffset: number; + idleTextureBarSpacing: number; + mediumTextureBarSpacing: number; + textureBeat: number; + highActivityExtraBeat: number; + highActivityExtraThreshold: number; + noteScorePreferenceWeight: number; + noteScoreRegisterWeight: number; + noteScoreChordToneWeight: number; + noteScoreRepeatPenalty: number; + gestureAccentMinIntervalSeconds: number; + strokeAccentMinSteps: number; + strokeAccentThreshold: number; + stingerDurationSeconds: number; + stingerSpacingSeconds: number; + maxBrushPhraseLayers: number; + maxBrushStreamNotesPerBar: number; + brushLayerBaseSeconds: number; + brushLayerEnergySeconds: number; + brushLayerMinIntensity: number; + brushStreamIdleIntervalBeats: number; + brushStreamActiveIntervalBeats: number; + brushStreamIntenseIntervalBeats: number; + brushStreamManicIntervalBeats: number; + brushMotifMaxSteps: number; + brushMotifCanonDelaySeconds: number; + padDurationBarScale: number; +} + +export const generativePianoTuning: GenerativePianoTuning = { + stylePools: [ + { + midiMin: 48, + midiMax: 67, + preferredMidi: 55, + pan: -0.18, + scaleDegrees: [0, 1, 2, 4], + }, + { + midiMin: 55, + midiMax: 74, + preferredMidi: 63, + pan: 0, + scaleDegrees: [1, 2, 3, 5], + }, + { + midiMin: 62, + midiMax: 81, + preferredMidi: 72, + pan: 0.18, + scaleDegrees: [2, 3, 4, 6], + }, + ], + padRegisters: [ + { + midiMin: 40, + midiMax: 55, + preferredMidi: 48, + pan: -0.12, + }, + { + midiMin: 48, + midiMax: 64, + preferredMidi: 55, + pan: 0.08, + }, + { + midiMin: 58, + midiMax: 76, + preferredMidi: 67, + pan: 0.2, + }, + ], + vibeChangeStinger: { + velocities: [0.1, 0.085, 0.07], + pans: [-0.16, 0, 0.16], + delaySends: [0.012, 0.014, 0.016], + lowpassExpression: 0.35, + }, + highActivityExtra: { + barOffset: 1, + expressionMultiplier: 0.9, + }, + padChord: { + velocities: [0.052, 0.041, 0.033], + expressionVelocityWeight: 0.02, + delaySend: 0.008, + lowpassExpressionWeight: 0.28, + }, + supportNote: { + velocityBase: 0.105, + velocityExpressionWeight: 0.07, + durationBaseSeconds: 1.35, + durationExpressionSeconds: 0.4, + delaySendBase: 0.016, + delaySendExpressionWeight: 0.006, + lowpassExpressionWeight: 0.7, + expressionThreshold: 0.55, + offsetsByStyle: [ + [0, 2, 12], + [1, 2, 0, 12], + [2, 12, 3, 13], + ], + }, + textureNote: { + velocityBase: 0.09, + velocityExpressionWeight: 0.08, + durationBaseSeconds: 0.62, + durationExpressionSeconds: 0.24, + delaySendBase: 0.016, + delaySendExpressionWeight: 0.006, + idleExpressionThreshold: 0.35, + mediumExpressionThreshold: 0.7, + intenseSpacing: 1, + idlePhase: 1, + }, + gestureAccent: { + rotationStrengthMultiplier: 3, + quantizeStepLookahead: 1, + velocityBase: 0.12, + velocityStrengthWeight: 0.09, + durationBaseSeconds: 0.48, + durationStrengthSeconds: 0.22, + delaySend: 0.012, + }, + touchNote: { + registerBiasManiaAmount: 0, + velocityBase: 0.14, + velocityStrengthWeight: 0.11, + durationBaseSeconds: 0.55, + durationStrengthSeconds: 0.18, + delaySend: 0.006, + lowpassBaseExpression: 0.55, + lowpassStrengthWeight: 0.35, + }, + brushPhrase: { + initialMotifOffset: -1, + energyDecaySeconds: 0.72, + maniaDecaySeconds: 0.54, + fadeMinimumLifetimeSeconds: 0.001, + layerIntensityBase: 0.8, + layerIntensityManiaWeight: 0.42, + frameActivityWeight: 0.42, + frameManiaWeight: 0.18, + }, + brushStream: { + inferredManiaThreshold: 0.82, + inferredManiaRange: 0.18, + registerManiaShift: 0.45, + chordToneEverySteps: 4, + durationBaseSeconds: 0.48, + durationIntensitySeconds: 0.08, + durationManiaSeconds: 0.34, + durationMinSeconds: 0.14, + durationMaxSeconds: 0.62, + delaySendBase: 0.012, + delaySendIntensityWeight: 0.011, + delaySendManiaWeight: 0.006, + delaySendMin: 0.006, + delaySendMax: 0.032, + velocityBase: 0.1, + velocityIntensityWeight: 0.13, + lowpassBaseExpression: 0.39, + lowpassIntensityWeight: 0.48, + lowpassManiaWeight: 0.18, + manicThreshold: 0.85, + intenseThreshold: 0.62, + activeThreshold: 0.34, + }, + brushStreamEcho: { + maniaThreshold: 0.86, + stepModulo: 2, + stepRemainder: 1, + intensityThreshold: 0.95, + octaveSemitones: 12, + maxMidi: 88, + velocityBase: 0.045, + velocityIntensityWeight: 0.05, + durationMinSeconds: 0.11, + durationScale: 0.68, + panScale: -0.75, + delaySendMin: 0.006, + delaySendScale: 0.72, + lowpassBaseExpression: 0.62, + lowpassManiaWeight: 0.24, + }, + brushMotif: { + highThreshold: 0.82, + mediumThreshold: 0.55, + highOffset: 1, + mediumOffset: 0, + lowOffset: -1, + minOffset: -3, + maxOffset: 4, + }, + registerBias: { + maniaShiftSemitones: 4, + midiMin: 36, + midiMaxForMin: 86, + minimumSpan: 4, + midiMax: 91, + }, + candidateOctaveSearch: { + min: -3, + max: 3, + }, + stylePanOffsetScale: 0.35, + lowpass: { + midiBase: 48, + midiRange: 33, + midiLiftHz: 720, + expressionBase: 0.58, + expressionWeight: 0.32, + }, + styleRotationBars: 2, + chordBars: 4, + supportBarSpacing: 2, + supportBarOffset: 1, + idleTextureBarSpacing: 2, + mediumTextureBarSpacing: 1, + textureBeat: 2, + highActivityExtraBeat: 3, + highActivityExtraThreshold: 0.45, + noteScorePreferenceWeight: 1.8, + noteScoreRegisterWeight: 0.28, + noteScoreChordToneWeight: 0.75, + noteScoreRepeatPenalty: 3.2, + gestureAccentMinIntervalSeconds: 2.5, + strokeAccentMinSteps: 12, + strokeAccentThreshold: 0.58, + stingerSpacingSeconds: 0.08, + stingerDurationSeconds: 1.1, + maxBrushPhraseLayers: 3, + maxBrushStreamNotesPerBar: 9, + brushLayerBaseSeconds: 5.5, + brushLayerEnergySeconds: 2.5, + brushLayerMinIntensity: 0.12, + brushStreamIdleIntervalBeats: 2, + brushStreamActiveIntervalBeats: 1, + brushStreamIntenseIntervalBeats: 0.5, + brushStreamManicIntervalBeats: 0.5, + brushMotifMaxSteps: 8, + brushMotifCanonDelaySeconds: 0.055, + padDurationBarScale: 0.46, +}; + +export const styleVoices: [ + GardenAudioStyleVoice, + GardenAudioStyleVoice, + GardenAudioStyleVoice, +] = [ + { + scaleDegreeOffset: 0, + velocityMultiplier: 0.92, + panOffset: -0.14, + }, + { + scaleDegreeOffset: 1, + velocityMultiplier: 1, + panOffset: 0, + }, + { + scaleDegreeOffset: 2, + velocityMultiplier: 0.86, + panOffset: 0.14, + }, +]; diff --git a/src/game-loop/dev-stats-overlay.ts b/src/game-loop/dev-stats-overlay.ts new file mode 100644 index 0000000..b81ad38 --- /dev/null +++ b/src/game-loop/dev-stats-overlay.ts @@ -0,0 +1,68 @@ +const DEV_STATS_REFRESH_MS = 200; +const ZERO_STAT_TEXT = '0'; +const ZERO_FRAME_TIME_TEXT = '0ms'; +const ZERO_RESOLUTION_TEXT = '0x0'; + +interface DevStatsSnapshot { + time: DOMHighResTimeStamp; + fps: number; + agentCount: number; + frameTimeMs: number; + renderWidth: number; + renderHeight: number; +} + +export class DevStatsOverlay { + private readonly element: HTMLDivElement; + private previousUpdateTime = Number.NEGATIVE_INFINITY; + private previousText = ''; + + public constructor(parent: HTMLElement) { + this.element = document.createElement('div'); + this.element.className = 'dev-stats-overlay'; + this.element.setAttribute('aria-hidden', 'true'); + parent.append(this.element); + } + + public update({ + time, + fps, + agentCount, + frameTimeMs, + renderWidth, + renderHeight, + }: DevStatsSnapshot): void { + if (time - this.previousUpdateTime < DEV_STATS_REFRESH_MS) { + return; + } + + this.previousUpdateTime = time; + const text = `FPS ${formatFps(fps)}\nAgents ${formatAgentCount(agentCount)}\nFrame ${formatFrameTime(frameTimeMs)}\nResolution ${formatResolution(renderWidth, renderHeight)}`; + if (text !== this.previousText) { + this.element.textContent = text; + this.previousText = text; + } + } + + public destroy(): void { + this.element.remove(); + } +} + +const formatFps = (fps: number): string => + Number.isFinite(fps) ? Math.max(0, Math.round(fps)).toString() : ZERO_STAT_TEXT; + +const formatAgentCount = (agentCount: number): string => + Number.isFinite(agentCount) + ? Math.max(0, Math.round(agentCount)).toLocaleString('en-US') + : ZERO_STAT_TEXT; + +const formatFrameTime = (frameTimeMs: number): string => + Number.isFinite(frameTimeMs) + ? `${Math.max(0, frameTimeMs).toFixed(frameTimeMs < 10 ? 1 : 0)}ms` + : ZERO_FRAME_TIME_TEXT; + +const formatResolution = (width: number, height: number): string => + Number.isFinite(width) && Number.isFinite(height) + ? `${Math.max(0, Math.round(width))}x${Math.max(0, Math.round(height))}` + : ZERO_RESOLUTION_TEXT; diff --git a/src/game-loop/internal-render-size.ts b/src/game-loop/internal-render-size.ts new file mode 100644 index 0000000..841416e --- /dev/null +++ b/src/game-loop/internal-render-size.ts @@ -0,0 +1,53 @@ +const MEGAPIXEL = 1_000_000; + +export interface InternalRenderSizeOptions { + clientHeight: number; + clientWidth: number; + maxPixelScale: number; + maxTextureDimension: number; + targetAreaMegapixels: number; +} + +export interface InternalRenderSize { + height: number; + width: number; +} + +const getSafeInternalRenderAreaMegapixels = (targetAreaMegapixels: number): number => + Number.isFinite(targetAreaMegapixels) && targetAreaMegapixels > 0 + ? targetAreaMegapixels + : 1; + +const getSafeMaxPixelScale = (maxPixelScale: number): number => + Number.isFinite(maxPixelScale) && maxPixelScale > 0 + ? maxPixelScale + : Number.POSITIVE_INFINITY; + +export const getInternalRenderSize = ({ + clientHeight, + clientWidth, + maxPixelScale, + maxTextureDimension, + targetAreaMegapixels, +}: InternalRenderSizeOptions): InternalRenderSize => { + const safeClientWidth = Math.max(1, clientWidth); + const safeClientHeight = Math.max(1, clientHeight); + const safeMaxTextureDimension = + Number.isFinite(maxTextureDimension) && maxTextureDimension > 0 + ? Math.floor(maxTextureDimension) + : Number.POSITIVE_INFINITY; + const targetArea = + getSafeInternalRenderAreaMegapixels(targetAreaMegapixels) * MEGAPIXEL; + const areaScale = Math.sqrt(targetArea / (safeClientWidth * safeClientHeight)); + const dimensionScale = Math.min( + areaScale, + getSafeMaxPixelScale(maxPixelScale), + safeMaxTextureDimension / safeClientWidth, + safeMaxTextureDimension / safeClientHeight + ); + + return { + height: Math.max(1, Math.round(safeClientHeight * dimensionScale)), + width: Math.max(1, Math.round(safeClientWidth * dimensionScale)), + }; +}; diff --git a/src/pipelines/agents/agent-dispatch.ts b/src/pipelines/agents/agent-dispatch.ts new file mode 100644 index 0000000..3a91ff8 --- /dev/null +++ b/src/pipelines/agents/agent-dispatch.ts @@ -0,0 +1,34 @@ +export const AGENT_WORKGROUP_SIZE = 64; +export const AGENT_MAX_DISPATCH_WORKGROUPS_PER_DIMENSION = 65_535; +export const AGENT_MAX_DISPATCHABLE_COUNT = 0xffffffff; + +export const getAgentDispatchWorkgroups = (agentCount: number): [number, number] => { + if (!Number.isFinite(agentCount) || agentCount <= 0) { + throw new Error('Agent count must be a positive finite number'); + } + + const workgroupCount = Math.ceil(agentCount / AGENT_WORKGROUP_SIZE); + if (workgroupCount <= AGENT_MAX_DISPATCH_WORKGROUPS_PER_DIMENSION) { + return [workgroupCount, 1]; + } + + const workgroupX = Math.min( + Math.ceil(Math.sqrt(workgroupCount)), + AGENT_MAX_DISPATCH_WORKGROUPS_PER_DIMENSION + ); + const workgroupY = Math.ceil(workgroupCount / workgroupX); + + if (workgroupY > AGENT_MAX_DISPATCH_WORKGROUPS_PER_DIMENSION) { + throw new Error('Agent count exceeds dispatchable workgroup range'); + } + + return [workgroupX, workgroupY]; +}; + +export const dispatchAgentWorkgroups = ( + passEncoder: GPUComputePassEncoder, + agentCount: number +): void => { + const [workgroupX, workgroupY] = getAgentDispatchWorkgroups(agentCount); + passEncoder.dispatchWorkgroups(workgroupX, workgroupY); +}; diff --git a/src/utils/hex-to-rgb.ts b/src/utils/hex-to-rgb.ts new file mode 100644 index 0000000..2eff6b9 --- /dev/null +++ b/src/utils/hex-to-rgb.ts @@ -0,0 +1,12 @@ +const HEX_COLOR_PATTERN = + /^#?(?[0-9a-f]{2})(?[0-9a-f]{2})(?[0-9a-f]{2})$/i; + +export const hexToRgb = (hex: string): [number, number, number] => { + const match = HEX_COLOR_PATTERN.exec(hex); + if (!match?.groups) { + return [0, 0, 0]; + } + + const { red, green, blue } = match.groups; + return [parseInt(red, 16) / 255, parseInt(green, 16) / 255, parseInt(blue, 16) / 255]; +}; diff --git a/src/utils/math.ts b/src/utils/math.ts new file mode 100644 index 0000000..5937e8a --- /dev/null +++ b/src/utils/math.ts @@ -0,0 +1,29 @@ +export const clamp = (value: number, min: number, max: number): number => + Math.min(max, Math.max(min, value)); + +export const clamp01 = (value: number): number => clamp(value, 0, 1); + +export const mix = (from: number, to: number, amount: number): number => + from + (to - from) * amount; + +export const mixAngle = (from: number, to: number, amount: number): number => { + const delta = Math.atan2(Math.sin(to - from), Math.cos(to - from)); + return from + delta * amount; +}; + +export const approach = ( + current: number, + target: number, + elapsedSeconds: number, + timeConstantSeconds: number +): number => { + const amount = 1 - Math.exp(-elapsedSeconds / Math.max(0.001, timeConstantSeconds)); + return mix(current, target, amount); +}; + +export const smoothstep = (edge0: number, edge1: number, value: number): number => { + const amount = clamp01((value - edge0) / (edge1 - edge0)); + return amount * amount * (3 - 2 * amount); +}; + +export const easeOutQuad = (value: number): number => value * (2 - value);