diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 710c622..0c52ecd 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -57,4 +57,4 @@ jobs: if: github.event_name == 'push' && github.ref == 'refs/heads/main' run: | apt update && apt install -y rsync - rsync -a --delete dist/ /pages/fleeting-garden + rsync -a --delete dist/ /pages/fleeting diff --git a/.gitignore b/.gitignore index 916a63e..f06235c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,47 +1,2 @@ -# Dependency directory node_modules -modules/ -ts-node--*/ -rss.xml - dist -playwright-report -test-results - -# Logs -logs -*.log -npm-debug.log* - -# Runtime data -pids -*.pid -*.seed -*.ssh -*.ppk -v8-compile-cache-0/ -Thumbs.db - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (http://nodejs.org/api/addons.html) -build/Release -bin -ts-node - -# Personal Scripts -*.bat -*.ssh -*.sh -!system.min.js - -# Editors -.vscode -.markdownlint.json - -# Build Files -temp -*.js -*.map -!webpack.* diff --git a/definitions.d.ts b/definitions.d.ts index 9b4e880..c90ad44 100644 --- a/definitions.d.ts +++ b/definitions.d.ts @@ -6,5 +6,3 @@ declare module '*.wgsl?raw' { interface HTMLCanvasElement { getContext(contextId: 'webgpu'): GPUCanvasContext | null; } - -declare var webkitOfflineAudioContext: typeof OfflineAudioContext | undefined; diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts index 86a6c02..ef4cfc3 100644 --- a/e2e/app.spec.ts +++ b/e2e/app.spec.ts @@ -133,3 +133,37 @@ test('shows a clear fallback when WebGPU is unavailable', async ({ page }) => { await expect(fallback).toContainText('webgpu-unsupported'); expect(browserFailures).toEqual([]); }); + +test('keeps audio focus outlines scoped to the active control', async ({ page }) => { + await disableWebGpu(page); + await page.goto('/'); + await expect(page.locator('body')).not.toHaveClass(/is-loading/); + + const audioControl = page.locator('.audio-control'); + const soundButton = page.locator('button.sound'); + const volumeSlider = page.locator('.volume-slider'); + + await soundButton.click(); + await expect(audioControl).toHaveCSS('outline-style', 'none'); + await expect(soundButton).toHaveCSS('outline-style', 'none'); + + await page.mouse.click(10, 10); + for (let tabIndex = 0; tabIndex < 12; tabIndex += 1) { + await page.keyboard.press('Tab'); + const activeClass = await page.evaluate(() => + String(document.activeElement?.className ?? '') + ); + if (activeClass.includes('sound')) { + break; + } + } + + await expect(soundButton).toBeFocused(); + await expect(soundButton).toHaveCSS('outline-style', 'solid'); + await expect(soundButton).toHaveCSS('outline-offset', '-4px'); + + await page.keyboard.press('Tab'); + await expect(volumeSlider).toBeFocused(); + await expect(volumeSlider).toHaveCSS('outline-style', 'solid'); + await expect(volumeSlider).toHaveCSS('outline-offset', '-4px'); +}); diff --git a/index.html b/index.html index 884b053..16f5c13 100644 --- a/index.html +++ b/index.html @@ -47,20 +47,24 @@
-
@@ -141,57 +145,57 @@
- - + + diff --git a/package-lock.json b/package-lock.json index 8d4a2d5..89cdaff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@vitejs/plugin-basic-ssl": "^2.3.0", "@webgpu/types": "^0.1.69", "browserslist": "^4.28.2", + "browserslist-to-esbuild": "^2.1.1", "eslint": "^10.3.0", "eslint-config-prettier": "^10.1.8", "gl-matrix": "^3.4.4", @@ -2832,6 +2833,25 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/browserslist-to-esbuild": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/browserslist-to-esbuild/-/browserslist-to-esbuild-2.1.1.tgz", + "integrity": "sha512-KN+mty6C3e9AN8Z5dI1xeN15ExcRNeISoC3g7V0Kax/MMF9MSoYA2G7lkTTcVUFntiEjkpI0HNgqJC1NjdyNUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "meow": "^13.0.0" + }, + "bin": { + "browserslist-to-esbuild": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "browserslist": "*" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -3954,6 +3974,19 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/meow": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", + "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", diff --git a/package.json b/package.json index ba2e8c3..dbc6789 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "@vitejs/plugin-basic-ssl": "^2.3.0", "@webgpu/types": "^0.1.69", "browserslist": "^4.28.2", + "browserslist-to-esbuild": "^2.1.1", "eslint": "^10.3.0", "eslint-config-prettier": "^10.1.8", "gl-matrix": "^3.4.4", diff --git a/src/analytics.ts b/src/analytics.ts index f025389..78731cd 100644 --- a/src/analytics.ts +++ b/src/analytics.ts @@ -52,6 +52,10 @@ export const trackVibeChange = ({ }); }; +export const trackStart = () => { + track('Start'); +}; + export const trackExport = ({ vibeId }: { vibeId: VibeId }) => { track('Export', { props: { diff --git a/src/audio/garden-audio-config.ts b/src/audio/garden-audio-config.ts index 78abfd5..b6722a5 100644 --- a/src/audio/garden-audio-config.ts +++ b/src/audio/garden-audio-config.ts @@ -8,201 +8,6 @@ export interface GardenAudioChord { quality: GardenAudioChordQuality; } -interface GardenAudioStyleVoice { - scaleDegreeOffset: number; - velocityMultiplier: number; - panOffset: number; -} - -export interface GardenAudioRegister { - midiMin: number; - midiMax: number; - preferredMidi: number; - pan: number; -} - -export interface GardenAudioStylePool extends GardenAudioRegister { - scaleDegrees: Array; -} - -interface GardenAudioGenerativePianoConfig { - stylePools: [GardenAudioStylePool, GardenAudioStylePool, GardenAudioStylePool]; - padRegisters: [GardenAudioRegister, GardenAudioRegister, GardenAudioRegister]; - chordVoicings: { - majorOpen: Array; - minorOpen: Array; - majorClosed: Array; - minorClosed: Array; - }; - 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 interface GardenAudioVibeProfile { rootMidi: number; scale: Array; @@ -215,7 +20,6 @@ export interface GardenAudioConfig { masterVolume: number; fadeInSeconds: number; updateRampSeconds: number; - highPassFrequencyHz: number; delay: { timeSeconds: number; feedback: number; @@ -228,44 +32,24 @@ export interface GardenAudioConfig { outputBase: number; outputActivityDuck: number; timeRampSeconds: number; - feedbackHighPassHz: number; - feedbackLowPassHz: number; - returnLowPassHz: number; }; piano: { maxVoices: number; - filterType: BiquadFilterType; gain: number; sustainSeconds: number; sustainLevel: number; releaseSeconds: number; lowpassHz: number; - filterQ: number; gainAttackSeconds: number; lowpassMaxHz: number; lowpassMinHz: number; - minDurationSeconds: number; - minFadeSeconds: number; - minGain: number; - pitchSemitonesPerOctave: number; - scheduleAheadSeconds: number; sustainBase: number; sustainVelocityRange: number; - tailStopExtraSeconds: number; - voiceStealFadeSeconds: number; - voiceStealStopSeconds: number; - sampleBaseUrl: string; - preloadDecode: { - channels: number; - frames: number; - sampleRateHz: number; - }; }; rhythm: { bpm: number; stepsPerBeat: number; stepsPerBar: number; - lookaheadSeconds: number; sparseActivity: number; }; eraser: { @@ -285,32 +69,11 @@ export interface GardenAudioConfig { strokeDecaySeconds: number; }; graph: { - closeGain: number; - closeRampSeconds: number; - delayMaxSeconds: number; - eventBusGain: number; - noiseMax: number; - noiseMin: number; - unlockTickFrequencyHz: number; - unlockTickSeconds: number; - unlockTickType: OscillatorType; - latencyHint: AudioContextLatencyCategory; - outputFilterType: BiquadFilterType; - noiseBufferChannels: number; - noiseBufferDurationSeconds: number; pianoBusGains: Record; pianoBusActivityDucking: Record; noiseBusGain: number; - compressor: { - thresholdDb: number; - kneeDb: number; - ratio: number; - attackSeconds: number; - releaseSeconds: number; - }; }; input: { - fallbackFrameSeconds: number; fullActivitySpeed: number; activityNoiseFloorSpeed: number; activityCurve: number; @@ -321,22 +84,7 @@ export interface GardenAudioConfig { manicActivityThreshold: number; manicReleaseThreshold: number; maniaSmoothingSeconds: number; - minElapsedSeconds: number; }; - muteGain: number; - muteRampSeconds: number; - noiseBurst: { - attackSeconds: number; - filterQ: number; - offsetRandomSeconds: number; - scheduleAheadSeconds: number; - silentGain: number; - filterType: BiquadFilterType; - }; - startDelaySeconds: number; - vibeChangeStingerMinIntervalSeconds: number; - generativePiano: GardenAudioGenerativePianoConfig; - styleVoices: [GardenAudioStyleVoice, GardenAudioStyleVoice, GardenAudioStyleVoice]; } export const gardenAudioConfig: GardenAudioConfig = appConfig.audio; diff --git a/src/audio/garden-audio-energy.test.ts b/src/audio/garden-audio-energy.test.ts deleted file mode 100644 index ac6a6be..0000000 --- a/src/audio/garden-audio-energy.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { gardenAudioConfig } from './garden-audio-config'; -import { GardenAudioEnergy } from './garden-audio-energy'; - -describe('GardenAudioEnergy', () => { - it('suspends activity but keeps a fading level when the gesture ends', () => { - const energy = new GardenAudioEnergy(gardenAudioConfig); - - energy.beginGesture(0); - energy.recordStroke(0.8, 0.1); - energy.update(0.1); - energy.update(0.2); - - const levelBeforeLift = energy.getLevel(); - expect(energy.getActivity()).toBeGreaterThan(0); - - energy.endGesture(); - - expect(energy.getActivity()).toBe(0); - expect(energy.getLevel()).toBe(levelBeforeLift); - energy.update(0.3); - expect(energy.getLevel()).toBeLessThan(levelBeforeLift); - expect(energy.getLevel()).toBeGreaterThan(0); - }); - - it('uses recent stroke intensity rather than gesture duration alone', () => { - const energy = new GardenAudioEnergy(gardenAudioConfig); - - energy.beginGesture(0); - energy.recordStroke(1, 0.1); - energy.update(0.1); - energy.update(0.2); - const activeLevel = energy.getActivity(); - - energy.update(1.2); - - expect(energy.getActivity()).toBeLessThan(activeLevel); - }); - - it('raises activity immediately when a stroke is recorded', () => { - const energy = new GardenAudioEnergy(gardenAudioConfig); - - energy.beginGesture(0); - energy.recordStroke(0.12, 0.05); - - expect(energy.getActivity()).toBeGreaterThan(0.09); - }); -}); diff --git a/src/audio/garden-audio-energy.ts b/src/audio/garden-audio-energy.ts index 67640c5..f6e689d 100644 --- a/src/audio/garden-audio-energy.ts +++ b/src/audio/garden-audio-energy.ts @@ -1,4 +1,4 @@ -import { clamp01 } from '../utils/clamp'; +import { approach, clamp01 } from '../utils/math'; import type { GardenAudioConfig } from './garden-audio-config'; export class GardenAudioEnergy { @@ -59,8 +59,7 @@ export class GardenAudioEnergy { } else if (target > this.energy) { timeConstant = this.config.energy.attackSeconds; } - const amount = 1 - Math.exp(-elapsedSeconds / timeConstant); - this.energy += (target - this.energy) * amount; + this.energy = approach(this.energy, target, elapsedSeconds, timeConstant); } public getActivity(): number { diff --git a/src/audio/garden-audio-gesture-state.test.ts b/src/audio/garden-audio-gesture-state.test.ts deleted file mode 100644 index 20ee038..0000000 --- a/src/audio/garden-audio-gesture-state.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { gardenAudioConfig } from './garden-audio-config'; -import { GardenAudioGestureState } from './garden-audio-gesture-state'; -import type { GardenAudioStrokeMetrics } from './garden-audio-input'; - -const makeMetrics = ({ - elapsedSeconds, - normalizedDistance, -}: { - elapsedSeconds: number; - normalizedDistance: number; -}): GardenAudioStrokeMetrics => ({ - distancePixels: normalizedDistance * 1000, - elapsedSeconds, - normalizedDistance, - normalizedSpeed: normalizedDistance / elapsedSeconds, -}); - -describe('GardenAudioGestureState', () => { - it('ignores tiny jitter below the activity speed floor', () => { - const state = new GardenAudioGestureState(gardenAudioConfig.input); - - state.beginGesture(); - - expect( - state.recordStroke({ - metrics: makeMetrics({ - elapsedSeconds: 0.1, - normalizedDistance: 0.001, - }), - }).activity - ).toBe(0); - }); - - it('normalizes equal drawing speeds across pointer sample rates', () => { - const lowRate = new GardenAudioGestureState(gardenAudioConfig.input); - const highRate = new GardenAudioGestureState(gardenAudioConfig.input); - - lowRate.beginGesture(); - highRate.beginGesture(); - - const lowRateFrame = lowRate.recordStroke({ - metrics: makeMetrics({ - elapsedSeconds: 0.1, - normalizedDistance: 0.07, - }), - }); - - let highRateFrame = { activity: 0, maniaAmount: 0 }; - for (let index = 0; index < 5; index += 1) { - highRateFrame = highRate.recordStroke({ - metrics: makeMetrics({ - elapsedSeconds: 0.02, - normalizedDistance: 0.014, - }), - }); - } - - expect(highRateFrame.activity).toBeCloseTo(lowRateFrame.activity, 5); - }); - - it('holds mania with hysteresis before releasing', () => { - const state = new GardenAudioGestureState(gardenAudioConfig.input); - - state.beginGesture(); - - const manicFrame = state.recordStroke({ - metrics: makeMetrics({ - elapsedSeconds: 0.25, - normalizedDistance: 0.3, - }), - }); - - expect(manicFrame.maniaAmount).toBeGreaterThan(0); - - const heldFrame = state.recordStroke({ - metrics: makeMetrics({ - elapsedSeconds: 0.06, - normalizedDistance: 0.04, - }), - }); - - expect(heldFrame.maniaAmount).toBeGreaterThan(0); - - const releasedFrame = state.recordStroke({ - metrics: makeMetrics({ - elapsedSeconds: 0.7, - normalizedDistance: 0, - }), - }); - - expect(releasedFrame.maniaAmount).toBeLessThan(heldFrame.maniaAmount); - }); -}); diff --git a/src/audio/garden-audio-gesture-state.ts b/src/audio/garden-audio-gesture-state.ts index 4551adb..7d85364 100644 --- a/src/audio/garden-audio-gesture-state.ts +++ b/src/audio/garden-audio-gesture-state.ts @@ -1,4 +1,4 @@ -import { clamp, clamp01 } from '../utils/clamp'; +import { approach, clamp, clamp01, smoothstep } from '../utils/math'; import type { GardenAudioConfig } from './garden-audio-config'; import type { GardenAudioStrokeMetrics } from './garden-audio-input'; @@ -92,18 +92,3 @@ export class GardenAudioGestureState { return clamp(activity * distanceAmount, 0, this.inputConfig.activitySoftCeiling); } } - -const approach = ( - current: number, - target: number, - elapsedSeconds: number, - timeConstantSeconds: number -): number => { - const amount = 1 - Math.exp(-elapsedSeconds / Math.max(0.001, timeConstantSeconds)); - return current + (target - current) * amount; -}; - -const smoothstep = (edge0: number, edge1: number, value: number): number => { - const amount = clamp01((value - edge0) / (edge1 - edge0)); - return amount * amount * (3 - 2 * amount); -}; diff --git a/src/audio/garden-audio-graph.test.ts b/src/audio/garden-audio-graph.test.ts deleted file mode 100644 index 8d5031d..0000000 --- a/src/audio/garden-audio-graph.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import { VIBE_PRESETS } from '../vibes'; -import { gardenAudioConfig } from './garden-audio-config'; -import { GardenAudioGraph } from './garden-audio-graph'; - -class FakeAudioParam { - public value = 0; - public setTargetAtTime = vi.fn(); -} - -class FakeAudioNode { - public readonly gain = new FakeAudioParam(); - public readonly frequency = new FakeAudioParam(); - public readonly threshold = new FakeAudioParam(); - public readonly knee = new FakeAudioParam(); - public readonly ratio = new FakeAudioParam(); - public readonly attack = new FakeAudioParam(); - public readonly release = new FakeAudioParam(); - public readonly delayTime = new FakeAudioParam(); - public readonly connections: Array = []; - public type = ''; - - public connect(target: unknown): unknown { - this.connections.push(target); - return target; - } -} - -class FakeAudioBuffer { - private readonly data: Float32Array; - - public constructor(length: number) { - this.data = new Float32Array(length); - } - - public getChannelData(): Float32Array { - return this.data; - } -} - -class FakeAudioContext { - public readonly currentTime = 1; - public readonly destination = new FakeAudioNode() as unknown as AudioDestinationNode; - public readonly sampleRate = 16; - public readonly state = 'running'; - public readonly compressors: Array = []; - - public createGain(): GainNode { - return new FakeAudioNode() as unknown as GainNode; - } - - public createBiquadFilter(): BiquadFilterNode { - return new FakeAudioNode() as unknown as BiquadFilterNode; - } - - public createDelay(): DelayNode { - return new FakeAudioNode() as unknown as DelayNode; - } - - public createDynamicsCompressor(): DynamicsCompressorNode { - const node = new FakeAudioNode(); - this.compressors.push(node); - return node as unknown as DynamicsCompressorNode; - } - - public createBuffer(_channels: number, length: number): AudioBuffer { - return new FakeAudioBuffer(length) as unknown as AudioBuffer; - } -} - -describe('GardenAudioGraph', () => { - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it('builds controlled output, role buses, and delay automation', () => { - vi.stubGlobal('AudioContext', FakeAudioContext); - const graph = new GardenAudioGraph(gardenAudioConfig); - const context = graph.ensureContext(true) as unknown as FakeAudioContext; - - expect(context.compressors).toHaveLength(1); - expect(graph.getPianoBus('pad')).not.toBeNull(); - expect(graph.getPianoBus('pad')).not.toBe(graph.getPianoBus('gesture')); - expect(graph.noiseBus).not.toBeNull(); - - graph.updateDelay(VIBE_PRESETS[0].audio, 1); - - expect(graph.getPianoBus('pad')?.gain.setTargetAtTime).toHaveBeenCalled(); - expect(graph.getPianoBus('brush')?.gain.setTargetAtTime).toHaveBeenCalled(); - }); -}); diff --git a/src/audio/garden-audio-graph.ts b/src/audio/garden-audio-graph.ts index cd6c4bb..2862e5a 100644 --- a/src/audio/garden-audio-graph.ts +++ b/src/audio/garden-audio-graph.ts @@ -1,7 +1,38 @@ -import { clamp } from '../utils/clamp'; -import { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config'; +import { clamp } from '../utils/math'; +import { isIosLike } from './audio-platform'; +import type { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config'; import type { PianoNoteRole } from './garden-audio-types'; +const outputHighPassFrequencyHz = 45; +const graphTuning = { + closeGain: 0.0001, + closeRampSeconds: 0.015, + delayMaxSeconds: 2, + eventBusGain: 1, + noiseMax: 1, + noiseMin: -1, + latencyHint: 'interactive' as AudioContextLatencyCategory, + outputFilterType: 'highpass' as BiquadFilterType, + noiseBufferChannels: 1, + noiseBufferDurationSeconds: 1, + unlockTickFrequencyHz: 440, + unlockTickGain: 0.0001, + unlockTickSeconds: 0.025, + unlockTickType: 'sine' as OscillatorType, + compressor: { + thresholdDb: -18, + kneeDb: 18, + ratio: 2.1, + attackSeconds: 0.018, + releaseSeconds: 0.18, + }, +}; +const delayFilterTuning = { + feedbackHighPassHz: 180, + feedbackLowPassHz: 5200, + returnLowPassHz: 6200, +}; + export class GardenAudioGraph { public context: AudioContext | null = null; public eventBus: GainNode | null = null; @@ -13,6 +44,8 @@ export class GardenAudioGraph { private delayNode: DelayNode | null = null; private delayFeedback: GainNode | null = null; private delayOutput: GainNode | null = null; + private mediaStreamDestination: MediaStreamAudioDestinationNode | null = null; + private mediaStreamElement: HTMLAudioElement | null = null; private readonly pianoBuses = new Map(); public constructor(private readonly config: GardenAudioConfig) {} @@ -34,7 +67,7 @@ export class GardenAudioGraph { let context: AudioContext; try { context = new AudioContextConstructor({ - latencyHint: this.config.graph.latencyHint, + latencyHint: graphTuning.latencyHint, }); } catch { context = new AudioContextConstructor(); @@ -42,22 +75,30 @@ export class GardenAudioGraph { const masterGain = context.createGain(); const highPass = context.createBiquadFilter(); const compressor = context.createDynamicsCompressor(); + const mediaStreamDestination = isIosLike() + ? context.createMediaStreamDestination() + : null; masterGain.gain.value = 0; - highPass.type = this.config.graph.outputFilterType; - highPass.frequency.value = this.config.highPassFrequencyHz; - compressor.threshold.value = this.config.graph.compressor.thresholdDb; - compressor.knee.value = this.config.graph.compressor.kneeDb; - compressor.ratio.value = this.config.graph.compressor.ratio; - compressor.attack.value = this.config.graph.compressor.attackSeconds; - compressor.release.value = this.config.graph.compressor.releaseSeconds; + highPass.type = graphTuning.outputFilterType; + highPass.frequency.value = outputHighPassFrequencyHz; + compressor.threshold.value = graphTuning.compressor.thresholdDb; + compressor.knee.value = graphTuning.compressor.kneeDb; + compressor.ratio.value = graphTuning.compressor.ratio; + compressor.attack.value = graphTuning.compressor.attackSeconds; + compressor.release.value = graphTuning.compressor.releaseSeconds; masterGain.connect(highPass); highPass.connect(compressor); - compressor.connect(context.destination); + if (mediaStreamDestination) { + compressor.connect(mediaStreamDestination); + } else { + compressor.connect(context.destination); + } this.context = context; this.masterGain = masterGain; + this.mediaStreamDestination = mediaStreamDestination; this.noiseBuffer = this.createNoiseBuffer(context); this.createDelay(context, masterGain); this.createBuses(context, masterGain); @@ -77,17 +118,13 @@ export class GardenAudioGraph { const source = this.context.createOscillator(); const gain = this.context.createGain(); - source.type = this.config.graph.unlockTickType; - source.frequency.setValueAtTime(this.config.graph.unlockTickFrequencyHz, now); - gain.gain.setValueAtTime(this.config.piano.minGain, now); - gain.gain.exponentialRampToValueAtTime( - this.config.piano.minGain, - now + this.config.graph.unlockTickSeconds - ); + source.type = graphTuning.unlockTickType; + source.frequency.setValueAtTime(graphTuning.unlockTickFrequencyHz, now); + gain.gain.setValueAtTime(graphTuning.unlockTickGain, now); source.connect(gain); gain.connect(this.context.destination); source.start(now); - source.stop(now + this.config.graph.unlockTickSeconds); + source.stop(now + graphTuning.unlockTickSeconds); source.addEventListener( 'ended', () => { @@ -110,6 +147,38 @@ export class GardenAudioGraph { ); } + public startMediaElementOutput(): void { + if (!this.mediaStreamDestination) { + return; + } + + const mediaElement = this.ensureMediaStreamElement(); + const playPromise = mediaElement.play(); + void playPromise?.catch(() => undefined); + } + + private ensureMediaStreamElement(): HTMLAudioElement { + if (this.mediaStreamElement) { + return this.mediaStreamElement; + } + + const mediaElement = document.createElement('audio'); + mediaElement.autoplay = true; + mediaElement.volume = 1; + mediaElement.setAttribute('playsinline', ''); + mediaElement.setAttribute('aria-hidden', 'true'); + mediaElement.style.position = 'fixed'; + mediaElement.style.width = '1px'; + mediaElement.style.height = '1px'; + mediaElement.style.opacity = '0'; + mediaElement.style.pointerEvents = 'none'; + mediaElement.style.left = '-9999px'; + mediaElement.srcObject = this.mediaStreamDestination?.stream ?? null; + document.body.append(mediaElement); + this.mediaStreamElement = mediaElement; + return mediaElement; + } + public applyDelayProfile(profile: GardenAudioVibeProfile): void { if (!this.context || !this.delayNode) { return; @@ -167,9 +236,9 @@ export class GardenAudioGraph { if (this.masterGain && context.state !== 'closed') { this.masterGain.gain.setTargetAtTime( - this.config.graph.closeGain, + graphTuning.closeGain, context.currentTime, - this.config.graph.closeRampSeconds + graphTuning.closeRampSeconds ); } @@ -182,7 +251,7 @@ export class GardenAudioGraph { private createDelay(context: AudioContext, masterGain: GainNode): void { const delayInput = context.createGain(); - const delayNode = context.createDelay(this.config.graph.delayMaxSeconds); + const delayNode = context.createDelay(graphTuning.delayMaxSeconds); const delayFeedback = context.createGain(); const delayOutput = context.createGain(); const feedbackHighPass = context.createBiquadFilter(); @@ -193,11 +262,11 @@ export class GardenAudioGraph { delayFeedback.gain.value = this.config.delay.feedback; delayOutput.gain.value = this.config.delay.wetGain; feedbackHighPass.type = 'highpass'; - feedbackHighPass.frequency.value = this.config.delay.feedbackHighPassHz; + feedbackHighPass.frequency.value = delayFilterTuning.feedbackHighPassHz; feedbackLowPass.type = 'lowpass'; - feedbackLowPass.frequency.value = this.config.delay.feedbackLowPassHz; + feedbackLowPass.frequency.value = delayFilterTuning.feedbackLowPassHz; returnLowPass.type = 'lowpass'; - returnLowPass.frequency.value = this.config.delay.returnLowPassHz; + returnLowPass.frequency.value = delayFilterTuning.returnLowPassHz; delayInput.connect(delayNode); delayNode.connect(feedbackHighPass); @@ -216,7 +285,7 @@ export class GardenAudioGraph { private createBuses(context: AudioContext, masterGain: GainNode): void { const eventBus = context.createGain(); - eventBus.gain.value = this.config.graph.eventBusGain; + eventBus.gain.value = graphTuning.eventBusGain; eventBus.connect(masterGain); this.eventBus = eventBus; this.pianoBuses.clear(); @@ -249,12 +318,11 @@ export class GardenAudioGraph { private createNoiseBuffer(context: AudioContext): AudioBuffer { const buffer = context.createBuffer( - appPositiveInteger(this.config.graph.noiseBufferChannels), + appPositiveInteger(graphTuning.noiseBufferChannels), Math.max( 1, Math.floor( - context.sampleRate * - Math.max(0.001, this.config.graph.noiseBufferDurationSeconds) + context.sampleRate * Math.max(0.001, graphTuning.noiseBufferDurationSeconds) ) ), context.sampleRate @@ -263,8 +331,8 @@ export class GardenAudioGraph { for (let index = 0; index < data.length; index++) { data[index] = - this.config.graph.noiseMin + - Math.random() * (this.config.graph.noiseMax - this.config.graph.noiseMin); + graphTuning.noiseMin + + Math.random() * (graphTuning.noiseMax - graphTuning.noiseMin); } return buffer; @@ -280,6 +348,13 @@ export class GardenAudioGraph { this.delayNode = null; this.delayFeedback = null; this.delayOutput = null; + this.mediaStreamDestination = null; + if (this.mediaStreamElement) { + this.mediaStreamElement.pause(); + this.mediaStreamElement.srcObject = null; + this.mediaStreamElement.remove(); + this.mediaStreamElement = null; + } this.pianoBuses.clear(); } } diff --git a/src/audio/garden-audio-input.test.ts b/src/audio/garden-audio-input.test.ts deleted file mode 100644 index 32cc1f6..0000000 --- a/src/audio/garden-audio-input.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { gardenAudioConfig } from './garden-audio-config'; -import { getStrokeMetrics } from './garden-audio-input'; - -describe('getStrokeMetrics', () => { - it('normalizes stroke distance against canvas size', () => { - const standardDensity = getStrokeMetrics( - { - vibe: {} as never, - from: [0, 0], - to: [100, 0], - canvasSize: [1000, 500], - elapsedSeconds: 0.1, - isErasing: false, - }, - gardenAudioConfig.input - ); - const highDensity = getStrokeMetrics( - { - vibe: {} as never, - from: [0, 0], - to: [200, 0], - canvasSize: [2000, 1000], - elapsedSeconds: 0.1, - isErasing: false, - }, - gardenAudioConfig.input - ); - - expect(highDensity.normalizedDistance).toBeCloseTo( - standardDensity.normalizedDistance - ); - expect(highDensity.normalizedSpeed).toBeCloseTo(standardDensity.normalizedSpeed); - }); - - it('uses configured elapsed-time floors for missing or invalid samples', () => { - const metrics = getStrokeMetrics( - { - vibe: {} as never, - from: [0, 0], - to: [10, 0], - elapsedSeconds: 0, - isErasing: false, - }, - gardenAudioConfig.input - ); - - expect(metrics.elapsedSeconds).toBe(gardenAudioConfig.input.fallbackFrameSeconds); - expect(Number.isFinite(metrics.normalizedSpeed)).toBe(true); - }); -}); diff --git a/src/audio/garden-audio-input.ts b/src/audio/garden-audio-input.ts index 75522fd..ffe4bef 100644 --- a/src/audio/garden-audio-input.ts +++ b/src/audio/garden-audio-input.ts @@ -1,7 +1,8 @@ -import type { GardenAudioConfig } from './garden-audio-config'; import type { GardenAudioStroke } from './garden-audio-types'; const fallbackNormalizationPixels = 1000; +const fallbackFrameSeconds = 1 / 60; +const minElapsedSeconds = 0.001; export interface GardenAudioStrokeMetrics { distancePixels: number; @@ -10,14 +11,11 @@ export interface GardenAudioStrokeMetrics { normalizedSpeed: number; } -export const getStrokeMetrics = ( - stroke: GardenAudioStroke, - inputConfig: GardenAudioConfig['input'] -): GardenAudioStrokeMetrics => { +export const getStrokeMetrics = (stroke: GardenAudioStroke): GardenAudioStrokeMetrics => { const dx = stroke.to[0] - stroke.from[0]; const dy = stroke.to[1] - stroke.from[1]; const distancePixels = Math.hypot(dx, dy); - const elapsedSeconds = getElapsedSeconds(stroke, inputConfig); + const elapsedSeconds = getElapsedSeconds(stroke); const normalizedDistance = distancePixels / getStrokeNormalizationPixels(stroke); return { @@ -28,19 +26,16 @@ export const getStrokeMetrics = ( }; }; -const getElapsedSeconds = ( - stroke: GardenAudioStroke, - inputConfig: GardenAudioConfig['input'] -): number => { +const getElapsedSeconds = (stroke: GardenAudioStroke): number => { if ( stroke.elapsedSeconds !== undefined && Number.isFinite(stroke.elapsedSeconds) && stroke.elapsedSeconds > 0 ) { - return Math.max(inputConfig.minElapsedSeconds, stroke.elapsedSeconds); + return Math.max(minElapsedSeconds, stroke.elapsedSeconds); } - return inputConfig.fallbackFrameSeconds; + return fallbackFrameSeconds; }; const getStrokeNormalizationPixels = (stroke: GardenAudioStroke): number => { diff --git a/src/audio/garden-audio-music.ts b/src/audio/garden-audio-music.ts index 417065c..8b4ee2d 100644 --- a/src/audio/garden-audio-music.ts +++ b/src/audio/garden-audio-music.ts @@ -1,9 +1,14 @@ -import { VibePreset } from '../vibes'; -import { - GardenAudioChord, - gardenAudioConfig, - GardenAudioVibeProfile, -} from './garden-audio-config'; +import type { VibePreset } from '../vibes'; +import type { GardenAudioChord, GardenAudioVibeProfile } from './garden-audio-config'; + +export const PITCH_SEMITONES_PER_OCTAVE = 12; + +const chordVoicings = { + majorOpen: [0, 7, 12, 16], + minorOpen: [0, 7, 12, 15], + majorClosed: [0, 4, 7, 12, 16], + minorClosed: [0, 3, 7, 12, 15], +}; export const getVibeProfile = (vibe: VibePreset): GardenAudioVibeProfile => vibe.audio; @@ -12,14 +17,12 @@ export const getChordIntervals = ( openVoicing: boolean ): Array => { if (openVoicing) { - return chord.quality === 'major' - ? gardenAudioConfig.generativePiano.chordVoicings.majorOpen - : gardenAudioConfig.generativePiano.chordVoicings.minorOpen; + return chord.quality === 'major' ? chordVoicings.majorOpen : chordVoicings.minorOpen; } return chord.quality === 'major' - ? gardenAudioConfig.generativePiano.chordVoicings.majorClosed - : gardenAudioConfig.generativePiano.chordVoicings.minorClosed; + ? chordVoicings.majorClosed + : chordVoicings.minorClosed; }; export const degreeToSemitone = ( @@ -29,7 +32,5 @@ export const degreeToSemitone = ( const scaleIndex = ((degree % profile.scale.length) + profile.scale.length) % profile.scale.length; const octave = Math.floor(degree / profile.scale.length); - return ( - profile.scale[scaleIndex] + octave * gardenAudioConfig.piano.pitchSemitonesPerOctave - ); + return profile.scale[scaleIndex] + octave * PITCH_SEMITONES_PER_OCTAVE; }; diff --git a/src/audio/garden-audio.test.ts b/src/audio/garden-audio.test.ts deleted file mode 100644 index ba15c57..0000000 --- a/src/audio/garden-audio.test.ts +++ /dev/null @@ -1,326 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { appConfig } from '../config'; -import { VIBE_PRESETS } from '../vibes'; -import { gardenAudioConfig, GardenAudioConfig } from './garden-audio-config'; - -type FakeScheduledSourceNode = { - start: ReturnType; - stop: ReturnType; -}; - -const calls = { - constructed: 0, - resumed: 0, - sourcesStarted: 0, - sources: [] as Array, - gains: [] as Array, -}; - -let contextState: AudioContextState = 'suspended'; -let resumeError: Error | null = null; -let ErrorHandler: typeof import('../utils/error-handler').ErrorHandler; -let GardenAudio: typeof import('./garden-audio').GardenAudio; -let loadPianoSamples: typeof import('./piano-samples').loadPianoSamples; -let Severity: typeof import('../utils/error-handler').Severity; - -class FakeAudioParam { - public value = 0; - public setTargetAtTime = vi.fn(); - public setValueAtTime = vi.fn(); - public exponentialRampToValueAtTime = vi.fn(); - public cancelScheduledValues = vi.fn(); -} - -class FakeAudioNode { - public readonly gain = new FakeAudioParam(); - public readonly frequency = new FakeAudioParam(); - public readonly playbackRate = new FakeAudioParam(); - public readonly Q = new FakeAudioParam(); - public readonly threshold = new FakeAudioParam(); - public readonly knee = new FakeAudioParam(); - public readonly ratio = new FakeAudioParam(); - public readonly attack = new FakeAudioParam(); - public readonly release = new FakeAudioParam(); - public readonly delayTime = new FakeAudioParam(); - public readonly pan = new FakeAudioParam(); - public type = ''; - public addEventListener = vi.fn(); - public connect = vi.fn(); - public disconnect = vi.fn(); -} - -class FakeAudioBuffer { - private readonly data: Float32Array; - - public constructor(length: number) { - this.data = new Float32Array(length); - } - - public getChannelData(): Float32Array { - return this.data; - } -} - -class FakeAudioContext { - public readonly currentTime = 1; - public readonly sampleRate = 16; - public readonly destination = new FakeAudioNode() as unknown as AudioDestinationNode; - public readonly decodeAudioData = vi.fn(async () => ({}) as AudioBuffer); - - public constructor() { - calls.constructed += 1; - } - - public get state(): AudioContextState { - return contextState; - } - - public set state(state: AudioContextState) { - contextState = state; - } - - public createGain(): GainNode { - const node = new FakeAudioNode(); - calls.gains.push(node); - return node as unknown as GainNode; - } - - public createBiquadFilter(): BiquadFilterNode { - return new FakeAudioNode() as unknown as BiquadFilterNode; - } - - public createDelay(): DelayNode { - return new FakeAudioNode() as unknown as DelayNode; - } - - public createDynamicsCompressor(): DynamicsCompressorNode { - return new FakeAudioNode() as unknown as DynamicsCompressorNode; - } - - public createStereoPanner(): StereoPannerNode { - return new FakeAudioNode() as unknown as StereoPannerNode; - } - - public createBuffer(_channels: number, length: number): AudioBuffer { - return new FakeAudioBuffer(length) as unknown as AudioBuffer; - } - - public createBufferSource(): AudioBufferSourceNode { - const node = new FakeAudioNode() as unknown as AudioBufferSourceNode & { - buffer: AudioBuffer | null; - start: () => void; - stop: () => void; - }; - node.buffer = null; - node.start = vi.fn(() => { - calls.sourcesStarted += 1; - }) as unknown as typeof node.start; - node.stop = vi.fn() as unknown as typeof node.stop; - calls.sources.push(node as unknown as FakeScheduledSourceNode); - return node; - } - - public createOscillator(): OscillatorNode { - const node = new FakeAudioNode() as unknown as OscillatorNode & { - start: () => void; - stop: () => void; - }; - node.start = vi.fn(() => { - calls.sourcesStarted += 1; - }) as unknown as typeof node.start; - node.stop = vi.fn() as unknown as typeof node.stop; - calls.sources.push(node as unknown as FakeScheduledSourceNode); - return node; - } - - public async resume(): Promise { - calls.resumed += 1; - if (resumeError) { - throw resumeError; - } - contextState = 'running'; - } -} - -const makeConfig = (): GardenAudioConfig => ({ - ...gardenAudioConfig, -}); - -describe('GardenAudio startup policy', () => { - beforeEach(async () => { - vi.resetModules(); - ({ ErrorHandler, Severity } = await import('../utils/error-handler')); - ({ GardenAudio } = await import('./garden-audio')); - ({ loadPianoSamples } = await import('./piano-samples')); - - calls.constructed = 0; - calls.resumed = 0; - calls.sourcesStarted = 0; - calls.sources = []; - calls.gains = []; - contextState = 'suspended'; - resumeError = null; - vi.stubGlobal('AudioContext', FakeAudioContext); - vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('not loaded in tests'))); - }); - - afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - }); - - it('does not create an AudioContext from passive audio paths', () => { - const audio = new GardenAudio(makeConfig()); - const vibe = VIBE_PRESETS[0]; - - audio.start(vibe); - audio.stroke({ - vibe, - from: [0, 0], - to: [12, 0], - isErasing: false, - }); - - expect(calls.constructed).toBe(0); - }); - - it('only resumes a suspended context from a user gesture start', () => { - const audio = new GardenAudio(makeConfig()); - const vibe = VIBE_PRESETS[0]; - - audio.start(vibe, { userGesture: true }); - - expect(calls.constructed).toBe(1); - expect(calls.resumed).toBe(1); - expect(contextState).toBe('running'); - - contextState = 'suspended'; - audio.start(vibe); - audio.setMuted(false); - - expect(calls.resumed).toBe(1); - }); - - it('reports AudioContext resume failures as warnings', async () => { - const audio = new GardenAudio(makeConfig()); - const vibe = VIBE_PRESETS[0]; - resumeError = new Error('resume rejected'); - const addException = vi.spyOn(ErrorHandler, 'addException'); - - audio.start(vibe, { userGesture: true }); - await Promise.resolve(); - await Promise.resolve(); - - expect(addException).toHaveBeenCalledWith(resumeError, { - fallbackMessage: 'Could not resume audio playback.', - severity: Severity.WARNING, - }); - }); - - it('updates live master gain from adjustable volume', () => { - const config = makeConfig(); - const audio = new GardenAudio(config); - const vibe = VIBE_PRESETS[0]; - - audio.start(vibe, { userGesture: true }); - const masterGain = calls.gains[0]?.gain; - if (!masterGain) { - throw new Error('Missing fake master gain'); - } - - audio.setMasterVolume(0.2); - - expect(masterGain.setTargetAtTime).toHaveBeenLastCalledWith( - 0.2, - 1, - config.updateRampSeconds - ); - - audio.setMuted(true); - const mutedCallCount = masterGain.setTargetAtTime.mock.calls.length; - audio.setMasterVolume(0.8); - - expect(masterGain.setTargetAtTime).toHaveBeenCalledTimes(mutedCallCount); - - audio.setMuted(false); - - expect(masterGain.setTargetAtTime).toHaveBeenLastCalledWith( - 0.8, - 1, - config.fadeInSeconds - ); - }); - - it('stays silent without piano samples while preserving eraser noise', () => { - const audio = new GardenAudio(makeConfig()); - const vibe = VIBE_PRESETS[0]; - - audio.start(vibe, { userGesture: true }); - expect(calls.sourcesStarted).toBe(1); - - audio.beginGesture(); - audio.stroke({ - vibe, - from: [30, 40], - to: [60, 60], - isErasing: false, - elapsedSeconds: 0.05, - }); - - expect(calls.sourcesStarted).toBe(1); - - audio.stroke({ - vibe, - from: [60, 60], - to: [75, 80], - isErasing: true, - elapsedSeconds: 0.05, - }); - - expect(calls.sourcesStarted).toBe(2); - }); - - it('quickly stops active piano voices when the vibe changes', async () => { - vi.stubGlobal( - 'fetch', - vi.fn(async () => ({ - arrayBuffer: async () => new ArrayBuffer(8), - ok: true, - })) - ); - await loadPianoSamples(new FakeAudioContext() as unknown as AudioContext); - - const audio = new GardenAudio(makeConfig()); - const vibe = VIBE_PRESETS[0]; - - audio.start(vibe, { userGesture: true }); - audio.beginGesture(); - audio.stroke({ - vibe, - from: [30, 40], - to: [90, 40], - elapsedSeconds: 0.05, - isErasing: false, - }); - - const activePianoSources = calls.sources.filter( - (source) => source.stop.mock.calls.length === 1 - ); - expect(activePianoSources.length).toBeGreaterThan(0); - - const stopCounts = activePianoSources.map((source) => source.stop.mock.calls.length); - audio.changeVibe(VIBE_PRESETS[1], { userGesture: true }); - - const stoppedVoices = activePianoSources.filter( - (source, index) => source.stop.mock.calls.length === stopCounts[index] + 1 - ); - expect(stoppedVoices.length).toBeGreaterThan(0); - stoppedVoices.forEach((source) => { - expect(source.stop.mock.calls.at(-1)?.[0]).toBeCloseTo( - 1 + appConfig.audio.piano.voiceStealStopSeconds, - 3 - ); - }); - }); -}); diff --git a/src/audio/garden-audio.ts b/src/audio/garden-audio.ts index ff8e6bd..024a6cb 100644 --- a/src/audio/garden-audio.ts +++ b/src/audio/garden-audio.ts @@ -1,7 +1,7 @@ -import { clamp01 } from '../utils/clamp'; import { ErrorHandler, Severity } from '../utils/error-handler'; +import { clamp01 } from '../utils/math'; import type { VibeId, VibePreset } from '../vibes'; -import { GardenAudioConfig } from './garden-audio-config'; +import type { GardenAudioConfig } from './garden-audio-config'; import { GardenAudioEnergy } from './garden-audio-energy'; import { GardenAudioGestureState } from './garden-audio-gesture-state'; import { GardenAudioGraph } from './garden-audio-graph'; @@ -24,6 +24,12 @@ export type { type AudioLifecycle = 'idle' | 'started' | 'destroyed'; +const muteGain = 0.0001; +const muteRampSeconds = 0.02; +const brushUpPianoFinishSeconds = 1.2; +const brushUpPianoFadeSeconds = 1.1; +const vibeChangeStingerMinIntervalSeconds = 0.45; + export class GardenAudio { private readonly graph: GardenAudioGraph; private readonly piano: PianoSampler; @@ -36,7 +42,10 @@ export class GardenAudio { private lifecycle: AudioLifecycle = 'idle'; private isMuted = false; private isGestureActive = false; + private isPianoStoppedAfterGesture = false; + private fadePianoAfter: number | null = null; private masterVolume: number; + private stopPianoAfter: number | null = null; private lastEraserAt = Number.NEGATIVE_INFINITY; private lastVibeStingerAt = Number.NEGATIVE_INFINITY; @@ -44,50 +53,55 @@ export class GardenAudio { this.masterVolume = clamp01(config.masterVolume); this.graph = new GardenAudioGraph(config); this.piano = new PianoSampler(config, this.graph); - this.noise = new NoiseBurstPlayer(config, this.graph); + this.noise = new NoiseBurstPlayer(this.graph); this.energy = new GardenAudioEnergy(config); this.gestureState = new GardenAudioGestureState(config.input); this.pianoEngine = new GenerativePianoEngine(config, (note) => this.piano.play(note)); } public start(vibe: VibePreset, options: GardenAudioStartOptions = {}): void { - if (this.lifecycle === 'destroyed' || this.isMuted) { + const isUserGesture = options.userGesture === true; + + if (this.lifecycle === 'destroyed') { return; } - const context = this.graph.ensureContext(options.userGesture === true); + const context = this.graph.ensureContext(isUserGesture); if (!context) { return; } - const startupRampSeconds = - options.userGesture === true - ? this.config.muteRampSeconds - : this.config.fadeInSeconds; + const startupRampSeconds = isUserGesture + ? muteRampSeconds + : this.config.fadeInSeconds; const needsResume = context.state !== 'running' && context.state !== 'closed'; let resumePromise: Promise | null = null; + if (isUserGesture) { + this.graph.startMediaElementOutput(); + this.graph.unlock(); + } + if (needsResume) { - if (options.userGesture !== true) { + if (!isUserGesture) { return; } resumePromise = context.resume(); } - if (options.userGesture === true) { + if (isUserGesture) { this.graph.unlock(); } if (resumePromise) { void resumePromise .then(() => { - if ( - this.graph.context === context && - this.lifecycle !== 'destroyed' && - !this.isMuted - ) { + if (this.graph.context === context && this.lifecycle !== 'destroyed') { this.graph.unlock(); - this.graph.setMasterGain(this.masterVolume, startupRampSeconds); + this.completeStart(vibe, { + context, + startupRampSeconds, + }); } }) .catch((error) => { @@ -96,6 +110,32 @@ export class GardenAudio { severity: Severity.WARNING, }); }); + return; + } + + this.completeStart(vibe, { + context, + startupRampSeconds, + }); + } + + private completeStart( + vibe: VibePreset, + { + context, + startupRampSeconds, + }: { + context: AudioContext; + startupRampSeconds: number; + } + ): void { + if (this.graph.context !== context || this.lifecycle === 'destroyed') { + return; + } + + if (this.isMuted) { + this.graph.setMasterGain(muteGain, muteRampSeconds); + return; } this.lifecycle = 'started'; @@ -111,7 +151,12 @@ export class GardenAudio { this.pianoEngine.cue(context.currentTime); } }) - .catch(() => undefined); + .catch((error) => { + ErrorHandler.addException(error, { + fallbackMessage: 'Could not load piano samples. Using synthesized audio.', + severity: Severity.WARNING, + }); + }); } } @@ -143,8 +188,8 @@ export class GardenAudio { this.isMuted = isMuted; this.graph.setMasterGain( - isMuted ? this.config.muteGain : this.masterVolume, - isMuted ? this.config.muteRampSeconds : this.config.fadeInSeconds + isMuted ? muteGain : this.masterVolume, + isMuted ? muteRampSeconds : this.config.fadeInSeconds ); } @@ -162,6 +207,9 @@ export class GardenAudio { } this.isGestureActive = true; + this.isPianoStoppedAfterGesture = false; + this.fadePianoAfter = null; + this.stopPianoAfter = null; this.gestureState.beginGesture(); this.energy.beginGesture(context.currentTime); this.pianoEngine.beginGesture(); @@ -170,6 +218,12 @@ export class GardenAudio { public endGesture(): void { this.gestureState.endGesture(); this.isGestureActive = false; + const context = this.graph.context; + this.isPianoStoppedAfterGesture = true; + this.fadePianoAfter = context + ? context.currentTime + brushUpPianoFinishSeconds + : null; + this.stopPianoAfter = null; this.energy.endGesture(); this.pianoEngine.endGesture(); } @@ -187,6 +241,21 @@ export class GardenAudio { this.energy.silence(); } + if (!this.isGestureActive && this.isPianoStoppedAfterGesture) { + if (this.fadePianoAfter !== null && context.currentTime >= this.fadePianoAfter) { + this.piano.fadeAll(brushUpPianoFadeSeconds); + this.fadePianoAfter = null; + this.stopPianoAfter = context.currentTime + brushUpPianoFadeSeconds; + } + if (this.stopPianoAfter !== null && context.currentTime >= this.stopPianoAfter) { + this.piano.stopAll(); + this.pianoEngine.reset(); + this.stopPianoAfter = null; + } + this.updateDelay(snapshot); + return; + } + this.pianoEngine.renderLookahead({ vibe: snapshot.vibe, now: context.currentTime, @@ -211,7 +280,7 @@ export class GardenAudio { return; } - const metrics = getStrokeMetrics(stroke, this.config.input); + const metrics = getStrokeMetrics(stroke); const now = context.currentTime; const frame = this.gestureState.recordStroke({ metrics }); @@ -242,6 +311,9 @@ export class GardenAudio { this.pianoEngine.reset(); this.currentVibeId = null; this.isGestureActive = false; + this.isPianoStoppedAfterGesture = false; + this.fadePianoAfter = null; + this.stopPianoAfter = null; this.lastEraserAt = Number.NEGATIVE_INFINITY; this.lastVibeStingerAt = Number.NEGATIVE_INFINITY; } @@ -253,7 +325,7 @@ export class GardenAudio { } const now = context.currentTime; - if (now - this.lastVibeStingerAt < this.config.vibeChangeStingerMinIntervalSeconds) { + if (now - this.lastVibeStingerAt < vibeChangeStingerMinIntervalSeconds) { return; } diff --git a/src/audio/generative-piano.test.ts b/src/audio/generative-piano.test.ts deleted file mode 100644 index 59b4135..0000000 --- a/src/audio/generative-piano.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { VIBE_PRESETS } from '../vibes'; -import { gardenAudioConfig } from './garden-audio-config'; -import { PianoNote } from './garden-audio-types'; -import { GenerativePianoEngine } from './generative-piano'; - -const makeEngine = () => { - const notes: Array = []; - const engine = new GenerativePianoEngine(gardenAudioConfig, (note) => { - notes.push(note); - }); - - return { engine, notes }; -}; - -const getBeatSeconds = (): number => 60 / gardenAudioConfig.rhythm.bpm; - -const getBeatsPerBar = (): number => - Math.round( - gardenAudioConfig.rhythm.stepsPerBar / gardenAudioConfig.rhythm.stepsPerBeat - ); - -const renderBars = ( - engine: GenerativePianoEngine, - activity: number, - bars = 8, - now = 0 -) => { - engine.renderLookahead({ - vibe: VIBE_PRESETS[0], - now, - activity, - lookaheadSeconds: getBeatSeconds() * getBeatsPerBar() * bars, - }); -}; - -const average = (values: Array): number => - values.reduce((sum, value) => sum + value, 0) / values.length; - -const uniqueStartTimes = (notes: Array): Array => - Array.from(new Set(notes.map((note) => note.startTime.toFixed(3)))); - -const countNotesBetween = ( - notes: Array, - startSeconds: number, - endSeconds: number -): number => - notes.filter((note) => note.startTime >= startSeconds && note.startTime < endSeconds) - .length; - -const getNoteKey = (note: PianoNote): string => - [ - note.startTime.toFixed(3), - note.midi, - note.role ?? 'none', - note.pan.toFixed(3), - ].join(':'); - -describe('GenerativePianoEngine', () => { - it('plays quiet background music even when the garden is idle', () => { - const { engine, notes } = makeEngine(); - - renderBars(engine, 0); - - expect(notes.length).toBeGreaterThan(0); - expect(notes.some((note) => note.durationSeconds > getBeatSeconds() * 6)).toBe(true); - expect(Math.max(...notes.map((note) => note.velocity))).toBeLessThan(0.12); - }); - - it('keeps the background sparse instead of filling every beat', () => { - const { engine, notes } = makeEngine(); - - renderBars(engine, 0, 4); - - expect(uniqueStartTimes(notes).length).toBeLessThan(8); - }); - - it('lets activity add density without changing the beat grid', () => { - const idle = makeEngine(); - const active = makeEngine(); - const startDelaySeconds = 0.02; - - renderBars(idle.engine, 0, 8); - renderBars(active.engine, 1, 8); - - expect(active.notes.length).toBeGreaterThan(idle.notes.length); - active.notes.forEach((note) => { - const beatsFromStart = (note.startTime - startDelaySeconds) / getBeatSeconds(); - expect(Math.abs(beatsFromStart - Math.round(beatsFromStart))).toBeLessThan(0.001); - }); - }); - - it('uses style pools with multiple notes instead of one repeating key', () => { - const { engine, notes } = makeEngine(); - - renderBars(engine, 1, 16); - - expect(new Set(notes.map((note) => note.midi)).size).toBeGreaterThan(3); - }); - - it('changes musical style over time without a color change', () => { - const { engine, notes } = makeEngine(); - - renderBars(engine, 1, 32); - - const styleWindows = [ - notes.filter((note) => note.startTime >= 0 && note.startTime < 8), - notes.filter((note) => note.startTime >= 8 && note.startTime < 16), - notes.filter((note) => note.startTime >= 16 && note.startTime < 24), - ]; - const averageMidiByWindow = styleWindows.map((windowNotes) => - Math.round(average(windowNotes.map((note) => note.midi))) - ); - const averagePanByWindow = styleWindows.map((windowNotes) => - Number(average(windowNotes.map((note) => note.pan)).toFixed(2)) - ); - - expect(styleWindows.every((windowNotes) => windowNotes.length > 0)).toBe(true); - expect(new Set(averageMidiByWindow).size).toBeGreaterThan(1); - expect(new Set(averagePanByWindow).size).toBeGreaterThan(1); - }); - - it('starts a fading brush phrase layer with each new brush gesture', () => { - const baseline = makeEngine(); - const layered = makeEngine(); - const now = 4; - - baseline.engine.renderLookahead({ - vibe: VIBE_PRESETS[0], - now, - activity: 0.35, - lookaheadSeconds: 12, - }); - - layered.engine.beginGesture(); - layered.engine.recordStroke({ - vibe: VIBE_PRESETS[0], - now, - activity: 0.85, - }); - layered.engine.renderLookahead({ - vibe: VIBE_PRESETS[0], - now, - activity: 0.35, - lookaheadSeconds: 12, - }); - - const earlyExtra = - countNotesBetween(layered.notes, now + 1, now + 5) - - countNotesBetween(baseline.notes, now + 1, now + 5); - const lateExtra = - countNotesBetween(layered.notes, now + 10.5, now + 12) - - countNotesBetween(baseline.notes, now + 10.5, now + 12); - - expect(earlyExtra).toBeGreaterThan(2); - expect(lateExtra).toBe(0); - }); - - it('plays one immediate touch note and throttles later stroke accents', () => { - const { engine, notes } = makeEngine(); - const now = 4; - - engine.beginGesture(); - engine.recordStroke({ - vibe: VIBE_PRESETS[0], - now, - activity: 0.9, - }); - engine.recordStroke({ - vibe: VIBE_PRESETS[0], - now: now + 1, - activity: 0.95, - }); - - expect(notes).toHaveLength(1); - expect(notes[0].startTime).toBe(now); - - engine.recordStroke({ - vibe: VIBE_PRESETS[0], - now: now + 6, - activity: 0.95, - }); - - expect(notes).toHaveLength(2); - expect(new Set(notes.map((note) => note.midi)).size).toBeGreaterThan(1); - }); - - it('is deterministic for the same musical inputs', () => { - const first = makeEngine(); - const second = makeEngine(); - - renderBars(first.engine, 0.78, 16); - renderBars(second.engine, 0.78, 16); - - expect(second.notes).toEqual(first.notes); - }); - - it('does not duplicate notes across overlapping lookahead windows', () => { - const { engine, notes } = makeEngine(); - - engine.renderLookahead({ - vibe: VIBE_PRESETS[0], - now: 0, - activity: 0.72, - lookaheadSeconds: getBeatSeconds() * 2, - }); - engine.renderLookahead({ - vibe: VIBE_PRESETS[0], - now: getBeatSeconds() * 0.5, - activity: 0.72, - lookaheadSeconds: getBeatSeconds() * 2, - }); - - const noteKeys = notes.map(getNoteKey); - expect(new Set(noteKeys).size).toBe(noteKeys.length); - }); - - it('keeps generated notes inside the audio contract', () => { - const { engine, notes } = makeEngine(); - - VIBE_PRESETS.forEach((vibe) => { - engine.cue(0); - engine.renderLookahead({ - vibe, - now: 0, - activity: 1, - lookaheadSeconds: getBeatSeconds() * getBeatsPerBar() * 4, - }); - }); - - notes.forEach((note) => { - expect(Number.isFinite(note.startTime)).toBe(true); - expect(note.midi).toBeGreaterThanOrEqual(21); - expect(note.midi).toBeLessThanOrEqual(108); - expect(note.velocity).toBeGreaterThan(0); - expect(note.velocity).toBeLessThanOrEqual(0.4); - expect(note.pan).toBeGreaterThanOrEqual(-1); - expect(note.pan).toBeLessThanOrEqual(1); - expect(note.durationSeconds).toBeGreaterThan(0); - expect(note.lowpassHz ?? gardenAudioConfig.piano.lowpassHz).toBeGreaterThanOrEqual( - gardenAudioConfig.piano.lowpassMinHz - ); - expect(note.lowpassHz ?? gardenAudioConfig.piano.lowpassHz).toBeLessThanOrEqual( - gardenAudioConfig.piano.lowpassMaxHz - ); - }); - }); -}); diff --git a/src/audio/generative-piano.ts b/src/audio/generative-piano.ts index c0971fe..482a4fd 100644 --- a/src/audio/generative-piano.ts +++ b/src/audio/generative-piano.ts @@ -1,18 +1,28 @@ -import { clamp, clamp01 } from '../utils/clamp'; -import { VibePreset } from '../vibes'; -import { +import { clamp, clamp01 } from '../utils/math'; +import type { VibePreset } from '../vibes'; +import type { GardenAudioChord, GardenAudioConfig, - GardenAudioRegister, - GardenAudioStylePool, GardenAudioVibeProfile, } from './garden-audio-config'; import { degreeToSemitone, getChordIntervals, getVibeProfile, + PITCH_SEMITONES_PER_OCTAVE, } from './garden-audio-music'; -import { PianoNote } from './garden-audio-types'; +import { + GENERATIVE_LOOKAHEAD_SECONDS, + GENERATIVE_START_DELAY_SECONDS, + PIANO_SCHEDULE_AHEAD_SECONDS, +} from './garden-audio-scheduling'; +import type { PianoNote } from './garden-audio-types'; +import { + generativePianoTuning, + styleVoices, + type GardenAudioRegister, + type GardenAudioStylePool, +} from './generative-piano-tuning'; type GardenAudioStyleIndex = 0 | 1 | 2; @@ -87,8 +97,8 @@ export class GenerativePianoEngine { private readonly playNote: (note: PianoNote) => void ) {} - private get generation(): GardenAudioConfig['generativePiano'] { - return this.config.generativePiano; + private get generation(): typeof generativePianoTuning { + return generativePianoTuning; } public prime(now: number): void { @@ -190,7 +200,7 @@ export class GenerativePianoEngine { vibe, now, activity, - lookaheadSeconds = this.config.rhythm.lookaheadSeconds, + lookaheadSeconds = GENERATIVE_LOOKAHEAD_SECONDS, }: RenderLookaheadRequest): void { this.prime(now); this.skipLateBeats(now); @@ -418,7 +428,7 @@ export class GenerativePianoEngine { velocity: (this.generation.supportNote.velocityBase + expression * this.generation.supportNote.velocityExpressionWeight) * - this.config.styleVoices[styleIndex].velocityMultiplier, + styleVoices[styleIndex].velocityMultiplier, startTime, durationSeconds: this.generation.supportNote.durationBaseSeconds + @@ -464,7 +474,7 @@ export class GenerativePianoEngine { velocity: (this.generation.textureNote.velocityBase + expression * this.generation.textureNote.velocityExpressionWeight) * - this.config.styleVoices[styleIndex].velocityMultiplier, + styleVoices[styleIndex].velocityMultiplier, startTime, durationSeconds: this.generation.textureNote.durationBaseSeconds + @@ -511,7 +521,7 @@ export class GenerativePianoEngine { velocity: (this.generation.gestureAccent.velocityBase + strength * this.generation.gestureAccent.velocityStrengthWeight) * - this.config.styleVoices[styleIndex].velocityMultiplier, + styleVoices[styleIndex].velocityMultiplier, startTime, durationSeconds: this.generation.gestureAccent.durationBaseSeconds + @@ -560,7 +570,7 @@ export class GenerativePianoEngine { velocity: (this.generation.touchNote.velocityBase + strength * this.generation.touchNote.velocityStrengthWeight) * - this.config.styleVoices[styleIndex].velocityMultiplier, + styleVoices[styleIndex].velocityMultiplier, startTime: now, durationSeconds: this.generation.touchNote.durationBaseSeconds + @@ -661,7 +671,7 @@ export class GenerativePianoEngine { lookaheadEnd: number; activity: number; }): void { - const earliestStart = now + this.config.piano.scheduleAheadSeconds; + const earliestStart = now + PIANO_SCHEDULE_AHEAD_SECONDS; this.nextBrushStreamStep ??= 0; this.pruneBrushStreamNoteCounts(this.getGlobalBarIndex(now) - 1); @@ -770,7 +780,7 @@ export class GenerativePianoEngine { velocity: (this.generation.brushStream.velocityBase + intensity * this.generation.brushStream.velocityIntensityWeight) * - this.config.styleVoices[styleIndex].velocityMultiplier, + styleVoices[styleIndex].velocityMultiplier, startTime, durationSeconds, pan, @@ -803,7 +813,7 @@ export class GenerativePianoEngine { velocity: (this.generation.brushStreamEcho.velocityBase + intensity * this.generation.brushStreamEcho.velocityIntensityWeight) * - this.config.styleVoices[styleIndex].velocityMultiplier, + styleVoices[styleIndex].velocityMultiplier, startTime: startTime + this.generation.brushMotifCanonDelaySeconds, durationSeconds: Math.max( this.generation.brushStreamEcho.durationMinSeconds, @@ -872,8 +882,8 @@ export class GenerativePianoEngine { : intensity >= this.generation.brushStream.intenseThreshold ? this.generation.brushStreamIntenseIntervalBeats : intensity >= this.generation.brushStream.activeThreshold - ? this.generation.brushStreamActiveIntervalBeats - : this.generation.brushStreamIdleIntervalBeats; + ? this.generation.brushStreamActiveIntervalBeats + : this.generation.brushStreamIdleIntervalBeats; return Math.max(1, Math.round(intervalBeats * this.config.rhythm.stepsPerBeat)); } @@ -913,7 +923,7 @@ export class GenerativePianoEngine { pool: GardenAudioStylePool; styleIndex: GardenAudioStyleIndex; }): Array { - const styleOffset = this.config.styleVoices[styleIndex].scaleDegreeOffset; + const styleOffset = styleVoices[styleIndex].scaleDegreeOffset; if (!layer || layer.motifOffsets.length === 0) { return this.rotate(pool.scaleDegrees, this.brushStreamNoteIndex + styleOffset); } @@ -990,10 +1000,7 @@ export class GenerativePianoEngine { octave <= this.generation.candidateOctaveSearch.max; octave += 1 ) { - const midi = - pitchSource.baseMidi + - offset + - octave * this.config.piano.pitchSemitonesPerOctave; + const midi = pitchSource.baseMidi + offset + octave * PITCH_SEMITONES_PER_OCTAVE; if (midi >= register.midiMin && midi <= register.midiMax) { const roundedMidi = Math.round(midi); candidates.push({ @@ -1080,7 +1087,7 @@ export class GenerativePianoEngine { private getStylePan(styleIndex: GardenAudioStyleIndex): number { const pool = this.generation.stylePools[styleIndex]; - const styleVoice = this.config.styleVoices[styleIndex]; + const styleVoice = styleVoices[styleIndex]; return clamp( pool.pan + styleVoice.panOffset * this.generation.stylePanOffsetScale, -1, @@ -1113,7 +1120,7 @@ export class GenerativePianoEngine { return; } - const earliestStart = now + this.config.piano.scheduleAheadSeconds; + const earliestStart = now + PIANO_SCHEDULE_AHEAD_SECONDS; if (this.getTimeForStep(this.nextBeatStep) >= earliestStart) { return; } @@ -1152,7 +1159,7 @@ export class GenerativePianoEngine { private getTimeForStep(stepIndex: number): number { return ( (this.timelineStartedAt ?? 0) + - this.config.startDelaySeconds + + GENERATIVE_START_DELAY_SECONDS + stepIndex * this.getStepDurationSeconds() ); } @@ -1161,7 +1168,7 @@ export class GenerativePianoEngine { const timelineStartedAt = this.timelineStartedAt ?? startTime; const elapsedSeconds = Math.max( 0, - startTime - timelineStartedAt - this.config.startDelaySeconds + startTime - timelineStartedAt - GENERATIVE_START_DELAY_SECONDS ); return Math.floor(elapsedSeconds / this.getStepDurationSeconds()); } @@ -1170,7 +1177,7 @@ export class GenerativePianoEngine { const timelineStartedAt = this.timelineStartedAt ?? startTime; const elapsedSeconds = Math.max( 0, - startTime - timelineStartedAt - this.config.startDelaySeconds + startTime - timelineStartedAt - GENERATIVE_START_DELAY_SECONDS ); return Math.max( 0, diff --git a/src/audio/noise-burst-player.ts b/src/audio/noise-burst-player.ts index f9c7f6b..3ba5b62 100644 --- a/src/audio/noise-burst-player.ts +++ b/src/audio/noise-burst-player.ts @@ -1,12 +1,18 @@ -import type { GardenAudioConfig } from './garden-audio-config'; -import { GardenAudioGraph } from './garden-audio-graph'; -import { NoiseBurst } from './garden-audio-types'; +import { createAudioPanNode } from './audio-pan-node'; +import type { GardenAudioGraph } from './garden-audio-graph'; +import type { NoiseBurst } from './garden-audio-types'; + +const noiseBurstTuning = { + attackSeconds: 0.004, + filterQ: 1.4, + offsetRandomSeconds: 0.4, + scheduleAheadSeconds: 0.002, + silentGain: 0.0001, + filterType: 'bandpass' as BiquadFilterType, +}; export class NoiseBurstPlayer { - public constructor( - private readonly config: GardenAudioConfig, - private readonly graph: GardenAudioGraph - ) {} + public constructor(private readonly graph: GardenAudioGraph) {} public play({ startTime, durationSeconds, gain, filterHz, pan }: NoiseBurst): void { const { context, eventBus, noiseBus, noiseBuffer } = this.graph; @@ -16,35 +22,30 @@ export class NoiseBurstPlayer { } const scheduledStart = Math.max( - context.currentTime + this.config.noiseBurst.scheduleAheadSeconds, + context.currentTime + noiseBurstTuning.scheduleAheadSeconds, startTime ); const source = context.createBufferSource(); const filter = context.createBiquadFilter(); const envelope = context.createGain(); - const panner = context.createStereoPanner(); + const panNode = createAudioPanNode(context, pan, scheduledStart); const stopAt = scheduledStart + durationSeconds; source.buffer = noiseBuffer; - filter.type = this.config.noiseBurst.filterType; + filter.type = noiseBurstTuning.filterType; filter.frequency.setValueAtTime(filterHz, scheduledStart); - filter.Q.value = this.config.noiseBurst.filterQ; - envelope.gain.setValueAtTime(this.config.noiseBurst.silentGain, scheduledStart); + filter.Q.value = noiseBurstTuning.filterQ; + envelope.gain.setValueAtTime(noiseBurstTuning.silentGain, scheduledStart); envelope.gain.exponentialRampToValueAtTime( - Math.max(this.config.noiseBurst.silentGain, gain), - scheduledStart + this.config.noiseBurst.attackSeconds + Math.max(noiseBurstTuning.silentGain, gain), + scheduledStart + noiseBurstTuning.attackSeconds ); - envelope.gain.exponentialRampToValueAtTime(this.config.noiseBurst.silentGain, stopAt); - panner.pan.setValueAtTime(pan, scheduledStart); - + envelope.gain.exponentialRampToValueAtTime(noiseBurstTuning.silentGain, stopAt); source.connect(filter); filter.connect(envelope); - envelope.connect(panner); - panner.connect(outputBus); - source.start( - scheduledStart, - Math.random() * this.config.noiseBurst.offsetRandomSeconds - ); + envelope.connect(panNode.input); + panNode.output.connect(outputBus); + source.start(scheduledStart, Math.random() * noiseBurstTuning.offsetRandomSeconds); source.stop(stopAt); source.addEventListener( 'ended', @@ -52,7 +53,7 @@ export class NoiseBurstPlayer { source.disconnect(); filter.disconnect(); envelope.disconnect(); - panner.disconnect(); + panNode.disconnect(); }, { once: true } ); diff --git a/src/audio/piano-sampler.test.ts b/src/audio/piano-sampler.test.ts deleted file mode 100644 index 6e61743..0000000 --- a/src/audio/piano-sampler.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { gardenAudioConfig } from './garden-audio-config'; -import type { GardenAudioGraph } from './garden-audio-graph'; -import type { PianoSampler } from './piano-sampler'; - -const calls = { - bufferSourcesStarted: 0, -}; -const sampleCount = 30; - -class FakeAudioParam { - public value = 0; - public setTargetAtTime = vi.fn(); - public setValueAtTime = vi.fn(); - public exponentialRampToValueAtTime = vi.fn(); - public cancelScheduledValues = vi.fn(); -} - -class FakeAudioNode { - public readonly gain = new FakeAudioParam(); - public readonly frequency = new FakeAudioParam(); - public readonly playbackRate = new FakeAudioParam(); - public readonly Q = new FakeAudioParam(); - public readonly pan = new FakeAudioParam(); - public buffer: AudioBuffer | null = null; - public type = ''; - public addEventListener = vi.fn(); - public connect = vi.fn(); - public disconnect = vi.fn(); - public start = vi.fn(); - public stop = vi.fn(); -} - -class FakeAudioContext { - public readonly currentTime = 1; - public readonly decodeAudioData = vi.fn(async () => ({}) as AudioBuffer); - - public createGain(): GainNode { - return new FakeAudioNode() as unknown as GainNode; - } - - public createBiquadFilter(): BiquadFilterNode { - return new FakeAudioNode() as unknown as BiquadFilterNode; - } - - public createStereoPanner(): StereoPannerNode { - return new FakeAudioNode() as unknown as StereoPannerNode; - } - - public createBufferSource(): AudioBufferSourceNode { - const node = new FakeAudioNode() as unknown as AudioBufferSourceNode & { - start: () => void; - stop: () => void; - }; - node.start = vi.fn(() => { - calls.bufferSourcesStarted += 1; - }); - node.stop = vi.fn(); - return node; - } -} - -const makeSampler = async (context: AudioContext): Promise => { - const { PianoSampler } = await import('./piano-sampler'); - const eventBus = new FakeAudioNode() as unknown as GainNode; - const graph = { - context, - delayInput: null, - eventBus, - getPianoBus: vi.fn(() => eventBus), - } as unknown as GardenAudioGraph; - - return new PianoSampler(gardenAudioConfig, graph); -}; - -describe('PianoSampler', () => { - beforeEach(() => { - calls.bufferSourcesStarted = 0; - vi.resetModules(); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it('loads every piano sample before playback', async () => { - const context = new FakeAudioContext() as unknown as AudioContext; - const sampler = await makeSampler(context); - const fetch = vi.fn(async () => { - return { - arrayBuffer: async () => new ArrayBuffer(8), - ok: true, - } as Response; - }); - vi.stubGlobal('fetch', fetch); - - await sampler.load(context); - sampler.play({ - durationSeconds: 0.2, - midi: 60, - pan: 0, - startTime: context.currentTime, - velocity: 0.5, - }); - - expect(fetch).toHaveBeenCalledTimes(sampleCount); - expect(context.decodeAudioData).toHaveBeenCalledTimes(sampleCount); - expect(calls.bufferSourcesStarted).toBe(1); - }); - - it('only queues a piano load when the sampler is idle', async () => { - const context = new FakeAudioContext() as unknown as AudioContext; - const sampler = await makeSampler(context); - const fetch = vi.fn(async () => { - return { - arrayBuffer: async () => new ArrayBuffer(8), - ok: true, - } as Response; - }); - vi.stubGlobal('fetch', fetch); - - const firstLoad = sampler.loadIfIdle(context); - const secondLoad = sampler.loadIfIdle(context); - - expect(firstLoad).toBeInstanceOf(Promise); - expect(secondLoad).toBeNull(); - - await firstLoad; - - expect(sampler.loadIfIdle(context)).toBeNull(); - expect(fetch).toHaveBeenCalledTimes(sampleCount); - }); - - it('allows loading to be retried after a load failure', async () => { - const context = new FakeAudioContext() as unknown as AudioContext; - const sampler = await makeSampler(context); - const fetch = vi - .fn() - .mockRejectedValueOnce(new Error('load failed')) - .mockResolvedValue({ - arrayBuffer: async () => new ArrayBuffer(8), - ok: true, - } as Response); - vi.stubGlobal('fetch', fetch); - - await expect(sampler.loadIfIdle(context)).rejects.toThrow('load failed'); - await expect(sampler.loadIfIdle(context)).resolves.toBeUndefined(); - - expect(fetch).toHaveBeenCalledTimes(sampleCount * 2); - }); - - it('stays silent when no decoded sample is available', () => { - const context = new FakeAudioContext() as unknown as AudioContext; - return makeSampler(context).then((sampler) => { - sampler.play({ - durationSeconds: 0.2, - midi: 60, - pan: 0, - startTime: context.currentTime, - velocity: 0.5, - }); - - expect(calls.bufferSourcesStarted).toBe(0); - }); - }); -}); diff --git a/src/audio/piano-sampler.ts b/src/audio/piano-sampler.ts index e8314c5..c3a5007 100644 --- a/src/audio/piano-sampler.ts +++ b/src/audio/piano-sampler.ts @@ -1,11 +1,32 @@ -import { clamp, clamp01 } from '../utils/clamp'; -import { GardenAudioConfig } from './garden-audio-config'; -import { GardenAudioGraph } from './garden-audio-graph'; -import { ActivePianoVoice, LoadedPianoSample, PianoNote } from './garden-audio-types'; +import { clamp, clamp01 } from '../utils/math'; +import { createAudioPanNode } from './audio-pan-node'; +import type { GardenAudioConfig } from './garden-audio-config'; +import type { GardenAudioGraph } from './garden-audio-graph'; +import { PITCH_SEMITONES_PER_OCTAVE } from './garden-audio-music'; +import { PIANO_SCHEDULE_AHEAD_SECONDS } from './garden-audio-scheduling'; +import type { + ActivePianoVoice, + LoadedPianoSample, + PianoNote, +} from './garden-audio-types'; import { getLoadedPianoSamples, loadPianoSamples } from './piano-samples'; type PianoLoadState = 'idle' | 'loading' | 'loaded'; +const pianoSamplerTuning = { + filterType: 'lowpass' as BiquadFilterType, + filterQ: 0.7, + minDurationSeconds: 0.08, + minFadeSeconds: 0.08, + minGain: 0.0001, + synthGainScale: 0.34, + synthMaxDurationSeconds: 1.8, + synthOscillatorType: 'triangle' as OscillatorType, + tailStopExtraSeconds: 0.05, + voiceStealFadeSeconds: 0.025, + voiceStealStopSeconds: 0.05, +}; + export class PianoSampler { private loadState: PianoLoadState = 'idle'; private sampleLoadPromise: Promise | null = null; @@ -34,7 +55,9 @@ export class PianoSampler { } this.loadState = 'loading'; - this.sampleLoadPromise = loadPianoSamples(context) + this.sampleLoadPromise = loadPianoSamples(context, undefined, { + forceReload: true, + }) .then((samples) => { this.setSamples(samples); this.loadState = 'loaded'; @@ -74,16 +97,26 @@ export class PianoSampler { const sample = this.findNearestSample(midi); if (!sample) { + this.playSynthFallback({ + midi, + velocity, + startTime, + durationSeconds, + pan, + role, + delaySend, + lowpassHz, + }); return; } const scheduledStart = Math.max( - context.currentTime + this.config.piano.scheduleAheadSeconds, + context.currentTime + PIANO_SCHEDULE_AHEAD_SECONDS, startTime ); const noteVelocity = clamp01(velocity); const noteGainValue = Math.max( - this.config.piano.minGain, + pianoSamplerTuning.minGain, this.config.piano.gain * noteVelocity ); const sustainSeconds = @@ -91,14 +124,14 @@ export class PianoSampler { (this.config.piano.sustainBase + noteVelocity * this.config.piano.sustainVelocityRange); const sustainAt = - scheduledStart + Math.max(this.config.piano.minDurationSeconds, durationSeconds); + scheduledStart + Math.max(pianoSamplerTuning.minDurationSeconds, durationSeconds); const releaseAt = sustainAt + sustainSeconds; const releaseSeconds = this.config.piano.releaseSeconds; const stopAt = releaseAt + releaseSeconds; const source = context.createBufferSource(); const filter = context.createBiquadFilter(); const gain = context.createGain(); - const panner = context.createStereoPanner(); + const panNode = createAudioPanNode(context, pan, scheduledStart); let sendGain: GainNode | null = null; this.trimActiveVoices(scheduledStart); @@ -111,45 +144,46 @@ export class PianoSampler { source.buffer = sample.buffer; source.playbackRate.setValueAtTime( - Math.pow(2, (midi - sample.midi) / this.config.piano.pitchSemitonesPerOctave), + Math.pow(2, (midi - sample.midi) / PITCH_SEMITONES_PER_OCTAVE), scheduledStart ); - filter.type = this.config.piano.filterType; + filter.type = pianoSamplerTuning.filterType; filter.frequency.setValueAtTime( clamp(lowpassHz, this.config.piano.lowpassMinHz, this.config.piano.lowpassMaxHz), scheduledStart ); - filter.Q.value = this.config.piano.filterQ; - gain.gain.setValueAtTime(this.config.piano.minGain, scheduledStart); + filter.Q.value = pianoSamplerTuning.filterQ; + gain.gain.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart); gain.gain.exponentialRampToValueAtTime( noteGainValue, scheduledStart + this.config.piano.gainAttackSeconds ); gain.gain.setTargetAtTime( - Math.max(this.config.piano.minGain, noteGainValue * this.config.piano.sustainLevel), + Math.max( + pianoSamplerTuning.minGain, + noteGainValue * this.config.piano.sustainLevel + ), sustainAt, Math.max( - this.config.piano.minFadeSeconds, + pianoSamplerTuning.minFadeSeconds, sustainSeconds * this.config.piano.sustainBase ) ); - gain.gain.setTargetAtTime(this.config.piano.minGain, releaseAt, releaseSeconds); - panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart); - + gain.gain.setTargetAtTime(pianoSamplerTuning.minGain, releaseAt, releaseSeconds); source.connect(filter); filter.connect(gain); - gain.connect(panner); - panner.connect(eventBus); + gain.connect(panNode.input); + panNode.output.connect(eventBus); if (delayInput && delaySend > 0) { sendGain = context.createGain(); sendGain.gain.value = delaySend; - panner.connect(sendGain); + panNode.output.connect(sendGain); sendGain.connect(delayInput); } source.start(scheduledStart); - source.stop(stopAt + this.config.piano.tailStopExtraSeconds); + source.stop(stopAt + pianoSamplerTuning.tailStopExtraSeconds); this.activeVoices.push({ gain, source, stopAt }); source.addEventListener( @@ -158,7 +192,7 @@ export class PianoSampler { source.disconnect(); filter.disconnect(); gain.disconnect(); - panner.disconnect(); + panNode.disconnect(); sendGain?.disconnect(); this.activeVoices = this.activeVoices.filter((voice) => voice.gain !== gain); }, @@ -181,6 +215,22 @@ export class PianoSampler { this.activeVoices = []; } + public fadeAll(fadeSeconds: number): void { + const context = this.graph.context; + if (!context) { + this.activeVoices = []; + return; + } + + const now = context.currentTime; + const fadeDurationSeconds = Math.max(pianoSamplerTuning.minFadeSeconds, fadeSeconds); + + this.trimActiveVoices(now); + this.activeVoices.forEach((voice) => { + this.fadeVoice(voice, now, fadeDurationSeconds); + }); + } + public reset(): void { this.loadState = 'idle'; this.sampleLoadPromise = null; @@ -198,18 +248,114 @@ export class PianoSampler { ); } + private playSynthFallback({ + midi, + velocity, + startTime, + durationSeconds, + pan, + role, + delaySend = 0, + lowpassHz = this.config.piano.lowpassHz, + }: PianoNote): void { + const { context, delayInput } = this.graph; + const eventBus = this.graph.getPianoBus(role); + if (!context || !eventBus) { + return; + } + + const scheduledStart = Math.max( + context.currentTime + PIANO_SCHEDULE_AHEAD_SECONDS, + startTime + ); + const noteVelocity = clamp01(velocity); + const noteGainValue = Math.max( + pianoSamplerTuning.minGain, + this.config.piano.gain * noteVelocity * pianoSamplerTuning.synthGainScale + ); + const releaseAt = + scheduledStart + + clamp( + durationSeconds + this.config.piano.sustainSeconds * 0.5, + pianoSamplerTuning.minDurationSeconds, + pianoSamplerTuning.synthMaxDurationSeconds + ); + const stopAt = releaseAt + this.config.piano.releaseSeconds; + const source = context.createOscillator(); + const filter = context.createBiquadFilter(); + const gain = context.createGain(); + const panNode = createAudioPanNode(context, pan, scheduledStart); + let sendGain: GainNode | null = null; + + this.trimActiveVoices(scheduledStart); + while (this.activeVoices.length >= this.config.piano.maxVoices) { + const oldest = this.activeVoices.shift(); + if (oldest) { + this.stopVoice(oldest, scheduledStart); + } + } + + source.type = pianoSamplerTuning.synthOscillatorType; + source.frequency.setValueAtTime(getMidiFrequency(midi), scheduledStart); + filter.type = pianoSamplerTuning.filterType; + filter.frequency.setValueAtTime( + clamp(lowpassHz, this.config.piano.lowpassMinHz, this.config.piano.lowpassMaxHz), + scheduledStart + ); + filter.Q.value = pianoSamplerTuning.filterQ; + gain.gain.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart); + gain.gain.exponentialRampToValueAtTime( + noteGainValue, + scheduledStart + this.config.piano.gainAttackSeconds + ); + gain.gain.setTargetAtTime( + pianoSamplerTuning.minGain, + releaseAt, + this.config.piano.releaseSeconds + ); + + source.connect(filter); + filter.connect(gain); + gain.connect(panNode.input); + panNode.output.connect(eventBus); + + if (delayInput && delaySend > 0) { + sendGain = context.createGain(); + sendGain.gain.value = delaySend; + panNode.output.connect(sendGain); + sendGain.connect(delayInput); + } + + source.start(scheduledStart); + source.stop(stopAt + pianoSamplerTuning.tailStopExtraSeconds); + this.activeVoices.push({ gain, source, stopAt }); + + source.addEventListener( + 'ended', + () => { + source.disconnect(); + filter.disconnect(); + gain.disconnect(); + panNode.disconnect(); + sendGain?.disconnect(); + this.activeVoices = this.activeVoices.filter((voice) => voice.gain !== gain); + }, + { once: true } + ); + } + private trimActiveVoices(now: number): void { this.activeVoices = this.activeVoices.filter((voice) => voice.stopAt > now); } private stopVoice(voice: ActivePianoVoice, now: number): void { - const stopAt = now + this.config.piano.voiceStealStopSeconds; + const stopAt = now + pianoSamplerTuning.voiceStealStopSeconds; voice.gain.gain.cancelScheduledValues(now); voice.gain.gain.setTargetAtTime( - this.config.piano.minGain, + pianoSamplerTuning.minGain, now, - this.config.piano.voiceStealFadeSeconds + pianoSamplerTuning.voiceStealFadeSeconds ); voice.stopAt = stopAt; try { @@ -219,7 +365,31 @@ export class PianoSampler { } } + private fadeVoice( + voice: ActivePianoVoice, + now: number, + fadeDurationSeconds: number + ): void { + const stopAt = Math.min(voice.stopAt, now + fadeDurationSeconds); + + voice.gain.gain.cancelScheduledValues(now); + voice.gain.gain.setTargetAtTime( + pianoSamplerTuning.minGain, + now, + Math.max(0.001, fadeDurationSeconds / 4) + ); + voice.stopAt = stopAt; + try { + voice.source.stop(stopAt + pianoSamplerTuning.tailStopExtraSeconds); + } catch { + // The voice may already have ended; either way it is fading out of the mix. + } + } + private setSamples(samples: Array): void { this.samples = samples.slice().sort((a, b) => a.midi - b.midi); } } + +const getMidiFrequency = (midi: number): number => + 440 * Math.pow(2, (midi - 69) / PITCH_SEMITONES_PER_OCTAVE); diff --git a/src/audio/piano-samples.ts b/src/audio/piano-samples.ts index 914e831..d456968 100644 --- a/src/audio/piano-samples.ts +++ b/src/audio/piano-samples.ts @@ -1,4 +1,3 @@ -import { gardenAudioConfig } from './garden-audio-config'; import type { LoadedPianoSample } from './garden-audio-types'; interface PianoSampleDefinition { @@ -45,21 +44,36 @@ const sampleFiles: Array<[fileName: string, midi: number]> = [ ['C8v12.m4a', 108], ]; +const sampleBaseUrl = `${import.meta.env.BASE_URL}audio/`; +const preloadDecode = { + channels: 2, + frames: 128, + sampleRateHz: 48_000, +}; + const pianoSampleDefinitions: Array = sampleFiles .map(([fileName, midi]) => ({ midi, - url: `${gardenAudioConfig.piano.sampleBaseUrl}${fileName}`, + url: `${sampleBaseUrl}${fileName}`, })) .sort((a, b) => a.midi - b.midi); let loadedPianoSamples: Array | null = null; let pianoSampleLoadPromise: Promise> | null = null; +interface PianoSampleLoadOptions { + forceReload?: boolean; +} + +const sampleLoadTuning = { + concurrency: 4, + sampleTimeoutMs: 15_000, +}; + export const preloadPianoSamples = ( onProgress?: (progress: PianoSampleLoadProgress) => void ): Promise> => { - const OfflineAudioContextConstructor = - globalThis.OfflineAudioContext ?? globalThis.webkitOfflineAudioContext; + const OfflineAudioContextConstructor = globalThis.OfflineAudioContext; if (!OfflineAudioContextConstructor) { return Promise.reject( @@ -68,16 +82,17 @@ export const preloadPianoSamples = ( } const decodeContext = new OfflineAudioContextConstructor( - gardenAudioConfig.piano.preloadDecode.channels, - gardenAudioConfig.piano.preloadDecode.frames, - gardenAudioConfig.piano.preloadDecode.sampleRateHz + preloadDecode.channels, + preloadDecode.frames, + preloadDecode.sampleRateHz ); return loadPianoSamples(decodeContext, onProgress); }; export const loadPianoSamples = ( decodeContext: BaseAudioContext, - onProgress?: (progress: PianoSampleLoadProgress) => void + onProgress?: (progress: PianoSampleLoadProgress) => void, + options: PianoSampleLoadOptions = {} ): Promise> => { if (loadedPianoSamples) { onProgress?.({ @@ -87,7 +102,7 @@ export const loadPianoSamples = ( return Promise.resolve([...loadedPianoSamples]); } - if (pianoSampleLoadPromise) { + if (pianoSampleLoadPromise && options.forceReload !== true) { return pianoSampleLoadPromise; } @@ -95,16 +110,27 @@ export const loadPianoSamples = ( const totalCount = pianoSampleDefinitions.length; onProgress?.({ loadedCount, totalCount }); - pianoSampleLoadPromise = Promise.all( - pianoSampleDefinitions.map(async (sample) => { - const loadedSample = await loadPianoSample(decodeContext, sample); - loadedCount += 1; - onProgress?.({ loadedCount, totalCount, sample }); - return loadedSample; - }) + pianoSampleLoadPromise = loadPianoSampleBatch( + pianoSampleDefinitions, + async (sample) => { + try { + return await withTimeout( + loadPianoSample(decodeContext, sample), + sampleLoadTuning.sampleTimeoutMs + ); + } finally { + loadedCount += 1; + onProgress?.({ loadedCount, totalCount, sample }); + } + } ).then( (samples) => { - loadedPianoSamples = samples.slice().sort((a, b) => a.midi - b.midi); + loadedPianoSamples = samples + .filter((sample): sample is LoadedPianoSample => sample !== null) + .sort((a, b) => a.midi - b.midi); + if (loadedPianoSamples.length === 0) { + throw new Error('Unable to load any piano samples.'); + } return [...loadedPianoSamples]; }, (error: unknown) => { @@ -133,6 +159,43 @@ const loadPianoSample = async ( return { midi: sample.midi, buffer }; }; +const loadPianoSampleBatch = async ( + samples: Array, + loadSample: ( + sample: PianoSampleDefinition + ) => Promise +): Promise> => { + const results: Array = []; + + for (let index = 0; index < samples.length; index += sampleLoadTuning.concurrency) { + const batch = samples.slice(index, index + sampleLoadTuning.concurrency); + const batchResults = await Promise.all( + batch.map((sample) => loadSample(sample).catch(() => null)) + ); + results.push(...batchResults); + } + + return results; +}; + +const withTimeout = (promise: Promise, timeoutMs: number): Promise => + new Promise((resolve, reject) => { + const timeout = globalThis.setTimeout(() => { + reject(new Error('Timed out while loading a piano sample.')); + }, timeoutMs); + + promise.then( + (value) => { + globalThis.clearTimeout(timeout); + resolve(value); + }, + (error: unknown) => { + globalThis.clearTimeout(timeout); + reject(error); + } + ); + }); + const decodeAudioData = ( decodeContext: BaseAudioContext, audioData: ArrayBuffer diff --git a/src/config.ts b/src/config.ts index de6a9c9..3df06aa 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,10 +1,9 @@ +import { APP_STORAGE_KEYS, DEFAULT_AUDIO_VOLUME } from './app-constants'; import { defaultSettings } from './config/default-settings'; import { runtimeControls } from './config/runtime-controls'; import type { GardenAppConfig } from './config/types'; import { defaultVibeId, vibePresets } from './config/vibe-presets'; -const defaultAudioMasterVolume = 0.42; - export { VibeId } from './config/types'; export type { @@ -16,10 +15,9 @@ export type { export const appConfig = { audio: { - masterVolume: defaultAudioMasterVolume, + masterVolume: DEFAULT_AUDIO_VOLUME, fadeInSeconds: 0.45, updateRampSeconds: 0.08, - highPassFrequencyHz: 45, delay: { timeSeconds: 0.46, feedback: 0.12, @@ -32,44 +30,24 @@ export const appConfig = { outputBase: 0.65, outputActivityDuck: 0.28, timeRampSeconds: 0.12, - feedbackHighPassHz: 180, - feedbackLowPassHz: 5200, - returnLowPassHz: 6200, }, piano: { maxVoices: 24, - filterType: 'lowpass', gain: 0.48, sustainSeconds: 0.42, sustainLevel: 0.32, releaseSeconds: 0.24, lowpassHz: 7600, - filterQ: 0.7, gainAttackSeconds: 0.006, lowpassMaxHz: 12000, lowpassMinHz: 1400, - minDurationSeconds: 0.08, - minFadeSeconds: 0.08, - minGain: 0.0001, - pitchSemitonesPerOctave: 12, - scheduleAheadSeconds: 0.002, sustainBase: 0.45, sustainVelocityRange: 0.55, - tailStopExtraSeconds: 0.05, - voiceStealFadeSeconds: 0.025, - voiceStealStopSeconds: 0.05, - sampleBaseUrl: `${import.meta.env.BASE_URL}audio/`, - preloadDecode: { - channels: 1, - frames: 1, - sampleRateHz: 44_100, - }, }, rhythm: { bpm: 74, stepsPerBeat: 4, stepsPerBar: 16, - lookaheadSeconds: 0.3, sparseActivity: 0.055, }, eraser: { @@ -89,19 +67,6 @@ export const appConfig = { strokeDecaySeconds: 0.32, }, graph: { - closeGain: 0.0001, - closeRampSeconds: 0.015, - delayMaxSeconds: 2, - eventBusGain: 1, - noiseMax: 1, - noiseMin: -1, - unlockTickFrequencyHz: 440, - unlockTickSeconds: 0.035, - unlockTickType: 'sine', - latencyHint: 'interactive', - outputFilterType: 'highpass', - noiseBufferChannels: 1, - noiseBufferDurationSeconds: 1, pianoBusGains: { pad: 0.86, support: 0.94, @@ -119,16 +84,8 @@ export const appConfig = { stinger: 0, }, noiseBusGain: 0.72, - compressor: { - thresholdDb: -18, - kneeDb: 18, - ratio: 2.1, - attackSeconds: 0.018, - releaseSeconds: 0.18, - }, }, input: { - fallbackFrameSeconds: 1 / 60, fullActivitySpeed: 0.86, activityNoiseFloorSpeed: 0.025, activityCurve: 0.74, @@ -139,259 +96,7 @@ export const appConfig = { manicActivityThreshold: 0.9, manicReleaseThreshold: 0.76, maniaSmoothingSeconds: 0.12, - minElapsedSeconds: 0.001, }, - muteGain: 0.0001, - muteRampSeconds: 0.02, - noiseBurst: { - attackSeconds: 0.004, - filterQ: 1.4, - offsetRandomSeconds: 0.4, - scheduleAheadSeconds: 0.002, - silentGain: 0.0001, - filterType: 'bandpass', - }, - startDelaySeconds: 0.02, - vibeChangeStingerMinIntervalSeconds: 0.45, - generativePiano: { - 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, - }, - ], - chordVoicings: { - majorOpen: [0, 7, 12, 16], - minorOpen: [0, 7, 12, 15], - majorClosed: [0, 4, 7, 12, 16], - minorClosed: [0, 3, 7, 12, 15], - }, - 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, - }, - styleVoices: [ - { - scaleDegreeOffset: 0, - velocityMultiplier: 0.92, - panOffset: -0.14, - }, - { - scaleDegreeOffset: 1, - velocityMultiplier: 1, - panOffset: 0, - }, - { - scaleDegreeOffset: 2, - velocityMultiplier: 0.86, - panOffset: 0.14, - }, - ], }, deltaTime: { maxDeltaTimeSeconds: 1 / 30, @@ -444,10 +149,9 @@ export const appConfig = { }, simulation: { budget: { - adaptiveCapDecreaseAgentsPerSecond: 50_000, + adaptiveCapDecreaseAgentsPerSecond: 200_000, adaptiveCapInitial: 1_000_000, - adaptiveCapMax: 2_000_000, - adaptiveCapMin: 500_000, + adaptiveCapMin: 50_000, adaptiveRefreshTargetFps: 60, frameGapResetSeconds: 1, fpsHeadroom: 0.95, @@ -458,6 +162,7 @@ export const appConfig = { brushEffectFramesPerSecond: 60, clearColor: { r: 0, g: 0, b: 0, a: 0 }, initialAgentCount: 180_000, + maxDevicePixelRatio: 2, intro: { angleJitterRadians: Math.PI * 0.08, angleEaseEnd: 1, @@ -508,9 +213,9 @@ export const appConfig = { }, }, storage: { - audioMutedKey: 'fleeting-garden:audio-muted', - audioVolumeKey: 'fleeting-garden:audio-volume', - vibeKey: 'fleeting-garden:vibe', + audioMutedKey: APP_STORAGE_KEYS.audioMuted, + audioVolumeKey: APP_STORAGE_KEYS.audioVolume, + vibeKey: APP_STORAGE_KEYS.vibe, }, toolbar: { eraser: { @@ -566,7 +271,7 @@ export const appConfig = { whiteContrastNumerator: 1.05, }, volume: { - default: defaultAudioMasterVolume, + default: DEFAULT_AUDIO_VOLUME, max: 1, min: 0, step: 0.01, diff --git a/src/config/default-settings.ts b/src/config/default-settings.ts index aa20316..47584d3 100644 --- a/src/config/default-settings.ts +++ b/src/config/default-settings.ts @@ -55,7 +55,9 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = { eraserLineDistanceEpsilon: 0.0001, eraserMaskAlphaThreshold: 0.5, + internalRenderAreaMegapixels: 8.3, strokeSpawnSpreadBrushSizeMultiplier: 1, + maxAgentCount: 700_000, renderTraceNormalizationFloor: 1, renderBrushColorBase: 1.2, diff --git a/src/config/runtime-controls.ts b/src/config/runtime-controls.ts index b526d34..7d9870e 100644 --- a/src/config/runtime-controls.ts +++ b/src/config/runtime-controls.ts @@ -157,6 +157,13 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = { max: 0.12, step: 0.001, }, + internalRenderAreaMegapixels: { + folder: 'Render', + label: 'internal area (MP)', + min: 0.5, + max: 16.6, + step: 0.1, + }, decayRateBrush: { folder: 'Diffusion', min: 0.1, @@ -248,6 +255,12 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = { max: 1, step: 0.001, }, + maxAgentCount: { + folder: 'Agent', + integer: true, + label: 'max agent count', + step: 10_000, + }, mirrorSegmentCount: { folder: 'Brush', integer: true, diff --git a/src/config/types.ts b/src/config/types.ts index 19fd7da..f347cea 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -11,8 +11,8 @@ export interface NumberControlConfig { folder: string; integer?: boolean; label?: string; - max: number; - min: number; + max?: number; + min?: number; options?: Record; step?: number; } @@ -32,7 +32,9 @@ export type GardenRuntimeSettings = { eraserLineDistanceEpsilon: number; eraserMaskAlphaThreshold: number; eraserSize: number; + internalRenderAreaMegapixels: number; mirrorSegmentCount: number; + maxAgentCount: number; selectedColorIndex: number; spawnPerPixel: number; strokeSpawnSpreadBrushSizeMultiplier: number; @@ -137,7 +139,6 @@ export interface GardenAppConfig { budget: { adaptiveCapDecreaseAgentsPerSecond: number; adaptiveCapInitial: number; - adaptiveCapMax: number; adaptiveCapMin: number; adaptiveRefreshTargetFps: number; frameGapResetSeconds: number; @@ -149,6 +150,7 @@ export interface GardenAppConfig { brushEffectFramesPerSecond: number; clearColor: GPUColor; initialAgentCount: number; + maxDevicePixelRatio: number; intro: { angleJitterRadians: number; angleEaseEnd: number; diff --git a/src/game-loop/agent-population.test.ts b/src/game-loop/agent-population.test.ts deleted file mode 100644 index 6cce50a..0000000 --- a/src/game-loop/agent-population.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { vec2 } from 'gl-matrix'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { appConfig } from '../config'; -import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline'; -import { settings } from '../settings'; -import { AgentPopulation } from './agent-population'; - -vi.hoisted(() => { - Object.defineProperty(globalThis, 'localStorage', { - configurable: true, - value: { - getItem: vi.fn(() => null), - setItem: vi.fn(), - }, - }); -}); - -const originalBrushSize = settings.brushSize; -const originalSelectedColorIndex = settings.selectedColorIndex; -const originalSpawnPerPixel = settings.spawnPerPixel; -const originalStrokeSpawnSpreadBrushSizeMultiplier = - settings.strokeSpawnSpreadBrushSizeMultiplier; - -const createPopulation = () => { - const pipeline = { - maxAgentCount: 10_000_000, - writeAgents: vi.fn(), - resizeAgents: vi.fn(), - compactAgents: vi.fn(), - } as unknown as AgentGenerationPipeline; - - return new AgentPopulation(pipeline); -}; - -const setPopulationActiveCount = (population: AgentPopulation, activeCount: number) => { - Object.assign(population as unknown as Record, { - activeCount, - }); -}; - -const setPopulationAdaptiveCap = (population: AgentPopulation, adaptiveCap: number) => { - Object.assign(population as unknown as Record, { - adaptiveCap, - }); -}; - -const getPopulationAdaptiveCap = (population: AgentPopulation): number => - (population as unknown as { adaptiveCap: number }).adaptiveCap; - -describe('AgentPopulation adaptive budget', () => { - beforeEach(() => { - settings.brushSize = 1; - settings.selectedColorIndex = 0; - settings.spawnPerPixel = 1; - settings.strokeSpawnSpreadBrushSizeMultiplier = 1; - }); - - afterEach(() => { - settings.brushSize = originalBrushSize; - settings.selectedColorIndex = originalSelectedColorIndex; - settings.spawnPerPixel = originalSpawnPerPixel; - settings.strokeSpawnSpreadBrushSizeMultiplier = - originalStrokeSpawnSpreadBrushSizeMultiplier; - vi.restoreAllMocks(); - }); - - it('expands beyond the 1M start cap only when new agents arrive under healthy FPS', () => { - const population = createPopulation(); - setPopulationActiveCount(population, 1_000_000); - - population.growBudget(1 / 60, 60); - population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(16, 0)); - - expect(getPopulationAdaptiveCap(population)).toBeGreaterThan( - appConfig.simulation.budget.adaptiveCapInitial - ); - expect(population.activeAgentCount).toBeGreaterThan( - appConfig.simulation.budget.adaptiveCapInitial - ); - expect(getPopulationAdaptiveCap(population)).toBeLessThanOrEqual( - appConfig.simulation.budget.adaptiveCapMax - ); - }); - - it('does not grow the cap above the adaptive max agent count', () => { - const population = createPopulation(); - const maxAgentCount = appConfig.simulation.budget.adaptiveCapMax; - setPopulationAdaptiveCap(population, maxAgentCount - 1); - setPopulationActiveCount(population, maxAgentCount - 1); - - population.growBudget(1 / 60, 60); - population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(16, 0)); - - expect(getPopulationAdaptiveCap(population)).toBe(maxAgentCount); - expect(population.activeAgentCount).toBe(maxAgentCount); - }); - - it('clamps a stale cap before adding agents', () => { - const population = createPopulation(); - const maxAgentCount = appConfig.simulation.budget.adaptiveCapMax; - setPopulationAdaptiveCap(population, maxAgentCount + 1_000); - setPopulationActiveCount(population, maxAgentCount); - - population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(16, 0)); - - expect(getPopulationAdaptiveCap(population)).toBe(maxAgentCount); - expect(population.activeAgentCount).toBe(maxAgentCount); - }); - - it('scales stroke spawn spread by device pixel ratio', () => { - settings.brushSize = 10; - const writeAgents = vi.fn(); - const pipeline = { - maxAgentCount: 10_000_000, - writeAgents, - resizeAgents: vi.fn(), - compactAgents: vi.fn(), - } as unknown as AgentGenerationPipeline; - const population = new AgentPopulation(pipeline, 0, () => 2); - vi.spyOn(Math, 'random').mockReturnValue(1); - - population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(0, 0)); - - const firstBatch = writeAgents.mock.calls[0][1] as Float32Array; - expect(firstBatch[0]).toBe(10); - expect(firstBatch[1]).toBe(10); - }); - - it('decreases the cap and active count slowly when FPS falls below the threshold', () => { - const population = createPopulation(); - setPopulationActiveCount(population, 1_000_000); - - population.growBudget(10, 50); - - expect(getPopulationAdaptiveCap(population)).toBe( - appConfig.simulation.budget.adaptiveCapMin - ); - expect(population.activeAgentCount).toBe(appConfig.simulation.budget.adaptiveCapMin); - }); -}); diff --git a/src/game-loop/agent-population.ts b/src/game-loop/agent-population.ts index 89f40da..94d5846 100644 --- a/src/game-loop/agent-population.ts +++ b/src/game-loop/agent-population.ts @@ -1,9 +1,11 @@ import { vec2 } from 'gl-matrix'; import { appConfig } from '../config'; -import { AGENT_FLOAT_COUNT } from '../pipelines/agents/agent-generation/agent'; -import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline'; -import { getSafeDevicePixelRatio } from '../pipelines/brush/brush-pipeline'; +import { + AGENT_FLOAT_COUNT, + AgentGenerationPipeline, +} from '../pipelines/agents/agent-generation/agent-generation-pipeline'; +import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline'; import { settings } from '../settings'; import { createIntroTitleAgents } from './intro-title-agents'; @@ -14,6 +16,8 @@ export class AgentPopulation { private canExpandAdaptiveCap = true; private shouldCompactAfterErase = false; private isCompacting = false; + private pendingCompaction: Promise | null = null; + private postCompactionWriteEnd = 0; private readonly strokeAgentData = new Float32Array( appConfig.simulation.stroke.maxAgentCount * AGENT_FLOAT_COUNT ); @@ -21,7 +25,7 @@ export class AgentPopulation { public constructor( private readonly pipeline: AgentGenerationPipeline, private readonly introSeed = Math.floor(Math.random() * 0xffffffff), - private readonly getDevicePixelRatio = () => 1 + private readonly getCanvasPixelRatio = () => 1 ) { this.adaptiveCap = this.clampAdaptiveCap( appConfig.simulation.budget.adaptiveCapInitial @@ -55,6 +59,7 @@ export class AgentPopulation { } this.pipeline.writeAgents(0, data); + this.markPostCompactionWrite(0, data.length / AGENT_FLOAT_COUNT); this.activeCount = data.length / AGENT_FLOAT_COUNT; this.replacementCursor = 0; } @@ -76,7 +81,7 @@ export class AgentPopulation { this.shouldCompactAfterErase = true; } - public async compactAfterErase(isSwipeActive: boolean): Promise { + public compactAfterErase(isSwipeActive: boolean): void { if (!this.shouldCompactAfterErase || this.isCompacting || isSwipeActive) { return; } @@ -87,14 +92,33 @@ export class AgentPopulation { } this.isCompacting = true; - try { - const compactedAgentCount = await this.pipeline.compactAgents(this.activeCount); - this.activeCount = compactedAgentCount; - this.replacementCursor = - compactedAgentCount === 0 ? 0 : this.replacementCursor % compactedAgentCount; - } finally { - this.isCompacting = false; - } + this.postCompactionWriteEnd = 0; + this.pendingCompaction = this.pipeline + .compactAgents(this.activeCount) + .then((compactedAgentCount) => { + const finiteCompactedAgentCount = Number.isFinite(compactedAgentCount) + ? Math.max(0, Math.floor(compactedAgentCount)) + : 0; + this.activeCount = Math.min( + this.activeCount, + Math.max(finiteCompactedAgentCount, this.postCompactionWriteEnd) + ); + this.replacementCursor = + this.activeCount === 0 ? 0 : this.replacementCursor % this.activeCount; + this.trimActiveCountToBudget(); + }) + .catch((error: unknown) => { + console.warn('Could not compact agents after erase.', error); + }) + .finally(() => { + this.isCompacting = false; + this.pendingCompaction = null; + this.postCompactionWriteEnd = 0; + }); + } + + public async waitForCompaction(): Promise { + await this.pendingCompaction; } public spawnStrokeAgents(from: vec2, to: vec2): void { @@ -125,7 +149,7 @@ export class AgentPopulation { const base = i * AGENT_FLOAT_COUNT; const spread = settings.brushSize * - getSafeDevicePixelRatio(this.getDevicePixelRatio()) * + getSafePixelRatio(this.getCanvasPixelRatio()) * settings.strokeSpawnSpreadBrushSizeMultiplier; this.strokeAgentData[base] = x + (Math.random() - 0.5) * spread; this.strokeAgentData[base + 1] = y + (Math.random() - 0.5) * spread; @@ -157,6 +181,7 @@ export class AgentPopulation { this.activeCount, data.subarray(0, appendCount * AGENT_FLOAT_COUNT) ); + this.markPostCompactionWrite(this.activeCount, appendCount); this.activeCount += appendCount; } @@ -175,12 +200,24 @@ export class AgentPopulation { (sourceAgentOffset + chunkAgentCount) * AGENT_FLOAT_COUNT ) ); + this.markPostCompactionWrite(targetAgentOffset, chunkAgentCount); sourceAgentOffset += chunkAgentCount; this.replacementCursor = (targetAgentOffset + chunkAgentCount) % this.activeCount; } } + private markPostCompactionWrite(agentOffset: number, agentCount: number): void { + if (!this.isCompacting || agentCount <= 0) { + return; + } + + this.postCompactionWriteEnd = Math.max( + this.postCompactionWriteEnd, + Math.ceil(agentOffset + agentCount) + ); + } + private updateAdaptiveCap(deltaTime: number, smoothedFps: number): void { const previousCap = this.clampAdaptiveCap(this.adaptiveCap); this.canExpandAdaptiveCap = @@ -200,7 +237,8 @@ export class AgentPopulation { appConfig.simulation.budget.adaptiveCapDecreaseAgentsPerSecond * deltaTime ) ); - const nextCap = this.clampAdaptiveCap(previousCap - decrease); + const responsiveCap = Math.min(previousCap, this.clampAdaptiveCap(this.activeCount)); + const nextCap = this.clampAdaptiveCap(responsiveCap - decrease); this.adaptiveCap = nextCap; this.trimActiveCountToBudget(decrease); } @@ -230,10 +268,19 @@ export class AgentPopulation { } private clampAdaptiveCap(value: number): number { - const pipelineCap = Math.max(0, Math.floor(this.pipeline.maxAgentCount)); - const maxCap = Math.min(appConfig.simulation.budget.adaptiveCapMax, pipelineCap); + const runtimeMaxCap = + settings.maxAgentCount === Number.POSITIVE_INFINITY + ? Number.POSITIVE_INFINITY + : Number.isFinite(settings.maxAgentCount) + ? Math.max(0, Math.floor(settings.maxAgentCount)) + : Math.max(0, Math.floor(this.pipeline.maxAgentCount)); + const maxCap = Math.min(this.pipeline.maxSupportedAgentCount, runtimeMaxCap); const minCap = Math.min(appConfig.simulation.budget.adaptiveCapMin, maxCap); const finiteValue = Number.isFinite(value) ? value : minCap; - return Math.min(maxCap, Math.max(minCap, Math.round(finiteValue))); + const nextCap = Math.min(maxCap, Math.max(minCap, Math.round(finiteValue))); + return Math.min( + nextCap, + this.pipeline.ensureMaxAgentCount(nextCap, this.activeCount) + ); } } diff --git a/src/game-loop/export-4k.test.ts b/src/game-loop/export-4k.test.ts deleted file mode 100644 index 27b7e8d..0000000 --- a/src/game-loop/export-4k.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { - estimateExport4KMemory, - getAspectFitExport4KDimensions, - getExport4KPreflightError, -} from './export-4k'; - -const generousLimits = { - maxBufferSize: Number.MAX_SAFE_INTEGER, - maxTextureDimension2D: Number.MAX_SAFE_INTEGER, -}; - -describe('4K export preflight', () => { - it('fits export dimensions inside 4K while preserving source aspect ratio', () => { - expect(getAspectFitExport4KDimensions(3840, 2160)).toEqual({ - width: 3840, - height: 2160, - }); - expect(getAspectFitExport4KDimensions(800, 600)).toEqual({ - width: 2880, - height: 2160, - }); - expect(getAspectFitExport4KDimensions(600, 800)).toEqual({ - width: 1620, - height: 2160, - }); - expect(getAspectFitExport4KDimensions(1000, 1000)).toEqual({ - width: 2160, - height: 2160, - }); - }); - - it('estimates padded readback and temporary memory for the export', () => { - const estimate = estimateExport4KMemory(); - - expect(estimate.width).toBe(3840); - expect(estimate.height).toBe(2160); - expect(estimate.bytesPerRow % 256).toBe(0); - expect(estimate.estimatedPeakBytes).toBeGreaterThan(estimate.textureBytes); - }); - - it('rejects GPUs that cannot allocate the export texture', () => { - const error = getExport4KPreflightError({ - limits: { - maxBufferSize: Number.MAX_SAFE_INTEGER, - maxTextureDimension2D: 2048, - }, - }); - - expect(error?.code).toBe('export-4k-texture-too-large'); - }); - - it('rejects GPUs that cannot allocate the readback buffer', () => { - const estimate = estimateExport4KMemory(); - const error = getExport4KPreflightError({ - limits: { - maxBufferSize: estimate.readbackBufferBytes - 1, - maxTextureDimension2D: Number.MAX_SAFE_INTEGER, - }, - estimate, - }); - - expect(error?.code).toBe('export-4k-readback-too-large'); - }); - - it('rejects browser-reported low-memory devices', () => { - const error = getExport4KPreflightError({ - limits: generousLimits, - memoryInfo: { - deviceMemoryBytes: 2 * 1024 ** 3, - }, - }); - - expect(error?.code).toBe('export-4k-low-device-memory'); - }); - - it('allows export when memory hints are unavailable', () => { - expect( - getExport4KPreflightError({ - limits: generousLimits, - }) - ).toBeNull(); - }); -}); diff --git a/src/game-loop/frame-performance.test.ts b/src/game-loop/frame-performance.test.ts deleted file mode 100644 index 5facc5d..0000000 --- a/src/game-loop/frame-performance.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { FramePerformance } from './frame-performance'; - -const INITIAL_FPS = 60; - -function createScenario() { - const performance = new FramePerformance(); - let time = 0; - performance.update(time); - const advance = (fps: number): void => { - time += 1000 / fps; - performance.update(time); - }; - return { performance, advance }; -} - -describe('FramePerformance', () => { - it('starts at the adaptive budget target', () => { - const { performance } = createScenario(); - - expect(performance.smoothedFps).toBe(INITIAL_FPS); - }); - - it('smooths measured frame rates', () => { - const { performance, advance } = createScenario(); - - advance(120); - - expect(performance.smoothedFps).toBeGreaterThan(INITIAL_FPS); - expect(performance.smoothedFps).toBeLessThan(120); - }); - - it('ignores long gaps before smoothing resumes', () => { - const performance = new FramePerformance(); - performance.update(0); - performance.update(2_000); - - expect(performance.smoothedFps).toBe(INITIAL_FPS); - - performance.update(2_000 + 1000 / 30); - - expect(performance.smoothedFps).toBeLessThan(INITIAL_FPS); - }); -}); diff --git a/src/game-loop/frame-performance.ts b/src/game-loop/frame-performance.ts index ac3e18f..45e19fc 100644 --- a/src/game-loop/frame-performance.ts +++ b/src/game-loop/frame-performance.ts @@ -2,6 +2,9 @@ import { appConfig } from '../config'; export class FramePerformance { public smoothedFps = appConfig.simulation.budget.initialFps; + public measuredFps = 0; + public frameDeltaSeconds = 0; + public measuredFrameTimeMs = 0; private previousFrameTime: DOMHighResTimeStamp | null = null; @@ -13,14 +16,18 @@ export class FramePerformance { } const deltaSeconds = (time - previous) / 1000; - if ( - deltaSeconds <= 0 || - deltaSeconds > appConfig.simulation.budget.frameGapResetSeconds - ) { + if (deltaSeconds <= 0) { return; } const fps = 1 / deltaSeconds; + this.frameDeltaSeconds = deltaSeconds; + this.measuredFrameTimeMs = deltaSeconds * 1000; + this.measuredFps = fps; + if (deltaSeconds > appConfig.simulation.budget.frameGapResetSeconds) { + return; + } + this.smoothedFps = this.smoothedFps * appConfig.simulation.budget.fpsSmoothingRetain + fps * appConfig.simulation.budget.fpsSmoothingNew; diff --git a/src/game-loop/game-loop-intro.test.ts b/src/game-loop/game-loop-intro.test.ts deleted file mode 100644 index 9131f18..0000000 --- a/src/game-loop/game-loop-intro.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { describe, expect, it } from 'vitest'; - -const gameLoopSource = readFileSync( - join(process.cwd(), 'src/game-loop/game-loop.ts'), - 'utf8' -); - -const getStartDrawingHandlerSource = () => { - const start = gameLoopSource.indexOf('onStartDrawing:'); - const end = gameLoopSource.indexOf('onEraseGestureEnded:', start); - - if (start < 0 || end < 0) { - throw new Error('Could not find the pointer drawing intro handler'); - } - - return gameLoopSource.slice(start, end); -}; - -describe('GameLoop intro drawing policy', () => { - it('allows drawing to start without completing the intro sequence', () => { - const handlerSource = getStartDrawingHandlerSource(); - - expect(handlerSource).toContain('this.introPrompt.markStartedDrawing()'); - expect(handlerSource).not.toContain('this.introPrompt.complete('); - }); -}); diff --git a/src/game-loop/game-loop-ping-pong.test.ts b/src/game-loop/game-loop-ping-pong.test.ts deleted file mode 100644 index 588725b..0000000 --- a/src/game-loop/game-loop-ping-pong.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { describe, expect, it } from 'vitest'; - -const simulationFrameSource = readFileSync( - join(process.cwd(), 'src/game-loop/simulation-frame.ts'), - 'utf8' -); -const simulationTexturesSource = readFileSync( - join(process.cwd(), 'src/game-loop/simulation-textures.ts'), - 'utf8' -); -const resizableTextureSource = readFileSync( - join(process.cwd(), 'src/utils/graphics/resizable-texture.ts'), - 'utf8' -); - -const getRenderStepSource = () => { - const start = simulationFrameSource.indexOf( - 'const commandEncoder = this.device.createCommandEncoder();' - ); - const swapCall = ' this.textures.swapBrushEffectMaps();'; - const end = simulationFrameSource.indexOf(swapCall, start) + swapCall.length; - - if (start < 0 || end < 0) { - throw new Error('Could not find the simulation frame execution body'); - } - - return simulationFrameSource.slice(start, end); -}; - -describe('GameLoop ping-pong texture flow', () => { - it('copies only the trail map with a GPU texture copy and swaps source/influence references after diffusion', () => { - const renderStepSource = getRenderStepSource(); - - expect(renderStepSource).not.toContain('copyPipeline.execute'); - expect(renderStepSource).toContain('this.textures.copyTrailMapAToB(commandEncoder);'); - expect(simulationTexturesSource).toMatch( - /commandEncoder\.copyTextureToTexture\([\s\S]*this\.trailMapA\.getTexture\(\)[\s\S]*this\.trailMapB\.getTexture\(\)[\s\S]*width: size\[0\][\s\S]*height: size\[1\][\s\S]*\);/ - ); - expect(renderStepSource).toMatch( - /this\.pipelines\.diffusionPipeline\.execute\([\s\S]*this\.textures\.sourceMapA\.getTextureView\(\)[\s\S]*this\.textures\.sourceMapB\.getTextureView\(\)[\s\S]*\);[\s\S]*this\.pipelines\.brushEffectDiffusionPipeline\.execute\([\s\S]*this\.textures\.influenceMapA\.getTextureView\(\)[\s\S]*this\.textures\.influenceMapB\.getTextureView\(\)[\s\S]*\);[\s\S]*this\.device\.queue\.submit\(\[commandEncoder\.finish\(\)\]\);[\s\S]*this\.textures\.swapBrushEffectMaps\(\);/ - ); - }); - - it('keeps resizable textures usable for render, shader, and GPU copy paths', () => { - expect(resizableTextureSource).toContain('public getTexture(): GPUTexture'); - expect(resizableTextureSource).toContain('GPUTextureUsage.COPY_SRC'); - expect(resizableTextureSource).toContain('GPUTextureUsage.COPY_DST'); - expect(resizableTextureSource).toContain('commandEncoder.copyTextureToTexture('); - expect(simulationTexturesSource).not.toContain('private readonly copyPipeline'); - }); - - it('keeps ping-pong texture references mutable and swaps A/B identities', () => { - expect(simulationTexturesSource).toContain('public sourceMapA: ResizableTexture;'); - expect(simulationTexturesSource).toContain('public sourceMapB: ResizableTexture;'); - expect(simulationTexturesSource).toContain('public influenceMapA: ResizableTexture;'); - expect(simulationTexturesSource).toContain('public influenceMapB: ResizableTexture;'); - expect(simulationTexturesSource).toContain('public swapBrushEffectMaps(): void'); - expect(simulationTexturesSource).toContain( - '[this.sourceMapA, this.sourceMapB] = [this.sourceMapB, this.sourceMapA];' - ); - expect(simulationTexturesSource).toContain( - '[this.influenceMapA, this.influenceMapB] = [this.influenceMapB, this.influenceMapA];' - ); - }); -}); diff --git a/src/game-loop/game-loop-resources.ts b/src/game-loop/game-loop-resources.ts index 4acd14b..06778f6 100644 --- a/src/game-loop/game-loop-resources.ts +++ b/src/game-loop/game-loop-resources.ts @@ -20,7 +20,7 @@ interface FrameParameters extends RenderInputs { deltaTime: number; canvasSize: vec2; activeAgentCount: number; - devicePixelRatio: number; + canvasPixelRatio: number; introProgress: number; selectedColorIndex: number; isErasing: boolean; @@ -58,7 +58,7 @@ export class GameLoopResources { this.agentGenerationPipeline = new AgentGenerationPipeline( this.device, - appConfig.simulation.budget.adaptiveCapMax + Math.min(settings.maxAgentCount, appConfig.simulation.budget.adaptiveCapInitial) ); this.agentPipeline = new AgentPipeline( @@ -100,7 +100,7 @@ export class GameLoopResources { deltaTime, canvasSize, activeAgentCount, - devicePixelRatio, + canvasPixelRatio, introProgress, selectedColorIndex, channelColors, @@ -125,7 +125,7 @@ export class GameLoopResources { }); this.brushPipeline.setParameters({ ...settings, - devicePixelRatio, + pixelRatio: canvasPixelRatio, selectedColorIndex, }); this.diffusionPipeline.setParameters(settings); diff --git a/src/game-loop/game-loop.ts b/src/game-loop/game-loop.ts index 3ba7ee3..09af1e6 100644 --- a/src/game-loop/game-loop.ts +++ b/src/game-loop/game-loop.ts @@ -6,11 +6,13 @@ import { appConfig } from '../config'; import { activeVibe, settings } from '../settings'; import { DeltaTimeCalculator } from '../utils/delta-time-calculator'; import { AgentPopulation } from './agent-population'; +import { DevStatsOverlay } from './dev-stats-overlay'; import { EraserPreview } from './eraser-preview'; import { Export4KRenderer } from './export-4k-renderer'; import { FramePerformance } from './frame-performance'; import { GameLoopResources } from './game-loop-resources'; import { GardenUi } from './game-loop-types'; +import { getInternalRenderSize } from './internal-render-size'; import { IntroPrompt } from './intro-prompt'; import { GardenPointerInput } from './pointer-input'; import { RenderInputCache } from './render-input-cache'; @@ -26,11 +28,11 @@ export default class GameLoop { private readonly agentPopulation: AgentPopulation; private readonly export4KRenderer: Export4KRenderer; private readonly framePerformance = new FramePerformance(); + private readonly devStatsOverlay: DevStatsOverlay | null; private readonly toolbarContrastMonitor: ToolbarContrastMonitor; private readonly seedValue = Math.floor(Math.random() * 0xffffffff); private readonly seed = this.seedValue.toString(16); private readonly resizeListener = this.resize.bind(this); - private readonly keydownListener: (event: KeyboardEvent) => void; private pendingIntroResizeAt: DOMHighResTimeStamp | null = null; private hasFinished = false; @@ -38,11 +40,14 @@ export default class GameLoop { public constructor( private readonly canvas: HTMLCanvasElement, - device: GPUDevice, + private readonly device: GPUDevice, private readonly deltaTimeCalculator: DeltaTimeCalculator, ui: GardenUi ) { this.resize(); + this.devStatsOverlay = import.meta.env.DEV + ? new DevStatsOverlay(canvas.parentElement ?? document.body) + : null; this.resources = new GameLoopResources(canvas, device, this.canvasSize); this.introPrompt = new IntroPrompt(ui.prompt); this.eraserPreview = new EraserPreview(canvas, ui.eraserPreview); @@ -50,7 +55,7 @@ export default class GameLoop { this.agentPopulation = new AgentPopulation( this.resources.agentGenerationPipeline, this.seedValue, - () => this.devicePixelRatio + () => this.canvasPixelRatio ); this.agentPopulation.initializeIntroAgents(this.canvasSize); this.pointerInput = new GardenPointerInput({ @@ -60,7 +65,7 @@ export default class GameLoop { eraserAgentPipeline: this.resources.eraserAgentPipeline, eraserTexturePipeline: this.resources.eraserTexturePipeline, eraserPreview: this.eraserPreview, - getDevicePixelRatio: () => this.devicePixelRatio, + getCanvasPixelRatio: () => this.canvasPixelRatio, getMirrorSegmentCount: () => this.mirrorSegmentCount, onStartDrawing: () => this.introPrompt.markStartedDrawing(), onEraseGestureEnded: () => this.agentPopulation.requestCompactionAfterErase(), @@ -79,13 +84,8 @@ export default class GameLoop { getSourceTextureView: () => this.resources.textures.sourceMapA.getTextureView(), getVibeId: () => activeVibe.id, }); - this.keydownListener = () => { - this.audio.start(activeVibe, { userGesture: true }); - this.introPrompt.complete(); - }; window.addEventListener('resize', this.resizeListener); - window.addEventListener('keydown', this.keydownListener, { once: true }); this.pointerInput.attach(); } @@ -132,15 +132,16 @@ export default class GameLoop { await this.finished.promise; window.removeEventListener('resize', this.resizeListener); - window.removeEventListener('keydown', this.keydownListener); this.pointerInput.detach(); + this.devStatsOverlay?.destroy(); this.toolbarContrastMonitor.destroy(); this.introPrompt.destroy(); + await this.agentPopulation.waitForCompaction(); this.resources.destroy(); await this.audio.destroy(); } - private readonly render = async (time: DOMHighResTimeStamp) => { + private readonly render = (time: DOMHighResTimeStamp) => { if (this.hasFinished) { this.finished.resolve(); return; @@ -148,7 +149,10 @@ export default class GameLoop { const deltaTime = this.deltaTimeCalculator.calculateDeltaTimeInSeconds(time); this.framePerformance.update(time); - this.agentPopulation.growBudget(deltaTime, this.framePerformance.smoothedFps); + this.agentPopulation.growBudget( + this.framePerformance.frameDeltaSeconds, + this.framePerformance.smoothedFps + ); this.introPrompt.update(this.pendingIntroResizeAt === null ? deltaTime : 0); this.resize(); this.resizeSimulationToCanvas(time); @@ -156,8 +160,8 @@ export default class GameLoop { const { channelColors, backgroundColor } = this.renderInputs.get(); const introProgress = this.introPrompt.progress; - const devicePixelRatio = this.devicePixelRatio; - const eraserPixelSize = settings.eraserSize * devicePixelRatio; + const canvasPixelRatio = this.canvasPixelRatio; + const eraserPixelSize = settings.eraserSize * canvasPixelRatio; const isErasing = this.pointerInput.isEraseMode; const accentColor = channelColors[settings.selectedColorIndex] ?? channelColors[0]; this.renderInputs.updateAccentColor(accentColor); @@ -171,7 +175,7 @@ export default class GameLoop { deltaTime, canvasSize: this.canvasSize, activeAgentCount: this.agentPopulation.activeAgentCount, - devicePixelRatio, + canvasPixelRatio, introProgress, selectedColorIndex: settings.selectedColorIndex, isErasing, @@ -186,20 +190,28 @@ export default class GameLoop { ); this.pointerInput.clearSwipesIfIdle(); - await this.agentPopulation.compactAfterErase(this.pointerInput.isSwipeActive); + this.agentPopulation.compactAfterErase(this.pointerInput.isSwipeActive); + this.devStatsOverlay?.update({ + time, + fps: this.framePerformance.measuredFps, + agentCount: this.agentPopulation.activeAgentCount, + frameTimeMs: this.framePerformance.measuredFrameTimeMs, + renderWidth: this.canvas.width, + renderHeight: this.canvas.height, + }); requestAnimationFrame(this.render); }; private resize(): void { - const width = Math.max( - 1, - Math.floor(this.canvas.clientWidth * this.devicePixelRatio) - ); - const height = Math.max( - 1, - Math.floor(this.canvas.clientHeight * this.devicePixelRatio) - ); + const rect = this.canvas.getBoundingClientRect(); + const { width, height } = getInternalRenderSize({ + clientHeight: rect.height || this.canvas.clientHeight, + clientWidth: rect.width || this.canvas.clientWidth, + maxPixelScale: appConfig.simulation.maxDevicePixelRatio, + maxTextureDimension: this.device.limits.maxTextureDimension2D, + targetAreaMegapixels: settings.internalRenderAreaMegapixels, + }); if (this.canvas.width === width && this.canvas.height === height) { return; @@ -249,8 +261,11 @@ export default class GameLoop { return vec2.fromValues(this.canvas.width, this.canvas.height); } - private get devicePixelRatio(): number { - const ratio = window.devicePixelRatio; + private get canvasPixelRatio(): number { + const rect = this.canvas.getBoundingClientRect(); + const xScale = rect.width > 0 ? this.canvas.width / rect.width : 1; + const yScale = rect.height > 0 ? this.canvas.height / rect.height : xScale; + const ratio = (xScale + yScale) / 2; return Number.isFinite(ratio) && ratio > 0 ? ratio : 1; } diff --git a/src/game-loop/intro-prompt.test.ts b/src/game-loop/intro-prompt.test.ts deleted file mode 100644 index 90650fe..0000000 --- a/src/game-loop/intro-prompt.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -import { appConfig } from '../config'; -import { IntroPrompt } from './intro-prompt'; - -const createPromptElement = (): HTMLElement => - ({ - classList: { - add: vi.fn(), - contains: vi.fn(() => false), - remove: vi.fn(), - }, - innerHTML: '', - replaceChildren: vi.fn(), - }) as unknown as HTMLElement; - -describe('IntroPrompt', () => { - it('advances progress from simulation delta time', () => { - const prompt = new IntroPrompt(createPromptElement()); - - prompt.update(appConfig.simulation.intro.durationSeconds / 2); - - expect(prompt.progress).toBeCloseTo(0.5); - }); - - it('caps progress when the intro completes', () => { - const prompt = new IntroPrompt(createPromptElement()); - - prompt.update(appConfig.simulation.intro.durationSeconds * 2); - prompt.update(appConfig.simulation.intro.durationSeconds); - - expect(prompt.progress).toBe(1); - }); - - it('can rewind an active intro to leave a minimum resize paint window', () => { - const prompt = new IntroPrompt(createPromptElement()); - - prompt.update(appConfig.simulation.intro.durationSeconds * 0.95); - prompt.rewindToLeaveRemainingTime(appConfig.simulation.intro.durationSeconds * 0.25); - - expect(prompt.progress).toBeCloseTo(0.75); - }); - - it('allows title regeneration only before drawing or completion', () => { - const prompt = new IntroPrompt(createPromptElement()); - - expect(prompt.shouldRegenerateTitleOnResize).toBe(true); - - prompt.markStartedDrawing(); - - expect(prompt.shouldRegenerateTitleOnResize).toBe(false); - - const completedPrompt = new IntroPrompt(createPromptElement()); - completedPrompt.complete(); - - expect(completedPrompt.shouldRegenerateTitleOnResize).toBe(false); - }); -}); diff --git a/src/game-loop/intro-title-agents.test.ts b/src/game-loop/intro-title-agents.test.ts deleted file mode 100644 index 03a8688..0000000 --- a/src/game-loop/intro-title-agents.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { AGENT_FLOAT_COUNT } from '../pipelines/agents/agent-generation/agent'; -import { createIntroTitleAgents } from './intro-title-agents'; - -const installCanvasMask = () => { - Object.defineProperty(globalThis, 'document', { - configurable: true, - value: { - createElement: vi.fn(() => { - const canvas = { - width: 0, - height: 0, - getContext: vi.fn(() => ({ - clearRect: vi.fn(), - fillStyle: '', - fillText: vi.fn(), - font: '', - getImageData: vi.fn(() => { - const data = new Uint8ClampedArray(canvas.width * canvas.height * 4); - for (let i = 3; i < data.length; i += 4) { - data[i] = 255; - } - return { data }; - }), - lineJoin: '', - lineWidth: 0, - measureText: vi.fn((text: string) => ({ - actualBoundingBoxAscent: 10, - actualBoundingBoxDescent: 4, - width: text.length * 10, - })), - strokeStyle: '', - strokeText: vi.fn(), - textAlign: '', - textBaseline: '', - })), - }; - - return canvas; - }), - }, - }); -}; - -describe('createIntroTitleAgents', () => { - beforeEach(() => { - installCanvasMask(); - }); - - afterEach(() => { - Reflect.deleteProperty(globalThis, 'document'); - }); - - it('creates deterministic agents for the same seed and progress', () => { - const first = createIntroTitleAgents({ - count: 4, - height: 32, - progress: 0.4, - seed: 123, - width: 64, - }); - const second = createIntroTitleAgents({ - count: 4, - height: 32, - progress: 0.4, - seed: 123, - width: 64, - }); - - expect(Array.from(first)).toEqual(Array.from(second)); - }); - - it('preserves targets while advancing positions for a later intro progress', () => { - const initial = createIntroTitleAgents({ - count: 1, - height: 32, - progress: 0, - seed: 456, - width: 64, - }); - const later = createIntroTitleAgents({ - count: 1, - height: 32, - progress: 0.8, - seed: 456, - width: 64, - }); - - expect(later[0]).not.toBe(initial[0]); - expect(later[1]).not.toBe(initial[1]); - expect(later[4]).toBe(initial[4]); - expect(later[5]).toBe(initial[5]); - expect(later[7]).toBe(initial[7]); - expect(later.length).toBe(AGENT_FLOAT_COUNT); - }); -}); diff --git a/src/game-loop/intro-title-agents.ts b/src/game-loop/intro-title-agents.ts index b102ae1..95c3480 100644 --- a/src/game-loop/intro-title-agents.ts +++ b/src/game-loop/intro-title-agents.ts @@ -1,5 +1,6 @@ import { appConfig } from '../config'; -import { AGENT_FLOAT_COUNT } from '../pipelines/agents/agent-generation/agent'; +import { AGENT_FLOAT_COUNT } from '../pipelines/agents/agent-generation/agent-generation-pipeline'; +import { clamp, easeOutQuad, mix, mixAngle, smoothstep } from '../utils/math'; interface IntroTitlePoint { x: number; @@ -398,19 +399,6 @@ const createSeededRandom = (seed: number): RandomSource => { }; }; -const mix = (from: number, to: number, amount: number): number => - from + (to - from) * amount; - -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; -}; - -const smoothstep = (edge0: number, edge1: number, value: number): number => { - const t = clamp((value - edge0) / (edge1 - edge0), 0, 1); - return t * t * (3 - 2 * t); -}; - const easePathProgress = (amount: number): number => { if (appConfig.simulation.intro.pathEasing === 'linear') { return amount; @@ -418,10 +406,3 @@ const easePathProgress = (amount: number): number => { return easeOutQuad(amount); }; - -const easeOutQuad = (value: number): number => value * (2 - value); - -const clamp = (value: number, min: number, max: number): number => { - const safeValue = Number.isFinite(value) ? value : min; - return Math.min(max, Math.max(min, safeValue)); -}; diff --git a/src/game-loop/pointer-input.test.ts b/src/game-loop/pointer-input.test.ts deleted file mode 100644 index a22a715..0000000 --- a/src/game-loop/pointer-input.test.ts +++ /dev/null @@ -1,327 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -type PointerListener = (event: PointerEvent) => void; - -const makePointerEvent = ( - type: string, - event: Partial = {} -): PointerEvent => - ({ - buttons: 1, - clientX: 10, - clientY: 20, - isTrusted: true, - pointerId: 1, - pointerType: 'mouse', - pressure: 0.5, - timeStamp: 100, - type, - ...event, - }) as PointerEvent; - -const toPoint = (point: ArrayLike): Array => Array.from(point); - -class FakeCanvas { - public readonly capturedPointerIds: Array = []; - public readonly releasedPointerIds: Array = []; - public width = 300; - public height = 200; - - private readonly listeners = new Map>(); - - public addEventListener( - type: string, - listener: EventListenerOrEventListenerObject - ): void { - const listeners = this.listeners.get(type) ?? new Set(); - const pointerListener = - typeof listener === 'function' - ? listener - : (event: Event) => listener.handleEvent(event); - listeners.add(pointerListener as PointerListener); - this.listeners.set(type, listeners); - } - - public removeEventListener( - type: string, - listener: EventListenerOrEventListenerObject - ): void { - const listeners = this.listeners.get(type); - if (!listeners) { - return; - } - - listeners.delete(listener as PointerListener); - } - - public dispatchPointerEvent(type: string, event: Partial = {}): void { - const pointerEvent = makePointerEvent(type, event); - - this.listeners.get(type)?.forEach((listener) => listener(pointerEvent)); - } - - public getBoundingClientRect(): DOMRect { - return { - bottom: this.height, - height: this.height, - left: 0, - right: this.width, - toJSON: () => ({}), - top: 0, - width: this.width, - x: 0, - y: 0, - } as DOMRect; - } - - public setPointerCapture(pointerId: number): void { - this.capturedPointerIds.push(pointerId); - } - - public releasePointerCapture(pointerId: number): void { - this.releasedPointerIds.push(pointerId); - } -} - -const makeSwipePipeline = () => ({ - addSwipeSegment: vi.fn(), - clearSwipes: vi.fn(), -}); - -const createPointerInput = async ({ - devicePixelRatio = 1, -}: { devicePixelRatio?: number } = {}) => { - const { GardenPointerInput } = await import('./pointer-input'); - const { settings: runtimeSettings } = await import('../settings'); - const canvas = new FakeCanvas(); - const audio = { - beginGesture: vi.fn(), - endGesture: vi.fn(), - start: vi.fn(), - stroke: vi.fn(), - }; - const brushPipeline = makeSwipePipeline(); - const eraserAgentPipeline = makeSwipePipeline(); - const eraserTexturePipeline = makeSwipePipeline(); - const eraserPreview = { - isPointerInsideCanvas: vi.fn(() => true), - setEraseMode: vi.fn(), - setPointerHoveringCanvas: vi.fn(), - update: vi.fn(), - }; - const onStartDrawing = vi.fn(); - const onEraseGestureEnded = vi.fn(); - const spawnStrokeAgents = vi.fn(); - const input = new GardenPointerInput({ - audio, - brushPipeline, - canvas: canvas as unknown as HTMLCanvasElement, - eraserAgentPipeline, - eraserPreview, - eraserTexturePipeline, - getDevicePixelRatio: () => devicePixelRatio, - getMirrorSegmentCount: () => 1, - onEraseGestureEnded, - onStartDrawing, - spawnStrokeAgents, - } as unknown as ConstructorParameters[0]); - - input.attach(); - - return { - audio, - brushPipeline, - canvas, - input, - onStartDrawing, - runtimeSettings, - spawnStrokeAgents, - }; -}; - -describe('GardenPointerInput drawing startup', () => { - beforeEach(() => { - vi.resetModules(); - vi.stubGlobal('localStorage', { - clear: vi.fn(), - getItem: vi.fn(() => null), - removeItem: vi.fn(), - setItem: vi.fn(), - }); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it('allows pointer drawing immediately', async () => { - const { audio, brushPipeline, canvas, onStartDrawing, spawnStrokeAgents } = - await createPointerInput(); - - canvas.dispatchPointerEvent('pointerdown', { pointerId: 7 }); - canvas.dispatchPointerEvent('pointermove', { - clientX: 60, - clientY: 80, - pointerId: 7, - timeStamp: 120, - }); - - expect(onStartDrawing).toHaveBeenCalledTimes(1); - expect(audio.start).toHaveBeenCalledWith(expect.anything(), { userGesture: true }); - expect(audio.beginGesture).toHaveBeenCalledTimes(1); - expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(2); - expect(spawnStrokeAgents).toHaveBeenCalledTimes(2); - expect(canvas.capturedPointerIds).toEqual([7]); - }); - - it('starts drawing from a fresh pointerdown', async () => { - const { audio, brushPipeline, canvas, onStartDrawing, spawnStrokeAgents } = - await createPointerInput(); - - canvas.dispatchPointerEvent('pointerdown', { pointerId: 9 }); - - expect(onStartDrawing).toHaveBeenCalledTimes(1); - expect(audio.start).toHaveBeenCalledWith(expect.anything(), { userGesture: true }); - expect(audio.beginGesture).toHaveBeenCalledTimes(1); - expect(audio.stroke).not.toHaveBeenCalled(); - expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(1); - expect(spawnStrokeAgents).toHaveBeenCalledTimes(1); - expect(canvas.capturedPointerIds).toEqual([9]); - }); - - it('flushes the delayed smoothed stroke tail on pointerup', async () => { - const { brushPipeline, canvas } = await createPointerInput(); - - canvas.dispatchPointerEvent('pointerdown', { pointerId: 9 }); - canvas.dispatchPointerEvent('pointermove', { - clientX: 60, - clientY: 80, - pointerId: 9, - timeStamp: 120, - }); - canvas.dispatchPointerEvent('pointerup', { - clientX: 60, - clientY: 80, - pointerId: 9, - timeStamp: 140, - }); - - expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(3); - expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[1][0])).toEqual([10, 20]); - expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[1][1])).toEqual([35, 50]); - expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[2][0])).toEqual([35, 50]); - expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[2][1])).toEqual([60, 80]); - }); - - it('uses coalesced pointer samples for smoother brush segments', async () => { - const { audio, brushPipeline, canvas, spawnStrokeAgents } = - await createPointerInput(); - - canvas.dispatchPointerEvent('pointerdown', { pointerId: 9 }); - audio.stroke.mockClear(); - brushPipeline.addSwipeSegment.mockClear(); - spawnStrokeAgents.mockClear(); - - canvas.dispatchPointerEvent('pointermove', { - clientX: 40, - clientY: 20, - getCoalescedEvents: () => [ - makePointerEvent('pointermove', { - clientX: 20, - clientY: 20, - pointerId: 9, - timeStamp: 110, - }), - makePointerEvent('pointermove', { - clientX: 30, - clientY: 20, - pointerId: 9, - timeStamp: 115, - }), - makePointerEvent('pointermove', { - clientX: 40, - clientY: 20, - pointerId: 9, - timeStamp: 120, - }), - ], - pointerId: 9, - timeStamp: 120, - }); - - expect(audio.stroke).toHaveBeenCalledTimes(3); - expect(spawnStrokeAgents).toHaveBeenCalledTimes(3); - expect(brushPipeline.addSwipeSegment.mock.calls.length).toBeGreaterThan(3); - }); - - it('passes normalized audio geometry context with stroke events', async () => { - const { audio, canvas } = await createPointerInput(); - - canvas.dispatchPointerEvent('pointerdown', { pointerId: 9, timeStamp: 100 }); - canvas.dispatchPointerEvent('pointermove', { - clientX: 40, - clientY: 50, - pointerId: 9, - timeStamp: 150, - }); - - expect(audio.stroke).toHaveBeenCalledWith( - expect.objectContaining({ - canvasSize: [300, 200], - elapsedSeconds: 0.05, - from: expect.anything(), - isErasing: false, - to: expect.anything(), - }) - ); - const stroke = audio.stroke.mock.calls[0][0]; - expect(toPoint(stroke.from)).toEqual([10, 20]); - expect(toPoint(stroke.to)).toEqual([40, 50]); - }); - - it('keeps pointer geometry in backing pixels on high-DPR canvases', async () => { - const { audio, brushPipeline, canvas } = await createPointerInput({ - devicePixelRatio: 2, - }); - - canvas.dispatchPointerEvent('pointerdown', { - clientX: 10, - clientY: 20, - pointerId: 9, - timeStamp: 100, - }); - canvas.dispatchPointerEvent('pointermove', { - clientX: 40, - clientY: 50, - pointerId: 9, - timeStamp: 150, - }); - - const firstStroke = audio.stroke.mock.calls[0][0]; - expect(toPoint(firstStroke.from)).toEqual([20, 40]); - expect(toPoint(firstStroke.to)).toEqual([80, 100]); - expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[0][0])).toEqual([20, 40]); - }); - - it('caps curve tessellation with the brush curve resolution setting', async () => { - const { brushPipeline, canvas, runtimeSettings } = await createPointerInput(); - runtimeSettings.brushCurveResolution = 2; - runtimeSettings.brushSize = 1; - - canvas.dispatchPointerEvent('pointerdown', { pointerId: 9 }); - canvas.dispatchPointerEvent('pointermove', { - clientX: 10, - clientY: 60, - pointerId: 9, - timeStamp: 120, - }); - canvas.dispatchPointerEvent('pointermove', { - clientX: 60, - clientY: 60, - pointerId: 9, - timeStamp: 140, - }); - - expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(4); - }); -}); diff --git a/src/game-loop/pointer-input.ts b/src/game-loop/pointer-input.ts index fa80996..c3441e9 100644 --- a/src/game-loop/pointer-input.ts +++ b/src/game-loop/pointer-input.ts @@ -4,7 +4,7 @@ import { GardenAudio } from '../audio/garden-audio'; import { appConfig } from '../config'; import { BrushPipeline, - getSafeDevicePixelRatio, + getSafePixelRatio, } from '../pipelines/brush/brush-pipeline'; import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline'; import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline'; @@ -19,7 +19,7 @@ interface GardenPointerInputOptions { eraserAgentPipeline: EraserAgentPipeline; eraserTexturePipeline: EraserTexturePipeline; eraserPreview: EraserPreview; - getDevicePixelRatio: () => number; + getCanvasPixelRatio: () => number; getMirrorSegmentCount: () => number; onStartDrawing: () => void; onEraseGestureEnded: () => void; @@ -106,9 +106,7 @@ export class GardenPointerInput { return; } - if (event.pointerType !== 'touch') { - this.options.audio.start(activeVibe, { userGesture: true }); - } + this.options.audio.start(activeVibe, { userGesture: true }); this.options.audio.beginGesture(); this.options.onStartDrawing(); this.activePointerId = event.pointerId; @@ -204,12 +202,11 @@ export class GardenPointerInput { private getCanvasPointerPosition(event: PointerEvent): vec2 { const rect = this.canvas.getBoundingClientRect(); - const devicePixelRatio = getSafeDevicePixelRatio( - this.options.getDevicePixelRatio() - ); + const xScale = getSafePixelRatio(this.canvas.width / rect.width); + const yScale = getSafePixelRatio(this.canvas.height / rect.height); return vec2.fromValues( - (event.clientX - rect.left) * devicePixelRatio, - (event.clientY - rect.top) * devicePixelRatio + (event.clientX - rect.left) * xScale, + (event.clientY - rect.top) * yScale ); } @@ -219,7 +216,7 @@ export class GardenPointerInput { if ( previousSample !== undefined && vec2.squaredDistance(previousSample, position) <= - getBrushSmoothingDistanceSquared(this.options.getDevicePixelRatio()) + getBrushSmoothingDistanceSquared(this.options.getCanvasPixelRatio()) ) { return; } @@ -253,13 +250,15 @@ export class GardenPointerInput { private addQuadraticBrushSegments(start: vec2, control: vec2, end: vec2): void { const curveLength = vec2.distance(start, control) + vec2.distance(control, end); - const devicePixelRatio = getSafeDevicePixelRatio(this.options.getDevicePixelRatio()); + const canvasPixelRatio = getSafePixelRatio( + this.options.getCanvasPixelRatio() + ); const brushRadius = Math.max( - settings.brushCurveMinBrushRadius * devicePixelRatio, - (settings.brushSize * devicePixelRatio) / 2 + settings.brushCurveMinBrushRadius * canvasPixelRatio, + (settings.brushSize * canvasPixelRatio) / 2 ); const segmentSpacing = Math.max( - settings.brushCurveMinSegmentSpacing * devicePixelRatio, + settings.brushCurveMinSegmentSpacing * canvasPixelRatio, brushRadius * settings.brushCurveSegmentBrushRadiusRatio ); const mirrorSegmentCount = Math.max(1, this.options.getMirrorSegmentCount()); @@ -299,7 +298,7 @@ export class GardenPointerInput { if ( this.lastSmoothedBrushPosition !== null && vec2.squaredDistance(this.lastSmoothedBrushPosition, finalSample) > - getBrushSmoothingDistanceSquared(this.options.getDevicePixelRatio()) + getBrushSmoothingDistanceSquared(this.options.getCanvasPixelRatio()) ) { this.addMirroredBrushSegment(this.lastSmoothedBrushPosition, finalSample); } @@ -381,11 +380,11 @@ const getBrushCurveResolution = (): number => { return Math.max(1, Math.floor(resolution)); }; -const getBrushSmoothingDistanceSquared = (devicePixelRatio?: number): number => { +const getBrushSmoothingDistanceSquared = (pixelRatio?: number): number => { const distance = Number.isFinite(settings.brushSmoothingMinSampleDistance) ? settings.brushSmoothingMinSampleDistance : appConfig.defaultSettings.brushSmoothingMinSampleDistance; - return Math.max(0, distance * getSafeDevicePixelRatio(devicePixelRatio)) ** 2; + return Math.max(0, distance * getSafePixelRatio(pixelRatio)) ** 2; }; const isSamePointerSample = (left: PointerEvent, right: PointerEvent): boolean => diff --git a/src/game-loop/render-input-cache.ts b/src/game-loop/render-input-cache.ts index 6e72c16..53234b3 100644 --- a/src/game-loop/render-input-cache.ts +++ b/src/game-loop/render-input-cache.ts @@ -1,5 +1,6 @@ import { activeVibe } from '../settings'; -import { hexToRgb, type VibeId } from '../vibes'; +import { hexToRgb } from '../utils/hex-to-rgb'; +import { type VibeId } from '../vibes'; import { RenderInputs } from './game-loop-types'; export class RenderInputCache { diff --git a/src/game-loop/toolbar-contrast-monitor.test.ts b/src/game-loop/toolbar-contrast-monitor.test.ts deleted file mode 100644 index 142326a..0000000 --- a/src/game-loop/toolbar-contrast-monitor.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { getToolbarContrastMetrics } from './toolbar-contrast-monitor'; - -const makePixels = ( - samples: ReadonlyArray -): Uint8Array => { - const pixels = new Uint8Array(samples.length * 4); - samples.forEach(([red, green, blue], index) => { - const offset = index * 4; - pixels[offset] = red; - pixels[offset + 1] = green; - pixels[offset + 2] = blue; - pixels[offset + 3] = 255; - }); - return pixels; -}; - -describe('toolbar contrast monitoring', () => { - it('leaves the toolbar transparent over dark canvas samples', () => { - const metrics = getToolbarContrastMetrics( - makePixels(Array.from({ length: 91 }, () => [8, 12, 18])), - 91, - false - ); - - expect(metrics.backgroundOpacity).toBe(0); - expect(metrics.lowContrastRatio).toBe(0); - }); - - it('ramps background opacity as canvas samples get lighter', () => { - const dimMetrics = getToolbarContrastMetrics( - makePixels(Array.from({ length: 91 }, () => [130, 130, 130])), - 91, - false - ); - const brightMetrics = getToolbarContrastMetrics( - makePixels(Array.from({ length: 91 }, () => [210, 210, 210])), - 91, - false - ); - - expect(dimMetrics.backgroundOpacity).toBeGreaterThan(0); - expect(brightMetrics.backgroundOpacity).toBeGreaterThan(dimMetrics.backgroundOpacity); - expect(brightMetrics.backgroundOpacity).toBeLessThanOrEqual(0.82); - }); - - it('raises background opacity when enough samples have poor contrast with white controls', () => { - const darkSamples = Array.from({ length: 82 }, () => [8, 12, 18] as const); - const brightSamples = Array.from({ length: 9 }, () => [245, 240, 218] as const); - const metrics = getToolbarContrastMetrics( - makePixels([...darkSamples, ...brightSamples]), - 91, - false - ); - - expect(metrics.lowContrastRatio).toBeGreaterThanOrEqual(0.08); - expect(metrics.backgroundOpacity).toBeGreaterThan(0); - }); - - it('reads bgra canvas samples in the correct channel order', () => { - const bgraPixels = new Uint8Array([0, 0, 255, 255]); - const metrics = getToolbarContrastMetrics(bgraPixels, 1, true); - - expect(metrics.averageLuminance).toBeCloseTo(0.2126); - }); -}); diff --git a/src/game-loop/toolbar-contrast-monitor.ts b/src/game-loop/toolbar-contrast-monitor.ts index 5b90562..42ef8f1 100644 --- a/src/game-loop/toolbar-contrast-monitor.ts +++ b/src/game-loop/toolbar-contrast-monitor.ts @@ -1,4 +1,5 @@ import { appConfig } from '../config'; +import { clamp01 } from '../utils/math'; import type { CanvasReadbackRequest } from './game-loop-types'; interface CanvasSamplePoint { @@ -16,8 +17,6 @@ interface ToolbarContrastMetrics { const TOOLBAR_BACKGROUND_OPACITY_PROPERTY = '--toolbar-background-opacity'; const TOOLBAR_BACKGROUND_STRENGTH_PROPERTY = '--toolbar-background-strength'; -const clamp01 = (value: number): number => Math.min(1, Math.max(0, value)); - const getLinearChannel = (channel: number): number => { const normalized = channel / 255; return normalized <= appConfig.toolbar.contrast.linearChannelBreakpoint diff --git a/src/index.dom-contract.test.ts b/src/index.dom-contract.test.ts deleted file mode 100644 index a1fd0a6..0000000 --- a/src/index.dom-contract.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { describe, expect, it } from 'vitest'; - -const projectRoot = process.cwd(); -const indexSource = readFileSync(join(projectRoot, 'src/index.ts'), 'utf8'); -const html = readFileSync(join(projectRoot, 'index.html'), 'utf8'); - -const escapeRegex = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - -const hasClass = (className: string, tagName?: string) => { - const tagPattern = tagName ? `<${tagName}\\b[^>]*` : '<[a-z][^>]*'; - return new RegExp( - `${tagPattern}class="[^"]*\\b${escapeRegex(className)}\\b[^"]*"`, - 'i' - ).test(html); -}; - -const hasId = (id: string) => new RegExp(`\\bid="${escapeRegex(id)}"`, 'i').test(html); - -const hasTag = (tagName: string) => - new RegExp(`<${escapeRegex(tagName)}(?:\\s|>|/)`, 'i').test(html); - -const selectorExists = (selector: string) => { - const idSelector = /^#(?[\w-]+)$/.exec(selector); - if (idSelector?.groups?.id) { - return hasId(idSelector.groups.id); - } - - const classSelector = /^\.([\w-]+)$/.exec(selector); - if (classSelector?.[1]) { - return hasClass(classSelector[1]); - } - - const tagClassSelector = /^(?[a-z]+)\.(?[\w-]+)$/.exec(selector); - if (tagClassSelector?.groups) { - return hasClass(tagClassSelector.groups.className, tagClassSelector.groups.tagName); - } - - if (/^[a-z]+$/.test(selector)) { - return hasTag(selector); - } - - throw new Error(`Unsupported selector contract syntax: ${selector}`); -}; - -describe('index DOM selector contract', () => { - it('keeps every boot-time required selector target present in index.html', () => { - const selectors = Array.from( - indexSource.matchAll(/queryRequiredElements?\(\s*'([^']+)'\s*,/g), - (match) => match[1] - ); - - expect(selectors.length).toBeGreaterThan(0); - expect(selectors.filter((selector) => !selectorExists(selector))).toEqual([]); - }); - - it('keeps the three color swatches expected by the palette UI', () => { - const colorSwatchCount = Array.from( - html.matchAll(/class="[^"]*\bcolor-swatch\b[^"]*"/g) - ).length; - - expect(colorSwatchCount).toBe(3); - }); -}); diff --git a/src/index.ts b/src/index.ts index b624a08..f88ae26 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,16 +2,22 @@ import GameLoop from './game-loop/game-loop'; import './index.scss'; -import { initAnalytics, trackExport, trackVibeChange } from './analytics'; +import { initAnalytics, trackExport, trackStart, trackVibeChange } from './analytics'; +import { + APP_STORAGE_KEYS, + DEFAULT_AUDIO_VOLUME, + DISABLED_FLAG_VALUE, + ENABLED_FLAG_VALUE, + UNIT_INTERVAL_INPUT_MAX, + UNIT_INTERVAL_INPUT_MIN, +} from './app-constants'; import { preloadPianoSamples } from './audio/piano-samples'; -import { appConfig } from './config'; import { CollapsiblePanelAnimator } from './page/collapsible-panel-animator'; import { ConfigPane } from './page/config-pane'; import { FullScreenHandler } from './page/full-screen-handler'; import { MenuHider } from './page/menu-hider'; import { activeVibe, applyVibeSettings, resetSettings, settings } from './settings'; import { readBrowserStorage, writeBrowserStorage } from './utils/browser-storage'; -import { clamp } from './utils/clamp'; import { DeltaTimeCalculator } from './utils/delta-time-calculator'; import { queryRequiredElement, queryRequiredElements } from './utils/dom'; import { @@ -21,56 +27,165 @@ import { Severity, } from './utils/error-handler'; import { initializeGpu } from './utils/graphics/initialize-gpu'; +import { clamp01 } from './utils/math'; import { VIBE_PRESETS } from './vibes'; +const AUDIO_VOLUME_STEP = 0.01; + +const ERASER_CONTROL_SCALE_MAX = 1.33; +const ERASER_CONTROL_SCALE_MIN = 0.75; +const ERASER_SIZE_DEFAULT = 96; +const ERASER_SIZE_MAX = 240; +const ERASER_SIZE_MIN = 24; +const ERASER_SIZE_STEP = 1; + +const MIRROR_SEGMENT_DEFAULT = 1; +const MIRROR_SEGMENT_MAX = 12; +const MIRROR_SEGMENT_MIN = 1; +const MIRROR_SEGMENT_OFF_LABEL = 'Mirror off'; +const MIRROR_SEGMENT_STEP = 1; +const MIRROR_SEGMENT_LABEL_SUFFIX = 'slices'; + +const ELEMENT_TAGS = { + div: 'div', + pre: 'pre', +} as const; + +const ARIA_ATTRIBUTES = { + label: 'aria-label', + live: 'aria-live', + pressed: 'aria-pressed', + role: 'role', + valueNow: 'aria-valuenow', + valueText: 'aria-valuetext', +} as const; + +const ARIA_LIVE_VALUES = { + assertive: 'assertive', + polite: 'polite', +} as const; + +const ARIA_ROLES = { + alert: 'alert', + status: 'status', +} as const; + +const CSS_CLASSES = { + active: 'active', + errorsContainer: 'errors-container', + isLoading: 'is-loading', + muted: 'muted', + preDrawing: 'pre-drawing', +} as const; + +const CSS_VARIABLES = { + eraserControlScale: '--eraser-control-scale', + eraserProgress: '--eraser-progress', + gardenBackground: '--garden-background', + loadingProgress: '--loading-progress', + mirrorAngle: '--mirror-angle', + mirrorProgress: '--mirror-progress', + volumeProgress: '--volume-progress', +} as const; + +const DOM_EVENTS = { + click: 'click', + focus: 'focus', + input: 'input', + keydown: 'keydown', + pointerDown: 'pointerdown', + pointerUp: 'pointerup', + touchEnd: 'touchend', + touchStart: 'touchstart', +} as const; + +const APP_SELECTORS = { + aside: 'aside', + canvas: 'canvas', + eraserPreview: '.eraser-preview', + eraserSizeControl: '.eraser-size-control', + eraserSizeSlider: '.eraser-size-slider', + errorContainer: '.errors-container', + export4k: '.export-4k', + exportStatus: '.export-status', + infoButton: 'button.info', + infoElement: '.info-page', + loadingBar: '.loading-bar', + loadingProgress: '.loading-progress', + loadingStatus: '.loading-status', + maximizeFullScreenButton: 'button.maximize-full-screen', + minimizeFullScreenButton: 'button.minimize-full-screen', + mirrorSegmentControl: '.mirror-segment-control', + mirrorSegmentSlider: '.mirror-segment-slider', + nextVibe: '.next-vibe', + previousVibe: '.previous-vibe', + prompt: '.garden-prompt', + restartButton: 'button.restart', + settingsButton: 'button.settings', + soundButton: 'button.sound', + splash: '.splash', + startButton: '.start-button', + swatches: '.color-swatch', + toolbarRow: '.toolbar-row', + volumeControl: '.volume-control', + volumeSlider: '.volume-slider', +} as const; + +const AUDIO_LABELS = { + mutedPrefix: 'Muted', + mute: 'Mute audio', + unmute: 'Unmute audio', + volumeSuffix: 'volume', +} as const; + +const LOADING_MESSAGES = { + fontsError: 'Could not load fonts.', + pianoSamplesError: 'Could not preload piano samples.', + ready: 'Ready', +} as const; + +const VIBE_CHANGE_SOURCES = { + nextButton: 'next-button', + previousButton: 'previous-button', + settings: 'settings', +} as const; + const clampEraserSize = (value: number): number => { - const safeValue = Number.isFinite(value) ? value : appConfig.toolbar.eraser.default; - return Math.min( - appConfig.toolbar.eraser.max, - Math.max(appConfig.toolbar.eraser.min, Math.round(safeValue)) - ); + const safeValue = Number.isFinite(value) ? value : ERASER_SIZE_DEFAULT; + return Math.min(ERASER_SIZE_MAX, Math.max(ERASER_SIZE_MIN, Math.round(safeValue))); }; const getEraserSizeRatio = (size: number): number => - (size - appConfig.toolbar.eraser.min) / - (appConfig.toolbar.eraser.max - appConfig.toolbar.eraser.min); + (size - ERASER_SIZE_MIN) / (ERASER_SIZE_MAX - ERASER_SIZE_MIN); const clampMirrorSegmentCount = (value: number): number => { - const safeValue = Number.isFinite(value) ? value : appConfig.toolbar.mirror.default; + const safeValue = Number.isFinite(value) ? value : MIRROR_SEGMENT_DEFAULT; return Math.min( - appConfig.toolbar.mirror.max, - Math.max(appConfig.toolbar.mirror.min, Math.round(safeValue)) + MIRROR_SEGMENT_MAX, + Math.max(MIRROR_SEGMENT_MIN, Math.round(safeValue)) ); }; const getMirrorSegmentRatio = (count: number): number => - (count - appConfig.toolbar.mirror.min) / - (appConfig.toolbar.mirror.max - appConfig.toolbar.mirror.min); - -const mirrorSegmentNames: Readonly> = - appConfig.toolbar.mirror.names; + (count - MIRROR_SEGMENT_MIN) / (MIRROR_SEGMENT_MAX - MIRROR_SEGMENT_MIN); const formatMirrorSegmentCount = (count: number): string => - count === appConfig.toolbar.mirror.default - ? appConfig.toolbar.mirror.offLabel - : `${count} ${mirrorSegmentNames[count] ?? appConfig.toolbar.mirror.fallbackSegmentName}`; + count === MIRROR_SEGMENT_DEFAULT + ? MIRROR_SEGMENT_OFF_LABEL + : `${count} ${MIRROR_SEGMENT_LABEL_SUFFIX}`; const clampAudioVolume = (value: number): number => { - const safeValue = Number.isFinite(value) ? value : appConfig.toolbar.volume.default; - return clamp(safeValue, appConfig.toolbar.volume.min, appConfig.toolbar.volume.max); + const safeValue = Number.isFinite(value) ? value : DEFAULT_AUDIO_VOLUME; + return clamp01(safeValue); }; -const getAudioVolumeRatio = (volume: number): number => - (volume - appConfig.toolbar.volume.min) / - (appConfig.toolbar.volume.max - appConfig.toolbar.volume.min); - const getAudioVolumePercent = (volume: number): number => - Math.round(getAudioVolumeRatio(volume) * 100); + Math.round(clampAudioVolume(volume) * 100); const readInitialAudioVolume = (): number => { - const storedVolume = readBrowserStorage(appConfig.storage.audioVolumeKey); + const storedVolume = readBrowserStorage(APP_STORAGE_KEYS.audioVolume); return storedVolume === null - ? appConfig.toolbar.volume.default + ? DEFAULT_AUDIO_VOLUME : clampAudioVolume(Number(storedVolume)); }; @@ -82,13 +197,18 @@ type RuntimeUiError = Parameters< >[0]; const renderRuntimeMessage = (container: HTMLElement, error: RuntimeUiError) => { - const message = document.createElement('pre'); + const message = document.createElement(ELEMENT_TAGS.pre); message.className = error.severity; message.textContent = error.code ? `${error.message}\n${error.code}` : error.message; - message.setAttribute('role', error.severity === Severity.ERROR ? 'alert' : 'status'); message.setAttribute( - 'aria-live', - error.severity === Severity.ERROR ? 'assertive' : 'polite' + ARIA_ATTRIBUTES.role, + error.severity === Severity.ERROR ? ARIA_ROLES.alert : ARIA_ROLES.status + ); + message.setAttribute( + ARIA_ATTRIBUTES.live, + error.severity === Severity.ERROR + ? ARIA_LIVE_VALUES.assertive + : ARIA_LIVE_VALUES.polite ); container.append(message); @@ -105,54 +225,69 @@ const getRuntimeUiError = (exception: unknown): RuntimeUiError => ({ }); const renderStartupException = (exception: unknown) => { - const existingContainer = document.querySelector('.errors-container'); + const existingContainer = document.querySelector(APP_SELECTORS.errorContainer); const container = existingContainer instanceof HTMLElement ? existingContainer - : document.createElement('div'); + : document.createElement(ELEMENT_TAGS.div); if (!(existingContainer instanceof HTMLElement)) { - container.className = 'errors-container'; + container.className = CSS_CLASSES.errorsContainer; document.body.append(container); } - container.setAttribute('aria-live', 'assertive'); + container.setAttribute(ARIA_ATTRIBUTES.live, ARIA_LIVE_VALUES.assertive); renderRuntimeMessage(container, getRuntimeUiError(exception)); }; const queryAppElements = () => ({ - aside: queryRequiredElement('aside', HTMLElement), - toolbarRow: queryRequiredElement('.toolbar-row', HTMLElement), - infoButton: queryRequiredElement('button.info', HTMLButtonElement), - infoElement: queryRequiredElement('.info-page', HTMLElement), + aside: queryRequiredElement(APP_SELECTORS.aside, HTMLElement), + toolbarRow: queryRequiredElement(APP_SELECTORS.toolbarRow, HTMLElement), + infoButton: queryRequiredElement(APP_SELECTORS.infoButton, HTMLButtonElement), + infoElement: queryRequiredElement(APP_SELECTORS.infoElement, HTMLElement), minimizeFullScreenButton: queryRequiredElement( - 'button.minimize-full-screen', + APP_SELECTORS.minimizeFullScreenButton, HTMLButtonElement ), maximizeFullScreenButton: queryRequiredElement( - 'button.maximize-full-screen', + APP_SELECTORS.maximizeFullScreenButton, HTMLButtonElement ), - settingsButton: queryRequiredElement('button.settings', HTMLButtonElement), - soundButton: queryRequiredElement('button.sound', HTMLButtonElement), - volumeControl: queryRequiredElement('.volume-control', HTMLLabelElement), - volumeSlider: queryRequiredElement('.volume-slider', HTMLInputElement), - restartButton: queryRequiredElement('button.restart', HTMLButtonElement), - canvas: queryRequiredElement('canvas', HTMLCanvasElement), - eraserPreview: queryRequiredElement('.eraser-preview', HTMLDivElement), - errorContainer: queryRequiredElement('.errors-container', HTMLElement), - previousVibe: queryRequiredElement('.previous-vibe', HTMLButtonElement), - nextVibe: queryRequiredElement('.next-vibe', HTMLButtonElement), - swatches: queryRequiredElements('.color-swatch', HTMLButtonElement), - eraserSizeControl: queryRequiredElement('.eraser-size-control', HTMLLabelElement), - eraserSizeSlider: queryRequiredElement('.eraser-size-slider', HTMLInputElement), - mirrorSegmentControl: queryRequiredElement('.mirror-segment-control', HTMLLabelElement), - mirrorSegmentSlider: queryRequiredElement('.mirror-segment-slider', HTMLInputElement), - export4k: queryRequiredElement('.export-4k', HTMLButtonElement), - exportStatus: queryRequiredElement('.export-status', HTMLSpanElement), - prompt: queryRequiredElement('.garden-prompt', HTMLDivElement), - loadingStatus: queryRequiredElement('.loading-status', HTMLDivElement), - loadingProgress: queryRequiredElement('.loading-progress', HTMLDivElement), + settingsButton: queryRequiredElement(APP_SELECTORS.settingsButton, HTMLButtonElement), + soundButton: queryRequiredElement(APP_SELECTORS.soundButton, HTMLButtonElement), + volumeControl: queryRequiredElement(APP_SELECTORS.volumeControl, HTMLLabelElement), + volumeSlider: queryRequiredElement(APP_SELECTORS.volumeSlider, HTMLInputElement), + restartButton: queryRequiredElement(APP_SELECTORS.restartButton, HTMLButtonElement), + canvas: queryRequiredElement(APP_SELECTORS.canvas, HTMLCanvasElement), + eraserPreview: queryRequiredElement(APP_SELECTORS.eraserPreview, HTMLDivElement), + errorContainer: queryRequiredElement(APP_SELECTORS.errorContainer, HTMLElement), + previousVibe: queryRequiredElement(APP_SELECTORS.previousVibe, HTMLButtonElement), + nextVibe: queryRequiredElement(APP_SELECTORS.nextVibe, HTMLButtonElement), + swatches: queryRequiredElements(APP_SELECTORS.swatches, HTMLButtonElement), + eraserSizeControl: queryRequiredElement( + APP_SELECTORS.eraserSizeControl, + HTMLLabelElement + ), + eraserSizeSlider: queryRequiredElement( + APP_SELECTORS.eraserSizeSlider, + HTMLInputElement + ), + mirrorSegmentControl: queryRequiredElement( + APP_SELECTORS.mirrorSegmentControl, + HTMLLabelElement + ), + mirrorSegmentSlider: queryRequiredElement( + APP_SELECTORS.mirrorSegmentSlider, + HTMLInputElement + ), + export4k: queryRequiredElement(APP_SELECTORS.export4k, HTMLButtonElement), + exportStatus: queryRequiredElement(APP_SELECTORS.exportStatus, HTMLSpanElement), + prompt: queryRequiredElement(APP_SELECTORS.prompt, HTMLDivElement), + loadingStatus: queryRequiredElement(APP_SELECTORS.loadingStatus, HTMLDivElement), + loadingProgress: queryRequiredElement(APP_SELECTORS.loadingProgress, HTMLDivElement), + splash: queryRequiredElement(APP_SELECTORS.splash, HTMLDivElement), + loadingBar: queryRequiredElement(APP_SELECTORS.loadingBar, HTMLDivElement), + startButton: queryRequiredElement(APP_SELECTORS.startButton, HTMLButtonElement), }); type AppElements = ReturnType; @@ -160,52 +295,62 @@ type AppElements = ReturnType; let elements: AppElements; const setLoadingStage = (label: string, ratio: number) => { - const percent = Math.round(Math.max(0, Math.min(1, ratio)) * 100); + const percent = Math.round(clamp01(ratio) * 100); elements.loadingStatus.textContent = label; - elements.loadingProgress.style.setProperty('--loading-progress', `${percent}%`); - elements.loadingProgress.setAttribute('aria-valuenow', String(percent)); + elements.loadingProgress.style.setProperty( + CSS_VARIABLES.loadingProgress, + `${percent}%` + ); + elements.loadingProgress.setAttribute(ARIA_ATTRIBUTES.valueNow, String(percent)); }; let audioVolume = readInitialAudioVolume(); let isAudioMuted = - readBrowserStorage(appConfig.storage.audioMutedKey) === '1' || - audioVolume <= appConfig.toolbar.volume.min; + readBrowserStorage(APP_STORAGE_KEYS.audioMuted) === ENABLED_FLAG_VALUE || + audioVolume <= 0; let isEraserActive = false; const persistAudioUiState = () => { - writeBrowserStorage(appConfig.storage.audioMutedKey, isAudioMuted ? '1' : '0'); writeBrowserStorage( - appConfig.storage.audioVolumeKey, - formatStoredAudioVolume(audioVolume) + APP_STORAGE_KEYS.audioMuted, + isAudioMuted ? ENABLED_FLAG_VALUE : DISABLED_FLAG_VALUE ); + writeBrowserStorage(APP_STORAGE_KEYS.audioVolume, formatStoredAudioVolume(audioVolume)); }; const renderAudioUi = (game: GameLoop | null) => { audioVolume = clampAudioVolume(audioVolume); - const isEffectivelyMuted = isAudioMuted || audioVolume <= appConfig.toolbar.volume.min; + const isEffectivelyMuted = isAudioMuted || audioVolume <= 0; const volumePercent = getAudioVolumePercent(audioVolume); - elements.soundButton.classList.toggle('muted', isEffectivelyMuted); - elements.soundButton.setAttribute('aria-pressed', String(isEffectivelyMuted)); + elements.soundButton.classList.toggle(CSS_CLASSES.muted, isEffectivelyMuted); + elements.soundButton.setAttribute(ARIA_ATTRIBUTES.pressed, String(isEffectivelyMuted)); elements.soundButton.setAttribute( - 'aria-label', - isEffectivelyMuted ? 'Unmute audio' : 'Mute audio' + ARIA_ATTRIBUTES.label, + isEffectivelyMuted ? AUDIO_LABELS.unmute : AUDIO_LABELS.mute ); - elements.soundButton.title = isEffectivelyMuted ? 'Unmute audio' : 'Mute audio'; + elements.soundButton.title = isEffectivelyMuted + ? AUDIO_LABELS.unmute + : AUDIO_LABELS.mute; - elements.volumeSlider.min = appConfig.toolbar.volume.min.toString(); - elements.volumeSlider.max = appConfig.toolbar.volume.max.toString(); - elements.volumeSlider.step = appConfig.toolbar.volume.step.toString(); + elements.volumeSlider.min = UNIT_INTERVAL_INPUT_MIN; + elements.volumeSlider.max = UNIT_INTERVAL_INPUT_MAX; + elements.volumeSlider.step = AUDIO_VOLUME_STEP.toString(); elements.volumeSlider.value = formatStoredAudioVolume(audioVolume); elements.volumeSlider.setAttribute( - 'aria-valuetext', - isEffectivelyMuted ? `Muted, ${volumePercent}%` : `${volumePercent}%` + ARIA_ATTRIBUTES.valueText, + isEffectivelyMuted + ? `${AUDIO_LABELS.mutedPrefix}, ${volumePercent}%` + : `${volumePercent}%` ); - elements.volumeControl.classList.toggle('muted', isEffectivelyMuted); + elements.volumeControl.classList.toggle(CSS_CLASSES.muted, isEffectivelyMuted); elements.volumeControl.title = isEffectivelyMuted - ? `Muted, ${volumePercent}% volume` - : `${volumePercent}% volume`; - elements.volumeControl.style.setProperty('--volume-progress', `${volumePercent}%`); + ? `${AUDIO_LABELS.mutedPrefix}, ${volumePercent}% ${AUDIO_LABELS.volumeSuffix}` + : `${volumePercent}% ${AUDIO_LABELS.volumeSuffix}`; + elements.volumeControl.style.setProperty( + CSS_VARIABLES.volumeProgress, + `${volumePercent}%` + ); game?.setAudioVolume(audioVolume); game?.setAudioMuted(isEffectivelyMuted); @@ -215,14 +360,14 @@ const renderPaletteUi = (game: GameLoop | null) => { elements.swatches.forEach((swatch, index) => { swatch.style.backgroundColor = activeVibe.colors[index]; swatch.classList.toggle( - 'active', + CSS_CLASSES.active, settings.selectedColorIndex === index && !isEraserActive ); }); - elements.eraserSizeControl.classList.toggle('active', isEraserActive); + elements.eraserSizeControl.classList.toggle(CSS_CLASSES.active, isEraserActive); game?.setEraseMode(isEraserActive); document.documentElement.style.setProperty( - '--garden-background', + CSS_VARIABLES.gardenBackground, activeVibe.backgroundColor ); }; @@ -233,21 +378,22 @@ const renderEraserSizeUi = (game: GameLoop | null) => { settings.eraserSize = size; } - elements.eraserSizeSlider.min = appConfig.toolbar.eraser.min.toString(); - elements.eraserSizeSlider.max = appConfig.toolbar.eraser.max.toString(); - elements.eraserSizeSlider.step = appConfig.toolbar.eraser.step.toString(); + elements.eraserSizeSlider.min = ERASER_SIZE_MIN.toString(); + elements.eraserSizeSlider.max = ERASER_SIZE_MAX.toString(); + elements.eraserSizeSlider.step = ERASER_SIZE_STEP.toString(); elements.eraserSizeSlider.value = size.toString(); - elements.eraserSizeSlider.setAttribute('aria-valuetext', `${size}px`); + elements.eraserSizeSlider.setAttribute(ARIA_ATTRIBUTES.valueText, `${size}px`); const ratio = getEraserSizeRatio(size); const scale = - appConfig.toolbar.eraser.controlScaleMin + - (appConfig.toolbar.eraser.controlScaleMax - - appConfig.toolbar.eraser.controlScaleMin) * - ratio; - elements.eraserSizeControl.style.setProperty('--eraser-progress', `${ratio * 100}%`); + ERASER_CONTROL_SCALE_MIN + + (ERASER_CONTROL_SCALE_MAX - ERASER_CONTROL_SCALE_MIN) * ratio; elements.eraserSizeControl.style.setProperty( - '--eraser-control-scale', + CSS_VARIABLES.eraserProgress, + `${ratio * 100}%` + ); + elements.eraserSizeControl.style.setProperty( + CSS_VARIABLES.eraserControlScale, scale.toFixed(3) ); game?.updateEraserPreview(); @@ -259,19 +405,22 @@ const renderMirrorSegmentUi = () => { settings.mirrorSegmentCount = count; } - elements.mirrorSegmentSlider.min = appConfig.toolbar.mirror.min.toString(); - elements.mirrorSegmentSlider.max = appConfig.toolbar.mirror.max.toString(); - elements.mirrorSegmentSlider.step = appConfig.toolbar.mirror.step.toString(); + elements.mirrorSegmentSlider.min = MIRROR_SEGMENT_MIN.toString(); + elements.mirrorSegmentSlider.max = MIRROR_SEGMENT_MAX.toString(); + elements.mirrorSegmentSlider.step = MIRROR_SEGMENT_STEP.toString(); elements.mirrorSegmentSlider.value = count.toString(); const label = formatMirrorSegmentCount(count); const ratio = getMirrorSegmentRatio(count); - elements.mirrorSegmentSlider.setAttribute('aria-valuetext', label); + elements.mirrorSegmentSlider.setAttribute(ARIA_ATTRIBUTES.valueText, label); elements.mirrorSegmentControl.title = label; - elements.mirrorSegmentControl.classList.toggle('active', count > 1); - elements.mirrorSegmentControl.style.setProperty('--mirror-progress', `${ratio * 100}%`); + elements.mirrorSegmentControl.classList.toggle(CSS_CLASSES.active, count > 1); elements.mirrorSegmentControl.style.setProperty( - '--mirror-angle', + CSS_VARIABLES.mirrorProgress, + `${ratio * 100}%` + ); + elements.mirrorSegmentControl.style.setProperty( + CSS_VARIABLES.mirrorAngle, `${(360 / count).toFixed(3)}deg` ); }; @@ -286,11 +435,14 @@ const main = async () => { let configPane: ConfigPane | null = null; elements = queryAppElements(); - elements.errorContainer.setAttribute('aria-live', 'assertive'); + elements.errorContainer.setAttribute( + ARIA_ATTRIBUTES.live, + ARIA_LIVE_VALUES.assertive + ); ErrorHandler.addOnErrorListener((error) => { renderRuntimeMessage(elements.errorContainer, error); if (error.severity === Severity.ERROR) { - document.body.classList.remove('is-loading'); + document.body.classList.remove(CSS_CLASSES.isLoading); game?.destroy(); shouldStop = true; } @@ -325,6 +477,7 @@ const main = async () => { const startAudioFromUserGesture = (event: Event) => { if ( isAudioMuted || + (event.target instanceof Node && elements.startButton.contains(event.target)) || (event.target instanceof Node && elements.soundButton.contains(event.target)) ) { return; @@ -333,22 +486,34 @@ const main = async () => { game?.startAudio(true); }; - window.addEventListener('touchend', startAudioFromUserGesture, { + window.addEventListener(DOM_EVENTS.touchStart, startAudioFromUserGesture, { capture: true, passive: true, }); - window.addEventListener('pointerup', startAudioFromUserGesture, { + window.addEventListener(DOM_EVENTS.pointerDown, startAudioFromUserGesture, { capture: true, passive: true, }); - window.addEventListener('click', startAudioFromUserGesture, { capture: true }); - window.addEventListener('keydown', startAudioFromUserGesture, { capture: true }); + window.addEventListener(DOM_EVENTS.touchEnd, startAudioFromUserGesture, { + capture: true, + passive: true, + }); + window.addEventListener(DOM_EVENTS.pointerUp, startAudioFromUserGesture, { + capture: true, + passive: true, + }); + window.addEventListener(DOM_EVENTS.click, startAudioFromUserGesture, { + capture: true, + }); + window.addEventListener(DOM_EVENTS.keydown, startAudioFromUserGesture, { + capture: true, + }); - elements.restartButton.addEventListener('click', () => game?.destroy()); - elements.soundButton.addEventListener('click', () => { - const shouldUnmute = isAudioMuted || audioVolume <= appConfig.toolbar.volume.min; - if (shouldUnmute && audioVolume <= appConfig.toolbar.volume.min) { - audioVolume = appConfig.toolbar.volume.default; + elements.restartButton.addEventListener(DOM_EVENTS.click, () => game?.destroy()); + elements.soundButton.addEventListener(DOM_EVENTS.click, () => { + const shouldUnmute = isAudioMuted || audioVolume <= 0; + if (shouldUnmute && audioVolume <= 0) { + audioVolume = DEFAULT_AUDIO_VOLUME; } isAudioMuted = !shouldUnmute; persistAudioUiState(); @@ -357,9 +522,9 @@ const main = async () => { game?.startAudio(true); } }); - elements.volumeSlider.addEventListener('input', () => { + elements.volumeSlider.addEventListener(DOM_EVENTS.input, () => { audioVolume = clampAudioVolume(Number(elements.volumeSlider.value)); - isAudioMuted = audioVolume <= appConfig.toolbar.volume.min; + isAudioMuted = audioVolume <= 0; persistAudioUiState(); renderAudioUi(game); if (!isAudioMuted) { @@ -383,16 +548,16 @@ const main = async () => { game?.playVibeChangeAudio(true); }; - elements.previousVibe.addEventListener('click', () => - selectRelativeVibe(-1, 'previous-button') + elements.previousVibe.addEventListener(DOM_EVENTS.click, () => + selectRelativeVibe(-1, VIBE_CHANGE_SOURCES.previousButton) ); - elements.nextVibe.addEventListener('click', () => - selectRelativeVibe(1, 'next-button') + elements.nextVibe.addEventListener(DOM_EVENTS.click, () => + selectRelativeVibe(1, VIBE_CHANGE_SOURCES.nextButton) ); elements.swatches.forEach((swatch, index) => { - swatch.addEventListener('click', () => { + swatch.addEventListener(DOM_EVENTS.click, () => { settings.selectedColorIndex = index; isEraserActive = false; renderPaletteUi(game); @@ -405,11 +570,11 @@ const main = async () => { renderPaletteUi(game); }; - elements.eraserSizeControl.addEventListener('pointerdown', activateEraser); - elements.eraserSizeControl.addEventListener('click', activateEraser); - elements.eraserSizeSlider.addEventListener('focus', activateEraser); + elements.eraserSizeControl.addEventListener(DOM_EVENTS.pointerDown, activateEraser); + elements.eraserSizeControl.addEventListener(DOM_EVENTS.click, activateEraser); + elements.eraserSizeSlider.addEventListener(DOM_EVENTS.focus, activateEraser); - elements.eraserSizeSlider.addEventListener('input', () => { + elements.eraserSizeSlider.addEventListener(DOM_EVENTS.input, () => { settings.eraserSize = clampEraserSize(Number(elements.eraserSizeSlider.value)); isEraserActive = true; renderEraserSizeUi(game); @@ -417,7 +582,7 @@ const main = async () => { configPane?.refresh(); }); - elements.mirrorSegmentSlider.addEventListener('input', () => { + elements.mirrorSegmentSlider.addEventListener(DOM_EVENTS.input, () => { settings.mirrorSegmentCount = clampMirrorSegmentCount( Number(elements.mirrorSegmentSlider.value) ); @@ -427,7 +592,7 @@ const main = async () => { configPane?.refresh(); }); - elements.export4k.addEventListener('click', async () => { + elements.export4k.addEventListener(DOM_EVENTS.click, async () => { if (!game || elements.export4k.disabled) { return; } @@ -448,14 +613,36 @@ const main = async () => { renderMirrorSegmentUi(); renderAudioUi(game); + // Loading runs in the background while the splash (title + description + + // Start button) is shown. The Start tap is the user gesture that unlocks + // the AudioContext on iOS, and gates the intro. const fontsReady = document.fonts.ready.catch((error) => { ErrorHandler.addException(error, { - fallbackMessage: 'Could not load fonts.', + fallbackMessage: LOADING_MESSAGES.fontsError, severity: Severity.WARNING, }); }); - setLoadingStage('Connecting to GPU…', 0.1); - const gpu = await initializeGpu(); + const gpuPromise = initializeGpu(); + + let isPreloadComplete = false; + const preloadPromise = preloadPianoSamples(({ loadedCount, totalCount }) => { + const ratio = totalCount > 0 ? loadedCount / totalCount : 0; + setLoadingStage(`Loading piano samples ${loadedCount}/${totalCount}…`, ratio); + }).then( + () => { + isPreloadComplete = true; + setLoadingStage(LOADING_MESSAGES.ready, 1); + }, + (error: unknown) => { + isPreloadComplete = true; + ErrorHandler.addException(error, { + fallbackMessage: LOADING_MESSAGES.pianoSamplesError, + severity: Severity.WARNING, + }); + } + ); + + const gpu = await gpuPromise; configPane = new ConfigPane({ settingsButton: elements.settingsButton, onConfigChange: () => { @@ -480,7 +667,7 @@ const main = async () => { trackVibeChange({ vibeId: activePreset.id, vibeName: activePreset.name, - source: 'settings', + source: VIBE_CHANGE_SOURCES.settings, }); game?.onVibeChanged(); syncRuntimeUi(); @@ -488,17 +675,7 @@ const main = async () => { }, }); infoPageHandler.onOpen = configPane.close.bind(configPane); - setLoadingStage('Loading fonts…', 0.3); await fontsReady; - setLoadingStage('Loading piano samples…', 0.45); - await preloadPianoSamples(({ loadedCount, totalCount }) => { - const sampleRatio = totalCount > 0 ? loadedCount / totalCount : 1; - setLoadingStage( - `Loading piano samples ${loadedCount}/${totalCount}…`, - 0.45 + sampleRatio * 0.3 - ); - }); - setLoadingStage('Compiling shaders…', 0.8); const deltaTimeCalculator = new DeltaTimeCalculator(); @@ -515,18 +692,48 @@ const main = async () => { renderMirrorSegmentUi(); renderAudioUi(game); - const startPromise = game.start(); if (isFirstStart) { isFirstStart = false; - setLoadingStage('Ready', 1); + + // Splash is in the DOM by default; enable the button now that the + // audio system (GameLoop) is constructed and ready to be unlocked. + elements.startButton.disabled = false; + await new Promise((resolve) => { + const onClick = () => { + elements.startButton.removeEventListener(DOM_EVENTS.click, onClick); + game?.startAudio(true); + trackStart(); + elements.splash.hidden = true; + resolve(); + }; + elements.startButton.addEventListener(DOM_EVENTS.click, onClick); + }); + + if (!isPreloadComplete) { + elements.loadingBar.hidden = false; + void preloadPromise.finally(() => { + elements.loadingBar.hidden = true; + }); + } + + // Keep the toolbar/dock hidden until the user actually starts drawing. + document.body.classList.add(CSS_CLASSES.preDrawing); + elements.canvas.addEventListener( + DOM_EVENTS.pointerDown, + () => document.body.classList.remove(CSS_CLASSES.preDrawing), + { once: true } + ); + requestAnimationFrame(() => - requestAnimationFrame(() => document.body.classList.remove('is-loading')) + requestAnimationFrame(() => + document.body.classList.remove(CSS_CLASSES.isLoading) + ) ); } - await startPromise; + await game.start(); } } catch (e) { - document.body.classList.remove('is-loading'); + document.body.classList.remove(CSS_CLASSES.isLoading); if (hasRuntimeErrorListener) { ErrorHandler.addException(e); } else { diff --git a/src/page/collapsible-panel-animator.test.ts b/src/page/collapsible-panel-animator.test.ts deleted file mode 100644 index 6d32a4e..0000000 --- a/src/page/collapsible-panel-animator.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { CollapsiblePanelAnimator } from './collapsible-panel-animator'; - -type Listener = (event: Record) => void; - -class FakeClassList { - private readonly classes = new Set(); - - public add(className: string): void { - this.classes.add(className); - } - - public contains(className: string): boolean { - return this.classes.has(className); - } - - public remove(className: string): void { - this.classes.delete(className); - } - - public toggle(className: string, force?: boolean): boolean { - const shouldAdd = force ?? !this.classes.has(className); - if (shouldAdd) { - this.add(className); - } else { - this.remove(className); - } - return shouldAdd; - } -} - -class FakeElement { - public readonly classList = new FakeClassList(); - public inert = false; - - private readonly attributes = new Map(); - private readonly children = new Set(); - private readonly listeners = new Map>(); - - public addChild(child: FakeElement): void { - this.children.add(child); - } - - public addEventListener(type: string, listener: Listener): void { - this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); - } - - public contains(target: unknown): boolean { - return target === this || this.children.has(target as FakeElement); - } - - public dispatch(type: string, event: Record = {}): void { - this.listeners.get(type)?.forEach((listener) => listener({ target: this, ...event })); - } - - public focus(): void { - fakeDocument.activeElement = this; - } - - public getAttribute(name: string): string | null { - return this.attributes.get(name) ?? null; - } - - public setAttribute(name: string, value: string): void { - this.attributes.set(name, value); - } -} - -const windowListeners = new Map>(); -const fakeDocument: { activeElement: FakeElement | null } = { - activeElement: null, -}; - -const dispatchWindowEvent = (type: string, event: Record = {}) => { - windowListeners.get(type)?.forEach((listener) => listener(event)); -}; - -describe('CollapsiblePanelAnimator', () => { - beforeEach(() => { - windowListeners.clear(); - fakeDocument.activeElement = null; - vi.stubGlobal('HTMLElement', FakeElement); - vi.stubGlobal('document', fakeDocument); - vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { - callback(0); - return 1; - }); - vi.stubGlobal('window', { - addEventListener: (type: string, listener: Listener) => { - windowListeners.set(type, [...(windowListeners.get(type) ?? []), listener]); - }, - }); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it('syncs About panel accessibility when toggled and closed with Escape', () => { - const button = new FakeElement(); - const panel = new FakeElement(); - const dock = new FakeElement(); - dock.addChild(button); - dock.addChild(panel); - - new CollapsiblePanelAnimator( - button as unknown as HTMLButtonElement, - panel as unknown as HTMLElement, - dock as unknown as HTMLElement - ); - - expect(button.getAttribute('aria-expanded')).toBe('false'); - expect(panel.getAttribute('aria-hidden')).toBe('true'); - expect(panel.inert).toBe(true); - expect(panel.classList.contains('hidden')).toBe(true); - - fakeDocument.activeElement = button; - button.dispatch('click'); - - expect(button.getAttribute('aria-expanded')).toBe('true'); - expect(button.classList.contains('active')).toBe(true); - expect(panel.getAttribute('aria-hidden')).toBe('false'); - expect(panel.inert).toBe(false); - expect(panel.classList.contains('hidden')).toBe(false); - expect(fakeDocument.activeElement).toBe(panel); - - const preventDefault = vi.fn(); - dispatchWindowEvent('keydown', { key: 'Escape', preventDefault }); - - expect(preventDefault).toHaveBeenCalledOnce(); - expect(button.getAttribute('aria-expanded')).toBe('false'); - expect(panel.getAttribute('aria-hidden')).toBe('true'); - expect(panel.inert).toBe(true); - expect(fakeDocument.activeElement).toBe(button); - }); -}); diff --git a/src/page/config-pane.ts b/src/page/config-pane.ts index 84831c0..defadee 100644 --- a/src/page/config-pane.ts +++ b/src/page/config-pane.ts @@ -12,20 +12,22 @@ import { isVibeId, VIBE_PRESETS, type VibeId } from '../vibes'; type PaneContainer = Pick; type ColorReactionKey = (typeof colorReactionRows)[number]['keys'][number]; +const COLOR_REACTION_LABELS = ['1', '2', '3'] as const; + const colorReactionRows = [ { colorIndex: 0, - label: '1', + label: COLOR_REACTION_LABELS[0], keys: ['color1ToColor1', 'color1ToColor2', 'color1ToColor3'], }, { colorIndex: 1, - label: '2', + label: COLOR_REACTION_LABELS[1], keys: ['color2ToColor1', 'color2ToColor2', 'color2ToColor3'], }, { colorIndex: 2, - label: '3', + label: COLOR_REACTION_LABELS[2], keys: ['color3ToColor1', 'color3ToColor2', 'color3ToColor3'], }, ] as const; @@ -70,24 +72,34 @@ const normalizeNumber = (value: number, config: NumberControlConfig): number => if (optionValues.includes(value)) { return value; } - return optionValues.includes(0) ? 0 : (optionValues[0] ?? config.min); + return optionValues.includes(0) ? 0 : (optionValues[0] ?? config.min ?? 0); } - const finiteValue = Number.isFinite(value) ? value : config.min; - const clampedValue = Math.min(config.max, Math.max(config.min, finiteValue)); + const min = config.min ?? Number.NEGATIVE_INFINITY; + const max = config.max ?? Number.POSITIVE_INFINITY; + const fallbackValue = config.min ?? 0; + const finiteValue = Number.isFinite(value) ? value : fallbackValue; + const clampedValue = Math.min(max, Math.max(min, finiteValue)); return config.integer ? Math.round(clampedValue) : clampedValue; }; const getNumberBindingParams = ( key: keyof GardenRuntimeSettings & string, config: NumberControlConfig -): BindingParams => ({ - label: config.label ?? toLabel(key), - min: config.min, - max: config.max, - options: config.options, - step: config.step, -}); +): BindingParams => { + const params: BindingParams = { + label: config.label ?? toLabel(key), + options: config.options, + step: config.step, + }; + if (config.min !== undefined) { + params.min = config.min; + } + if (config.max !== undefined) { + params.max = config.max; + } + return params; +}; export class ConfigPane { private readonly container: HTMLDivElement; diff --git a/src/page/menu-hider.test.ts b/src/page/menu-hider.test.ts deleted file mode 100644 index 4f5cffd..0000000 --- a/src/page/menu-hider.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { appConfig } from '../config'; -import { MenuHider } from './menu-hider'; - -type Listener> = (event: T) => void; - -class FakeClassList { - private readonly classes = new Set(); - - public add(className: string): void { - this.classes.add(className); - } - - public contains(className: string): boolean { - return this.classes.has(className); - } - - public remove(className: string): void { - this.classes.delete(className); - } -} - -class FakeDockElement { - public readonly classList = new FakeClassList(); - public inert = false; - - private readonly attributes = new Map(); - private readonly listeners = new Map>(); - - public addEventListener(type: string, listener: Listener): void { - this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); - } - - public contains(target: unknown): boolean { - return target === this; - } - - public dispatch(type: string, event: Record = {}): void { - this.listeners.get(type)?.forEach((listener) => listener(event)); - } - - public getAttribute(name: string): string | null { - return this.attributes.get(name) ?? null; - } - - public getBoundingClientRect(): DOMRect { - return { - bottom: 720, - height: 120, - left: 0, - right: 1280, - toJSON: () => ({}), - top: 600, - width: 1280, - x: 0, - y: 600, - } as DOMRect; - } - - public setAttribute(name: string, value: string): void { - this.attributes.set(name, value); - } -} - -const windowListeners = new Map>(); -let isDesktop = true; - -const dispatchWindowEvent = >( - type: string, - event: T -) => { - windowListeners.get(type)?.forEach((listener) => listener(event)); -}; - -describe('MenuHider', () => { - beforeEach(() => { - vi.useFakeTimers(); - windowListeners.clear(); - isDesktop = true; - - vi.stubGlobal('document', { - activeElement: null, - addEventListener: vi.fn(), - documentElement: { - clientHeight: 720, - }, - }); - vi.stubGlobal('window', { - addEventListener: (type: string, listener: Listener) => { - windowListeners.set(type, [...(windowListeners.get(type) ?? []), listener]); - }, - clearTimeout, - innerHeight: 720, - matchMedia: () => ({ - addEventListener: vi.fn(), - matches: isDesktop, - }), - setTimeout, - }); - }); - - afterEach(() => { - vi.useRealTimers(); - vi.unstubAllGlobals(); - }); - - it('hides the dock after the desktop fullscreen pointer leaves it', () => { - const dock = new FakeDockElement(); - - new MenuHider(dock as unknown as HTMLElement, () => true); - dock.dispatch('pointerleave'); - vi.advanceTimersByTime(appConfig.menuHider.hideDelayMs); - - expect(dock.classList.contains('menu-hidden')).toBe(true); - expect(dock.getAttribute('aria-hidden')).toBe('true'); - expect(dock.inert).toBe(true); - - dispatchWindowEvent('pointermove', { clientX: 640, clientY: 710 }); - - expect(dock.classList.contains('menu-hidden')).toBe(false); - expect(dock.getAttribute('aria-hidden')).toBe('false'); - expect(dock.inert).toBe(false); - }); - - it('keeps the dock visible outside the desktop auto-hide breakpoint', () => { - isDesktop = false; - const dock = new FakeDockElement(); - - new MenuHider(dock as unknown as HTMLElement, () => true); - dock.dispatch('pointerleave'); - vi.advanceTimersByTime(appConfig.menuHider.hideDelayMs); - - expect(dock.classList.contains('menu-hidden')).toBe(false); - expect(dock.getAttribute('aria-hidden')).toBe('false'); - expect(dock.inert).toBe(false); - }); -}); diff --git a/src/pipelines/agents/agent-generation/agent-compaction.wgsl b/src/pipelines/agents/agent-generation/agent-compaction.wgsl index 7ae140a..d71ddb2 100644 --- a/src/pipelines/agents/agent-generation/agent-compaction.wgsl +++ b/src/pipelines/agents/agent-generation/agent-compaction.wgsl @@ -16,12 +16,17 @@ struct Counters { var workgroupAliveCount: atomic; var workgroupCompactedOffset: u32; +fn dead_agent() -> Agent { + return Agent(vec2(0.0, 0.0), 0.0, -1.0, vec2(-1.0, -1.0), 0.0, 0.0); +} + @compute @workgroup_size(64) fn main( @builtin(global_invocation_id) global_id: vec3, - @builtin(local_invocation_id) local_id: vec3 + @builtin(local_invocation_id) local_id: vec3, + @builtin(num_workgroups) num_workgroups: vec3 ) { - let id = get_id(global_id); + let id = get_id(global_id, num_workgroups); if local_id.x == 0u { atomicStore(&workgroupAliveCount, 0u); @@ -30,7 +35,7 @@ fn main( workgroupBarrier(); var localCompactedIndex = 0u; - var agent = Agent(vec2(0.0, 0.0), 0.0, -1.0, vec2(-1.0, -1.0), 0.0, 0.0); + var agent = dead_agent(); var isAlive = false; if id < settings.agentCount { agent = agents[id]; @@ -57,3 +62,20 @@ fn main( compactedAgents[workgroupCompactedOffset + localCompactedIndex] = agent; } } + +@compute @workgroup_size(64) +fn clearCompactedTail( + @builtin(global_invocation_id) global_id: vec3, + @builtin(num_workgroups) num_workgroups: vec3 +) { + let id = get_id(global_id, num_workgroups); + + if id >= settings.agentCount { + return; + } + + let aliveAgentCount = atomicLoad(&counters.aliveAgentCount); + if id >= aliveAgentCount { + compactedAgents[id] = dead_agent(); + } +} diff --git a/src/pipelines/agents/agent-generation/agent-generation-pipeline.test.ts b/src/pipelines/agents/agent-generation/agent-generation-pipeline.test.ts deleted file mode 100644 index 0758b4c..0000000 --- a/src/pipelines/agents/agent-generation/agent-generation-pipeline.test.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -import { AGENT_SIZE_IN_BYTES } from './agent'; -import { AgentGenerationPipeline } from './agent-generation-pipeline'; - -const installGpuConstants = () => { - Object.defineProperties(globalThis, { - GPUBufferUsage: { - configurable: true, - value: { - MAP_READ: 1, - COPY_DST: 2, - COPY_SRC: 4, - STORAGE: 8, - UNIFORM: 16, - }, - }, - GPUMapMode: { - configurable: true, - value: { - READ: 1, - }, - }, - GPUShaderStage: { - configurable: true, - value: { - COMPUTE: 1, - }, - }, - }); -}; - -type CopyCall = { - source: GPUBuffer; - sourceOffset: number; - destination: GPUBuffer; - destinationOffset: number; - size: number; -}; - -type DispatchCall = { - entryPoint: string; - workgroups: [number, number, number]; -}; - -type FakePipeline = { - entryPoint: string; -}; - -class FakeBuffer { - private readonly mappedRange: ArrayBuffer; - - public readonly destroy = vi.fn(); - public readonly mapAsync = vi.fn(async () => undefined); - public readonly getMappedRange = vi.fn(() => this.mappedRange); - public readonly unmap = vi.fn(); - - public constructor( - public readonly label: string, - size: number, - mappedValue = 0 - ) { - this.mappedRange = new ArrayBuffer(Math.max(size, Uint32Array.BYTES_PER_ELEMENT)); - new Uint32Array(this.mappedRange)[0] = mappedValue; - } -} - -class FakeComputePass { - private pipeline: FakePipeline | null = null; - - public readonly setPipeline = vi.fn((pipeline: GPUComputePipeline) => { - this.pipeline = pipeline as unknown as FakePipeline; - }); - public readonly setBindGroup = vi.fn(() => undefined); - public readonly dispatchWorkgroups = vi.fn((x: number, y = 1, z = 1) => { - this.device.dispatchCalls.push({ - entryPoint: this.pipeline?.entryPoint ?? 'unset', - workgroups: [x, y, z], - }); - }); - public readonly end = vi.fn(); - - public constructor(private readonly device: FakeDevice) {} -} - -class FakeCommandEncoder { - public readonly beginComputePass = vi.fn(() => new FakeComputePass(this.device)); - public readonly copyBufferToBuffer = vi.fn( - ( - source: GPUBuffer, - sourceOffset: number, - destination: GPUBuffer, - destinationOffset: number, - size: number - ) => { - this.device.copyCalls.push({ - source, - sourceOffset, - destination, - destinationOffset, - size, - }); - } - ); - public readonly finish = vi.fn(() => ({}) as GPUCommandBuffer); - - public constructor(private readonly device: FakeDevice) {} -} - -class FakeQueue { - public readonly writeBuffer = vi.fn(() => undefined); - public readonly submit = vi.fn(() => undefined); -} - -class FakeShaderModule { - public readonly getCompilationInfo = vi.fn(async () => ({ - messages: [], - })); -} - -class FakeDevice { - public readonly copyCalls: Array = []; - public readonly dispatchCalls: Array = []; - public readonly createdComputeEntryPoints: Array = []; - public readonly limits = { - maxBufferSize: 1024 * 1024 * 1024, - maxComputeWorkgroupsPerDimension: 65_535, - }; - public readonly queue = new FakeQueue(); - - private bufferIndex = 0; - - public readonly createBindGroupLayout = vi.fn(() => ({}) as GPUBindGroupLayout); - public readonly createBuffer = vi.fn((descriptor: GPUBufferDescriptor) => { - const label = - ['agents', 'compactedAgents', 'counters', 'countersStaging', 'uniforms'][ - this.bufferIndex - ] ?? `buffer${this.bufferIndex}`; - this.bufferIndex += 1; - - const isMappedReadBuffer = (Number(descriptor.usage) & GPUBufferUsage.MAP_READ) !== 0; - - return new FakeBuffer( - label, - Number(descriptor.size), - isMappedReadBuffer ? this.compactedCount : 0 - ) as unknown as GPUBuffer; - }); - public readonly createBindGroup = vi.fn(() => ({}) as GPUBindGroup); - public readonly createPipelineLayout = vi.fn(() => ({}) as GPUPipelineLayout); - public readonly createShaderModule = vi.fn( - () => new FakeShaderModule() as unknown as GPUShaderModule - ); - public readonly createComputePipeline = vi.fn( - (descriptor: GPUComputePipelineDescriptor) => { - const pipeline = { - entryPoint: descriptor.compute.entryPoint ?? 'main', - }; - this.createdComputeEntryPoints.push(pipeline.entryPoint); - return pipeline as unknown as GPUComputePipeline; - } - ); - public readonly createCommandEncoder = vi.fn(() => new FakeCommandEncoder(this)); - - public constructor(private readonly compactedCount: number) {} -} - -const createPipeline = (compactedCount: number) => { - installGpuConstants(); - - const device = new FakeDevice(compactedCount); - - return { - device, - pipeline: new AgentGenerationPipeline(device as unknown as GPUDevice, 1024), - }; -}; - -describe('AgentGenerationPipeline compaction', () => { - it('swaps compacted agents into the active buffer without a copy-back dispatch', async () => { - const agentCount = 10; - const { device, pipeline } = createPipeline(3); - - await expect(pipeline.compactAgents(agentCount)).resolves.toBe(3); - - expect(device.createdComputeEntryPoints).not.toContain('copyCompactedAgents'); - expect(device.dispatchCalls.map((call) => call.entryPoint)).toEqual(['main']); - expect(device.copyCalls.map((call) => call.size)).toEqual([ - Uint32Array.BYTES_PER_ELEMENT, - ]); - expect( - device.copyCalls.some((call) => call.size === agentCount * AGENT_SIZE_IN_BYTES) - ).toBe(false); - expect(device.queue.submit).toHaveBeenCalledTimes(1); - - pipeline.destroy(); - }); - - it('does not encode work for empty compaction requests', async () => { - const { device, pipeline } = createPipeline(0); - - await expect(pipeline.compactAgents(0)).resolves.toBe(0); - - expect(device.dispatchCalls).toEqual([]); - expect(device.copyCalls).toEqual([]); - expect(device.queue.submit).not.toHaveBeenCalled(); - - pipeline.destroy(); - }); -}); diff --git a/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts b/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts index 79abf51..f243441 100644 --- a/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts +++ b/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts @@ -1,14 +1,19 @@ import { vec2 } from 'gl-matrix'; -import { getWorkgroupCount } from '../../../utils/graphics/get-workgroup-count'; import { smartCompile } from '../../../utils/graphics/smart-compile'; -import { AGENT_SIZE_IN_BYTES } from './agent'; +import { + AGENT_MAX_DISPATCHABLE_COUNT, + dispatchAgentWorkgroups, +} from '../agent-dispatch'; import compactionShader from './agent-compaction.wgsl?raw'; import resizeShader from './agent-resize.wgsl?raw'; import agentSchema from './agent-schema.wgsl?raw'; +export const AGENT_FLOAT_COUNT = 8; +export const AGENT_SIZE_IN_BYTES = + AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT; + export class AgentGenerationPipeline { - private static readonly WORKGROUP_SIZE = 64; private static readonly UNIFORM_COUNT = 4; private static readonly COUNTER_COUNT = 1; @@ -21,9 +26,11 @@ export class AgentGenerationPipeline { private readonly resizePipeline: GPUComputePipeline; private readonly compactionPipeline: GPUComputePipeline; + private readonly clearCompactedTailPipeline: GPUComputePipeline; private activeAgentsBuffer: GPUBuffer; private inactiveAgentsBuffer: GPUBuffer; + private allocatedMaxAgentCount: number; private readonly countersBuffer: GPUBuffer; private readonly countersStagingBuffer: GPUBuffer; private readonly counterClearValues = new Uint32Array( @@ -35,8 +42,10 @@ export class AgentGenerationPipeline { public constructor( private readonly device: GPUDevice, - private readonly maxAgentCountUpperLimit: number + initialMaxAgentCount: number, + private readonly maxAgentCountUpperLimit = Number.POSITIVE_INFINITY ) { + this.allocatedMaxAgentCount = this.clampMaxAgentCount(initialMaxAgentCount); const emptyBindGroupLayout = device.createBindGroupLayout({ entries: [] }); this.bindGroupLayout = device.createBindGroupLayout({ entries: [ @@ -72,7 +81,7 @@ export class AgentGenerationPipeline { }); this.activeAgentsBuffer = this.createAgentsBuffer(); - this.inactiveAgentsBuffer = this.createAgentsBuffer(); + this.inactiveAgentsBuffer = this.createInactivePlaceholderBuffer(); this.countersBuffer = this.device.createBuffer({ size: AgentGenerationPipeline.COUNTER_COUNT * Uint32Array.BYTES_PER_ELEMENT, @@ -110,6 +119,16 @@ export class AgentGenerationPipeline { entryPoint: 'main', }, }); + + this.clearCompactedTailPipeline = device.createComputePipeline({ + layout: device.createPipelineLayout({ + bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout], + }), + compute: { + module: compactionModule, + entryPoint: 'clearCompactedTail', + }, + }); } public get agentsBuffer(): GPUBuffer { @@ -118,23 +137,86 @@ export class AgentGenerationPipeline { private createAgentsBuffer(): GPUBuffer { return this.device.createBuffer({ - size: this.maxAgentCount * AGENT_SIZE_IN_BYTES, - usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, + size: this.allocatedMaxAgentCount * AGENT_SIZE_IN_BYTES, + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC, + }); + } + + // The inactive slot only needs a real allocation during compaction. The rest of + // the time we keep a one-agent placeholder so the bind group at binding 3 stays + // valid for resize without holding a second N-agent buffer in GPU memory. + private createInactivePlaceholderBuffer(): GPUBuffer { + return this.device.createBuffer({ + size: AGENT_SIZE_IN_BYTES, + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC, }); } public get maxAgentCount(): number { + return this.allocatedMaxAgentCount; + } + + public get maxSupportedAgentCount(): number { + return this.clampMaxAgentCount(Number.POSITIVE_INFINITY); + } + + public ensureMaxAgentCount( + requestedMaxAgentCount: number, + activeAgentCount: number + ): number { + const nextMaxAgentCount = this.clampMaxAgentCount(requestedMaxAgentCount); + if (nextMaxAgentCount <= this.allocatedMaxAgentCount) { + return this.allocatedMaxAgentCount; + } + + const previousActiveAgentsBuffer = this.activeAgentsBuffer; + const previousMaxAgentCount = this.allocatedMaxAgentCount; + this.allocatedMaxAgentCount = nextMaxAgentCount; + this.activeAgentsBuffer = this.createAgentsBuffer(); + + const copyAgentCount = Math.min( + Math.max(0, Math.floor(activeAgentCount)), + previousMaxAgentCount, + nextMaxAgentCount + ); + if (copyAgentCount > 0) { + const commandEncoder = this.device.createCommandEncoder(); + commandEncoder.copyBufferToBuffer( + previousActiveAgentsBuffer, + 0, + this.activeAgentsBuffer, + 0, + copyAgentCount * AGENT_SIZE_IN_BYTES + ); + this.device.queue.submit([commandEncoder.finish()]); + } + + // GPUBuffer.destroy() defers actual freeing until pending submissions + // finish, so calling it synchronously after submit is safe and avoids the + // transient 4-buffers-live spike that pushes iOS Safari past its per-tab + // memory ceiling. + previousActiveAgentsBuffer.destroy(); + return this.allocatedMaxAgentCount; + } + + private clampMaxAgentCount(value: number): number { + const requestedMaxAgentCount = + value === Number.POSITIVE_INFINITY + ? Number.POSITIVE_INFINITY + : Number.isFinite(value) + ? Math.floor(value) + : 0; return Math.min( Number.isFinite(this.maxAgentCountUpperLimit) ? this.maxAgentCountUpperLimit : Number.POSITIVE_INFINITY, - Math.floor(this.device.limits.maxBufferSize / AGENT_SIZE_IN_BYTES) - 1, + Math.floor(this.device.limits.maxBufferSize / AGENT_SIZE_IN_BYTES), Math.floor( ((this.device.limits as GPUSupportedLimits).maxStorageBufferBindingSize ?? this.device.limits.maxBufferSize) / AGENT_SIZE_IN_BYTES - ) - 1, - this.device.limits.maxComputeWorkgroupsPerDimension * - AgentGenerationPipeline.WORKGROUP_SIZE + ), + AGENT_MAX_DISPATCHABLE_COUNT, + Math.max(0, requestedMaxAgentCount) ); } @@ -161,9 +243,7 @@ export class AgentGenerationPipeline { const passEncoder = commandEncoder.beginComputePass(); passEncoder.setPipeline(this.resizePipeline); passEncoder.setBindGroup(1, this.getBindGroup()); - passEncoder.dispatchWorkgroups( - getWorkgroupCount(agentCount, AgentGenerationPipeline.WORKGROUP_SIZE) - ); + dispatchAgentWorkgroups(passEncoder, agentCount); passEncoder.end(); this.device.queue.submit([commandEncoder.finish()]); @@ -174,6 +254,12 @@ export class AgentGenerationPipeline { return 0; } + // Stash the placeholder, swap in a real N-agent destination buffer just + // for this compaction so the rest of the time we only carry one full + // agent buffer in memory. + const placeholder = this.inactiveAgentsBuffer; + this.inactiveAgentsBuffer = this.createAgentsBuffer(); + this.agentCountUniformValues[0] = agentCount; this.device.queue.writeBuffer(this.countersBuffer, 0, this.counterClearValues); this.device.queue.writeBuffer(this.uniforms, 0, this.agentCountUniformValues); @@ -182,9 +268,9 @@ export class AgentGenerationPipeline { const passEncoder = commandEncoder.beginComputePass(); passEncoder.setPipeline(this.compactionPipeline); passEncoder.setBindGroup(1, this.getBindGroup()); - passEncoder.dispatchWorkgroups( - getWorkgroupCount(agentCount, AgentGenerationPipeline.WORKGROUP_SIZE) - ); + dispatchAgentWorkgroups(passEncoder, agentCount); + passEncoder.setPipeline(this.clearCompactedTailPipeline); + dispatchAgentWorkgroups(passEncoder, agentCount); passEncoder.end(); commandEncoder.copyBufferToBuffer( @@ -196,6 +282,14 @@ export class AgentGenerationPipeline { ); this.device.queue.submit([commandEncoder.finish()]); + this.swapAgentBuffers(); + + // After swap, inactive is the previous active (full size). Destroy it and + // restore the placeholder; the destroy is deferred by WebGPU until the + // submitted compaction work has finished. + const previousActiveAgentsBuffer = this.inactiveAgentsBuffer; + this.inactiveAgentsBuffer = placeholder; + previousActiveAgentsBuffer.destroy(); await this.countersStagingBuffer.mapAsync(GPUMapMode.READ); const compactedCount = new Uint32Array( @@ -204,7 +298,6 @@ export class AgentGenerationPipeline { 1 )[0]; this.countersStagingBuffer.unmap(); - this.swapAgentBuffers(); return compactedCount; } diff --git a/src/pipelines/agents/agent-generation/agent-resize.wgsl b/src/pipelines/agents/agent-generation/agent-resize.wgsl index 601a260..3e160de 100644 --- a/src/pipelines/agents/agent-generation/agent-resize.wgsl +++ b/src/pipelines/agents/agent-generation/agent-resize.wgsl @@ -8,9 +8,10 @@ struct ResizeSettings { @compute @workgroup_size(64) fn main( - @builtin(global_invocation_id) global_id: vec3 + @builtin(global_invocation_id) global_id: vec3, + @builtin(num_workgroups) num_workgroups: vec3 ) { - let id = get_id(global_id); + let id = get_id(global_id, num_workgroups); if id >= u32(resizeSettings.agentCount) { return; diff --git a/src/pipelines/agents/agent-generation/agent-schema.test.ts b/src/pipelines/agents/agent-generation/agent-schema.test.ts deleted file mode 100644 index 13aee8d..0000000 --- a/src/pipelines/agents/agent-generation/agent-schema.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { AGENT_FLOAT_COUNT, AGENT_SIZE_IN_BYTES } from './agent'; -import compactionShader from './agent-compaction.wgsl?raw'; -import resizeShader from './agent-resize.wgsl?raw'; -import agentSchema from './agent-schema.wgsl?raw'; - -const wgslFloatCountByType: Record = { - f32: 1, - 'vec2': 2, -}; - -const getAgentStructFields = () => { - const match = /struct Agent\s*\{(?[\s\S]*?)\n\}/.exec(agentSchema); - if (!match?.groups?.body) { - throw new Error('Agent struct was not found in agent-schema.wgsl'); - } - - return match.groups.body - .split('\n') - .map((line) => line.trim().replace(/,$/, '')) - .filter(Boolean) - .map((line) => { - const fieldMatch = /^(?\w+):\s*(?[^,]+)$/.exec(line); - if (!fieldMatch?.groups) { - throw new Error(`Unsupported Agent field syntax: ${line}`); - } - - return { - name: fieldMatch.groups.name, - type: fieldMatch.groups.type, - }; - }); -}; - -describe('Agent TS/WGSL contract', () => { - it('keeps the TypeScript float count aligned with the WGSL Agent struct', () => { - const fields = getAgentStructFields(); - const wgslFloatCount = fields.reduce((sum, field) => { - const count = wgslFloatCountByType[field.type]; - if (!count) { - throw new Error(`Unsupported WGSL Agent field type: ${field.type}`); - } - - return sum + count; - }, 0); - - expect(fields.map((field) => field.name)).toEqual([ - 'position', - 'angle', - 'colorIndex', - 'targetPosition', - 'targetAngle', - 'introDelay', - ]); - expect(wgslFloatCount).toBe(AGENT_FLOAT_COUNT); - expect(AGENT_SIZE_IN_BYTES).toBe(AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT); - }); - - it('keeps generation shader workgroup sizes aligned with agent indexing', () => { - [resizeShader, compactionShader].forEach((shader) => { - expect(shader).toMatch(/@workgroup_size\(64\)/); - }); - - expect(agentSchema).toContain('return global_id.x;'); - expect(compactionShader).toContain('let id = get_id(global_id);'); - expect(compactionShader).toContain('if id < settings.agentCount'); - }); - - it('keeps compaction as a ping-pong write without copy-back shader work', () => { - expect(compactionShader).not.toContain('fn copyCompactedAgents'); - expect(compactionShader).not.toContain('agents[id] = compactedAgents[id];'); - expect(compactionShader).toContain( - 'compactedAgents[workgroupCompactedOffset + localCompactedIndex] = agent;' - ); - }); - - it('uses workgroup-local counting before allocating global compacted ranges', () => { - expect(compactionShader).toContain( - 'var workgroupAliveCount: atomic;' - ); - expect(compactionShader).toContain( - 'localCompactedIndex = atomicAdd(&workgroupAliveCount, 1u);' - ); - expect( - compactionShader.match(/atomicAdd\(&counters\.aliveAgentCount/g) ?? [] - ).toHaveLength(1); - }); -}); diff --git a/src/pipelines/agents/agent-generation/agent-schema.wgsl b/src/pipelines/agents/agent-generation/agent-schema.wgsl index 9262591..94431be 100644 --- a/src/pipelines/agents/agent-generation/agent-schema.wgsl +++ b/src/pipelines/agents/agent-generation/agent-schema.wgsl @@ -9,6 +9,8 @@ struct Agent { @group(1) @binding(1) var agents: array; -fn get_id(global_id: vec3) -> u32 { - return global_id.x; +const agentWorkgroupSize = 64u; + +fn get_id(global_id: vec3, num_workgroups: vec3) -> u32 { + return global_id.x + global_id.y * num_workgroups.x * agentWorkgroupSize; } diff --git a/src/pipelines/agents/agent-generation/agent.ts b/src/pipelines/agents/agent-generation/agent.ts deleted file mode 100644 index 630e017..0000000 --- a/src/pipelines/agents/agent-generation/agent.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const AGENT_FLOAT_COUNT = 8; -export const AGENT_SIZE_IN_BYTES = AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT; diff --git a/src/pipelines/agents/agent-pipeline.ts b/src/pipelines/agents/agent-pipeline.ts index f49c287..3e1ac46 100644 --- a/src/pipelines/agents/agent-pipeline.ts +++ b/src/pipelines/agents/agent-pipeline.ts @@ -2,15 +2,14 @@ import { createCachedFloat32BufferWrite, writeFloat32BufferIfChanged, } from '../../utils/graphics/cached-buffer-write'; -import { getWorkgroupCount } from '../../utils/graphics/get-workgroup-count'; import { smartCompile } from '../../utils/graphics/smart-compile'; import { CommonState } from '../common-state/common-state'; +import { dispatchAgentWorkgroups } from './agent-dispatch'; import agentSchema from './agent-generation/agent-schema.wgsl?raw'; import { AgentSettings } from './agent-settings'; import shader from './agent.wgsl?raw'; export class AgentPipeline { - private static readonly WORKGROUP_SIZE = 64; private static readonly UNIFORM_COUNT = 33; private readonly bindGroupLayout: GPUBindGroupLayout; @@ -151,9 +150,7 @@ export class AgentPipeline { passEncoder.setPipeline(this.pipeline); this.commonState.execute(passEncoder); passEncoder.setBindGroup(1, bindGroup); - passEncoder.dispatchWorkgroups( - getWorkgroupCount(this.agentCount, AgentPipeline.WORKGROUP_SIZE) - ); + dispatchAgentWorkgroups(passEncoder, this.agentCount); passEncoder.end(); } diff --git a/src/pipelines/agents/agent.wgsl b/src/pipelines/agents/agent.wgsl index 491b04a..f1b7412 100644 --- a/src/pipelines/agents/agent.wgsl +++ b/src/pipelines/agents/agent.wgsl @@ -41,9 +41,10 @@ struct Settings { @compute @workgroup_size(64) fn main( - @builtin(global_invocation_id) global_id: vec3 + @builtin(global_invocation_id) global_id: vec3, + @builtin(num_workgroups) num_workgroups: vec3 ) { - let id = get_id(global_id); + let id = get_id(global_id, num_workgroups); if id >= u32(settings.agentCount) { return; diff --git a/src/pipelines/brush/brush-pipeline.test.ts b/src/pipelines/brush/brush-pipeline.test.ts deleted file mode 100644 index 889a2bf..0000000 --- a/src/pipelines/brush/brush-pipeline.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { getSafeDevicePixelRatio, setBrushUniformValues } from './brush-pipeline'; - -const brushSettings = { - brushAlpha: 0.75, - brushCoarseNoiseScale: 100, - brushDiscardThreshold: 0.02, - brushFeatherRatio: 0.25, - brushGrainMaxStrength: 1, - brushGrainMinStrength: 0.4, - brushGrainNoiseOffsetX: 0.1, - brushGrainNoiseOffsetY: 0.2, - brushGrainNoiseScale: 25, - brushMinimumFeather: 2, - brushSize: 10, - brushSizeVariation: 0.5, - selectedColorIndex: 1, -}; - -describe('brush pipeline parameters', () => { - it('scales pixel-space brush uniforms by device pixel ratio', () => { - const uniformValues = new Float32Array(16); - - setBrushUniformValues(uniformValues, { - ...brushSettings, - devicePixelRatio: 2, - }); - - expect(uniformValues[0]).toBe(10); - expect(uniformValues[1]).toBe(5); - expect(uniformValues[3]).toBe(4); - expect(uniformValues[5]).toBe(1); - expect(uniformValues[8]).toBe(200); - expect(uniformValues[9]).toBe(50); - expect(uniformValues[15]).toBe(19); - }); - - it('falls back to a 1x pixel ratio for invalid values', () => { - expect(getSafeDevicePixelRatio(0)).toBe(1); - expect(getSafeDevicePixelRatio(Number.NaN)).toBe(1); - expect(getSafeDevicePixelRatio(undefined)).toBe(1); - expect(getSafeDevicePixelRatio(1.5)).toBe(1.5); - }); -}); diff --git a/src/pipelines/brush/brush-pipeline.ts b/src/pipelines/brush/brush-pipeline.ts index dd70285..ecce302 100644 --- a/src/pipelines/brush/brush-pipeline.ts +++ b/src/pipelines/brush/brush-pipeline.ts @@ -16,15 +16,13 @@ interface LineSegment { } interface BrushParameterSettings extends BrushSettings { - devicePixelRatio?: number; + pixelRatio?: number; selectedColorIndex: number; } -export const getSafeDevicePixelRatio = (devicePixelRatio: number | undefined): number => - typeof devicePixelRatio === 'number' && - Number.isFinite(devicePixelRatio) && - devicePixelRatio > 0 - ? devicePixelRatio +export const getSafePixelRatio = (pixelRatio: number | undefined): number => + typeof pixelRatio === 'number' && Number.isFinite(pixelRatio) && pixelRatio > 0 + ? pixelRatio : 1; export const setBrushUniformValues = ( @@ -43,13 +41,13 @@ export const setBrushUniformValues = ( brushGrainMinStrength, brushGrainMaxStrength, selectedColorIndex, - devicePixelRatio, + pixelRatio, }: BrushParameterSettings ): void => { - const pixelRatio = getSafeDevicePixelRatio(devicePixelRatio); - const brushRadius = (brushSize * pixelRatio) / 2; + const safePixelRatio = getSafePixelRatio(pixelRatio); + const brushRadius = (brushSize * safePixelRatio) / 2; const brushRadiusVariation = Math.floor(brushRadius * brushSizeVariation); - const brushMinimumFeatherPixels = brushMinimumFeather * pixelRatio; + const brushMinimumFeatherPixels = brushMinimumFeather * safePixelRatio; const brushFeather = Math.max( brushMinimumFeatherPixels, brushRadius * brushFeatherRatio @@ -65,8 +63,8 @@ export const setBrushUniformValues = ( target[5] = selectedColorIndex === 1 ? 1 : 0; target[6] = selectedColorIndex === 2 ? 1 : 0; target[7] = brushAlpha; - target[8] = brushCoarseNoiseScale * pixelRatio; - target[9] = brushGrainNoiseScale * pixelRatio; + target[8] = brushCoarseNoiseScale * safePixelRatio; + target[9] = brushGrainNoiseScale * safePixelRatio; target[10] = brushGrainNoiseOffsetX; target[11] = brushGrainNoiseOffsetY; target[12] = brushDiscardThreshold; diff --git a/src/pipelines/diffusion/diffusion-pipeline.test.ts b/src/pipelines/diffusion/diffusion-pipeline.test.ts deleted file mode 100644 index c352d28..0000000 --- a/src/pipelines/diffusion/diffusion-pipeline.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import shader from './diffuse.wgsl?raw'; -import { - getSafeInverseDiffusionRate, - setDiffusionUniformValues, -} from './diffusion-pipeline'; - -describe('diffusion pipeline parameters', () => { - it('keeps zero diffusion rates finite before writing shader uniforms', () => { - const uniformValues = new Float32Array(8); - - setDiffusionUniformValues(uniformValues, { - brushDecayAlphaOffset: 1.001, - decayRateBrush: 900, - decayRateTrails: 970, - diffusionDecayRateDivisor: 1000, - diffusionNeighborDivisor: 8, - diffusionRateBrush: 0, - diffusionRateTrails: 0, - }); - - expect(Number.isFinite(uniformValues[0])).toBe(true); - expect(Number.isFinite(uniformValues[2])).toBe(true); - expect(uniformValues[0]).toBeGreaterThan(0); - expect(uniformValues[2]).toBeGreaterThan(0); - }); - - it('passes valid diffusion rates through as inverse values', () => { - expect(getSafeInverseDiffusionRate(2)).toBe(0.5); - expect(getSafeInverseDiffusionRate(0.25)).toBe(4); - }); - - it('keeps the diffusion shader on the tiled compute sampling path', () => { - expect(shader).toContain('@compute @workgroup_size(16, 16)'); - expect(shader).toContain('var tile'); - expect(shader).toContain('textureLoad'); - expect(shader).not.toContain('textureSample'); - expect(shader).not.toContain('pow('); - expect(shader).not.toContain('noise'); - }); - - it('keeps shader resource groups aligned with the simplified pipeline layout', () => { - expect(shader).toContain('@group(0) @binding(0) var settings'); - expect(shader).toContain('@group(0) @binding(1) var trailMap'); - expect(shader).toContain('@group(0) @binding(2) var trailMapOut'); - expect(shader).not.toContain('@group(1)'); - }); -}); diff --git a/src/pipelines/eraser/eraser-agent-pipeline.ts b/src/pipelines/eraser/eraser-agent-pipeline.ts index 145a783..392fbd4 100644 --- a/src/pipelines/eraser/eraser-agent-pipeline.ts +++ b/src/pipelines/eraser/eraser-agent-pipeline.ts @@ -2,13 +2,12 @@ import { createCachedFloat32BufferWrite, writeFloat32BufferIfChanged, } from '../../utils/graphics/cached-buffer-write'; -import { getWorkgroupCount } from '../../utils/graphics/get-workgroup-count'; import { smartCompile } from '../../utils/graphics/smart-compile'; +import { dispatchAgentWorkgroups } from '../agents/agent-dispatch'; import agentSchema from '../agents/agent-generation/agent-schema.wgsl?raw'; import shader from './eraser-agent.wgsl?raw'; export class EraserAgentPipeline { - private static readonly WORKGROUP_SIZE = 64; private static readonly UNIFORM_COUNT = 4; private readonly bindGroupLayout: GPUBindGroupLayout; @@ -118,9 +117,7 @@ export class EraserAgentPipeline { const passEncoder = commandEncoder.beginComputePass(); passEncoder.setPipeline(this.pipeline); passEncoder.setBindGroup(1, this.getBindGroup(eraserMask)); - passEncoder.dispatchWorkgroups( - getWorkgroupCount(this.agentCount, EraserAgentPipeline.WORKGROUP_SIZE) - ); + dispatchAgentWorkgroups(passEncoder, this.agentCount); passEncoder.end(); } diff --git a/src/pipelines/eraser/eraser-agent.wgsl b/src/pipelines/eraser/eraser-agent.wgsl index 9c7b57c..1abec62 100644 --- a/src/pipelines/eraser/eraser-agent.wgsl +++ b/src/pipelines/eraser/eraser-agent.wgsl @@ -10,9 +10,10 @@ struct Settings { @compute @workgroup_size(64) fn main( - @builtin(global_invocation_id) global_id: vec3 + @builtin(global_invocation_id) global_id: vec3, + @builtin(num_workgroups) num_workgroups: vec3 ) { - let id = get_id(global_id); + let id = get_id(global_id, num_workgroups); if id >= u32(settings.agentCount) { return; diff --git a/src/pipelines/render/render.wgsl b/src/pipelines/render/render.wgsl index 05f5c20..959e5f6 100644 --- a/src/pipelines/render/render.wgsl +++ b/src/pipelines/render/render.wgsl @@ -58,19 +58,24 @@ fn renderColor(traces: vec4, sources: vec4, pixel: vec2) -> vec4< strengths.r * settings.colorA + strengths.g * settings.colorB + strengths.b * settings.colorC; - let normalizedTraceColor = - traceColor / max(settings.traceNormalizationFloor, strengths.r + strengths.g + strengths.b); + let normalizedTraceColor = normalizeColorIntensity(traceColor); let brushColor = sourceStrengths.r * settings.colorA + sourceStrengths.g * settings.colorB + sourceStrengths.b * settings.colorC; + let normalizedBrushColor = normalizeColorIntensity(brushColor); let brushStrength = max(max(sourceStrengths.r, sourceStrengths.g), sourceStrengths.b); - let color = max( - normalizedTraceColor, - brushColor * (settings.brushColorBase + brushStrength * settings.brushColorStrengthMultiplier) + let brushVisibility = clamp( + brushStrength * ( + settings.brushColorBase + + brushStrength * settings.brushColorStrengthMultiplier + ), + 0, + 1 ); + let color = max(normalizedTraceColor, normalizedBrushColor); - let strength = max(max(strengths.r, strengths.g), strengths.b); + let strength = max(max(max(strengths.r, strengths.g), strengths.b), brushVisibility); return vec4(mix(background, clamp(color, vec3(0), vec3(1)), strength), 1); } @@ -78,6 +83,11 @@ fn clarity(strength: f32) -> f32 { return pow(clamp(strength, 0, 1), settings.clarity); } +fn normalizeColorIntensity(color: vec3) -> vec3 { + let brightestChannel = max(max(color.r, color.g), color.b); + return color / max(settings.traceNormalizationFloor, brightestChannel); +} + fn getTexturedBackground(pixel: vec2) -> vec3 { let noiseSize = vec2(textureDimensions(noise, 0)); let noiseCoord = pixel % noiseSize; diff --git a/src/pipelines/wgsl-uniform-layout.test.ts b/src/pipelines/wgsl-uniform-layout.test.ts deleted file mode 100644 index 1ccc1a9..0000000 --- a/src/pipelines/wgsl-uniform-layout.test.ts +++ /dev/null @@ -1,233 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import compactionShader from './agents/agent-generation/agent-compaction.wgsl?raw'; -import { AgentGenerationPipeline } from './agents/agent-generation/agent-generation-pipeline'; -import resizeShader from './agents/agent-generation/agent-resize.wgsl?raw'; -import { AgentPipeline } from './agents/agent-pipeline'; -import agentShader from './agents/agent.wgsl?raw'; -import { BrushPipeline } from './brush/brush-pipeline'; -import brushShader from './brush/brush.wgsl?raw'; -import { CommonState } from './common-state/common-state'; -import diffusionShader from './diffusion/diffuse.wgsl?raw'; -import { DiffusionPipeline } from './diffusion/diffusion-pipeline'; -import { EraserAgentPipeline } from './eraser/eraser-agent-pipeline'; -import eraserAgentShader from './eraser/eraser-agent.wgsl?raw'; -import { EraserTexturePipeline } from './eraser/eraser-texture-pipeline'; -import eraserTextureShader from './eraser/eraser-texture.wgsl?raw'; -import { RenderPipeline } from './render/render-pipeline'; -import renderShader from './render/render.wgsl?raw'; - -const wgslFloatCountsByType: Record = { - f32: 1, - u32: 1, - 'vec2': 2, - 'vec3': 3, - 'vec4': 4, -}; - -const stripComments = (source: string): string => - source.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, ''); - -const getStructFields = (source: string, structName: string) => { - const match = new RegExp( - `struct ${structName}\\s*\\{(?[\\s\\S]*?)\\n\\s*\\}` - ).exec(stripComments(source)); - if (!match?.groups?.body) { - throw new Error(`${structName} struct was not found`); - } - - return match.groups.body - .split('\n') - .map((line) => line.trim().replace(/,$/, '')) - .filter(Boolean) - .map((line) => { - const fieldMatch = /^(?\w+):\s*(?[^,]+)$/.exec(line); - if (!fieldMatch?.groups) { - throw new Error(`Unsupported WGSL struct field syntax: ${line}`); - } - - return { - name: fieldMatch.groups.name, - type: fieldMatch.groups.type, - }; - }); -}; - -const countUniformScalars = (source: string, structName: string): number => - getStructFields(source, structName).reduce((sum, field) => { - const count = wgslFloatCountsByType[field.type]; - if (!count) { - throw new Error(`Unsupported WGSL uniform field type: ${field.type}`); - } - - return sum + count; - }, 0); - -const getUniformCount = (pipeline: unknown): number => - (pipeline as { UNIFORM_COUNT: number }).UNIFORM_COUNT; - -const expectStructUniformLayout = ({ - pipeline, - source, - structName, - fieldNames, -}: { - pipeline: unknown; - source: string; - structName: string; - fieldNames: Array; -}) => { - const fields = getStructFields(source, structName); - - expect(fields.map((field) => field.name)).toEqual(fieldNames); - expect(countUniformScalars(source, structName)).toBe(getUniformCount(pipeline)); -}; - -describe('WGSL uniform layout contracts', () => { - it('keeps shared common-state uniforms aligned with WGSL', () => { - expectStructUniformLayout({ - pipeline: CommonState, - source: CommonState.shaderCode, - structName: 'State', - fieldNames: ['size', 'time', 'padding0'], - }); - }); - - it('keeps render and simulation uniforms aligned with WGSL', () => { - expectStructUniformLayout({ - pipeline: AgentPipeline, - source: agentShader, - structName: 'Settings', - fieldNames: [ - 'moveRate', - 'turnRate', - 'sensorAngleSin', - 'sensorAngleCos', - 'sensorOffset', - 'turnWhenLost', - 'individualTrailWeight', - 'agentCount', - 'introProgress', - 'color1ToColor1', - 'color1ToColor2', - 'color1ToColor3', - 'color2ToColor1', - 'color2ToColor2', - 'color2ToColor3', - 'color3ToColor1', - 'color3ToColor2', - 'color3ToColor3', - 'sourceAttractionWeight', - 'sourceSlowMoveRate', - 'sourceTrailWeightMultiplier', - 'forwardRotationScale', - 'introNearDistanceInner', - 'introNearDistanceMin', - 'introNearSensorOffsetMultiplier', - 'introTargetAngleBlend', - 'introProgressCutoff', - 'introTurnRateMultiplier', - 'introRandomTurnMultiplier', - 'introFarMoveMultiplier', - 'introNearMoveMultiplier', - 'introStepStopDistance', - 'randomTimeScale', - ], - }); - expectStructUniformLayout({ - pipeline: BrushPipeline, - source: brushShader, - structName: 'Settings', - fieldNames: [ - 'brushSize', - 'brushSizeVariation', - 'brushFeatherRatio', - 'brushMinimumFeather', - 'brushValue', - 'brushCoarseNoiseScale', - 'brushGrainNoiseScale', - 'brushGrainNoiseOffsetX', - 'brushGrainNoiseOffsetY', - 'brushDiscardThreshold', - 'brushGrainMinStrength', - 'brushGrainMaxStrength', - 'brushGeometryRadius', - ], - }); - expectStructUniformLayout({ - pipeline: DiffusionPipeline, - source: diffusionShader, - structName: 'Settings', - fieldNames: [ - 'inverseDiffusionRateTrails', - 'decayRateTrails', - 'inverseDiffusionRateBrush', - 'decayRateBrush', - 'diffusionNeighborDivisor', - 'brushDecayAlphaOffset', - 'padding0', - 'padding1', - ], - }); - expectStructUniformLayout({ - pipeline: RenderPipeline, - source: renderShader, - structName: 'Settings', - fieldNames: [ - 'colorA', - 'backgroundColorPadding0', - 'colorB', - 'backgroundColorPadding1', - 'colorC', - 'backgroundColorPadding2', - 'backgroundColor', - 'clarity', - 'traceNormalizationFloor', - 'brushColorBase', - 'brushColorStrengthMultiplier', - 'backgroundGrainStrength', - ], - }); - }); - - it('keeps eraser uniforms aligned with WGSL', () => { - expectStructUniformLayout({ - pipeline: EraserAgentPipeline, - source: eraserAgentShader, - structName: 'Settings', - fieldNames: ['agentCount', 'eraserMaskAlphaThreshold', 'padding1', 'padding2'], - }); - expectStructUniformLayout({ - pipeline: EraserTexturePipeline, - source: eraserTextureShader, - structName: 'Settings', - fieldNames: [ - 'eraserRadiusSquared', - 'lineDistanceEpsilon', - 'clearRed', - 'clearGreen', - 'clearBlue', - 'clearAlpha', - 'padding0', - 'padding1', - ], - }); - }); - - it('keeps agent-generation uniforms large enough for every generation shader', () => { - const generationUniformCounts = [ - countUniformScalars(resizeShader, 'ResizeSettings'), - countUniformScalars(compactionShader, 'Settings'), - ]; - - expect(Math.max(...generationUniformCounts)).toBe( - getUniformCount(AgentGenerationPipeline) - ); - }); - - it('guards invalid high agent color indexes instead of treating them as color 3', () => { - expect(agentShader).toContain('colorIndex < 0.0 || colorIndex >= 2.5'); - expect(agentShader).toContain('if colorIndex < 2.5'); - expect(agentShader).toContain('return vec3(0.0, 0.0, 0.0);'); - }); -}); diff --git a/src/settings.ts b/src/settings.ts index 3ed6e48..dfd469b 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -25,6 +25,8 @@ export const applyVibeSettings = (vibe: VibePreset) => { Object.assign(settings, { ...buildSettings(vibe), eraserSize: settings.eraserSize, + internalRenderAreaMegapixels: settings.internalRenderAreaMegapixels, + maxAgentCount: settings.maxAgentCount, mirrorSegmentCount: settings.mirrorSegmentCount, selectedColorIndex: Math.min(settings.selectedColorIndex, vibe.colors.length - 1), }); diff --git a/src/style/_app-shell.scss b/src/style/_app-shell.scss index b415f56..bf17e11 100644 --- a/src/style/_app-shell.scss +++ b/src/style/_app-shell.scss @@ -1,3 +1,7 @@ +html > body.pre-drawing .dev-stats-overlay { + display: none; +} + html > body { width: 100%; height: 100vh; @@ -47,6 +51,28 @@ html > body { } } + > .dev-stats-overlay { + position: absolute; + top: max(8px, env(safe-area-inset-top)); + left: max(8px, env(safe-area-inset-left)); + z-index: 6; + padding: 6px 8px; + border: 1px solid rgb(255 255 255 / 18%); + border-radius: 6px; + background: rgb(0 0 0 / 62%); + color: rgb(255 255 255 / 92%); + font: + 600 12px/1.35 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + white-space: pre; + pointer-events: none; + user-select: none; + box-shadow: 0 8px 24px rgb(0 0 0 / 28%); + } + > .errors-container { position: absolute; top: 0; diff --git a/src/style/_loading.scss b/src/style/_loading.scss index 2f0d6f9..ac83f11 100644 --- a/src/style/_loading.scss +++ b/src/style/_loading.scss @@ -4,77 +4,139 @@ left: 50%; display: flex; flex-direction: column; - gap: 18px; + gap: 22px; align-items: center; justify-content: center; z-index: 3; - width: min(78vw, 320px); + width: min(86vw, 380px); transform: translate(-50%, -50%); opacity: 0; pointer-events: none; transition: opacity var(--transition-time-long); - > .loading-dots { + > .splash { display: flex; - gap: 14px; + flex-direction: column; + gap: 16px; align-items: center; - justify-content: center; + pointer-events: auto; - > .loading-dot { - width: 14px; - height: 14px; - border-radius: 50%; - background: rgb(255 255 255 / 92%); + &[hidden] { + display: none; + } + + > .splash-title { + margin: 0; + color: rgb(255 255 255 / 96%); + font-size: clamp(28px, 6vw, 42px); + font-weight: 700; + line-height: 1.1; + text-align: center; + letter-spacing: 0.01em; + text-shadow: + 0 2px 18px rgb(0 0 0 / 60%), + 0 0 32px rgb(255 255 255 / 10%); + } + + > .splash-description { + margin: 0; + max-width: 28ch; + color: rgb(255 255 255 / 80%); + font-size: 15px; + font-weight: 400; + line-height: 1.45; + text-align: center; + text-shadow: 0 1px 12px rgb(0 0 0 / 60%); + } + + > .start-button { + margin-top: 8px; + padding: 14px 40px; + border: 1px solid rgb(255 255 255 / 38%); + border-radius: 999px; + background: rgb(255 255 255 / 8%); + color: rgb(255 255 255 / 96%); + font-size: 16px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + cursor: pointer; + backdrop-filter: blur(6px); box-shadow: - 0 0 18px rgb(255 255 255 / 38%), - 0 0 4px rgb(255 255 255 / 60%); - transform: scale(0.5); - opacity: 0.4; - animation: loading-bloom 1.4s ease-in-out infinite; + 0 0 24px rgb(255 255 255 / 14%), + 0 1px 6px rgb(0 0 0 / 28%); + transition: + opacity var(--transition-time), + transform var(--transition-time), + background var(--transition-time); - &:nth-child(2) { - animation-delay: 0.18s; + &[disabled] { + opacity: 0.5; + cursor: progress; } - &:nth-child(3) { - animation-delay: 0.36s; + &:not([disabled]):hover, + &:not([disabled]):focus-visible { + background: rgb(255 255 255 / 16%); + transform: scale(1.04); + outline: none; + } + + &:not([disabled]):active { + transform: scale(0.98); } } } - > .loading-status { - color: rgb(255 255 255 / 88%); - font-size: 16px; - font-weight: 400; - line-height: 1.25; - text-align: center; - text-shadow: 0 1px 12px rgb(0 0 0 / 60%); - letter-spacing: 0.01em; - min-height: 1.25em; - } - - > .loading-progress { - --loading-progress: 0%; - - position: relative; + > .loading-bar { + display: flex; + flex-direction: column; + gap: 18px; + align-items: center; width: 100%; - height: 3px; - overflow: hidden; - border-radius: 999px; - background: rgb(255 255 255 / 14%); - box-shadow: 0 1px 6px rgb(0 0 0 / 28%); - &::before { - content: ''; - position: absolute; - top: 0; - left: 0; - bottom: 0; - width: var(--loading-progress); - border-radius: inherit; - background: linear-gradient(90deg, rgb(255 255 255 / 72%), rgb(255 255 255 / 96%)); - box-shadow: 0 0 12px rgb(255 255 255 / 38%); - transition: width var(--transition-time-long) ease-out; + &[hidden] { + display: none; + } + + > .loading-status { + color: rgb(255 255 255 / 88%); + font-size: 16px; + font-weight: 400; + line-height: 1.25; + text-align: center; + text-shadow: 0 1px 12px rgb(0 0 0 / 60%); + letter-spacing: 0.01em; + min-height: 1.25em; + } + + > .loading-progress { + --loading-progress: 0%; + + position: relative; + width: 100%; + height: 3px; + overflow: hidden; + border-radius: 999px; + background: rgb(255 255 255 / 14%); + box-shadow: 0 1px 6px rgb(0 0 0 / 28%); + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + bottom: 0; + width: var(--loading-progress); + border-radius: inherit; + background: linear-gradient( + 90deg, + rgb(255 255 255 / 72%), + rgb(255 255 255 / 96%) + ); + box-shadow: 0 0 12px rgb(255 255 255 / 38%); + transition: width var(--transition-time-long) ease-out; + } } } } @@ -94,22 +156,3 @@ html > body.is-loading { } } -@keyframes loading-bloom { - 0%, - 100% { - transform: scale(0.5); - opacity: 0.35; - } - - 50% { - transform: scale(1); - opacity: 1; - } -} - -@media (prefers-reduced-motion: reduce) { - .loading-indicator > .loading-dots > .loading-dot { - transform: scale(0.85); - opacity: 0.85; - } -} diff --git a/src/style/_motion.scss b/src/style/_motion.scss index 9a885e8..20d1e66 100644 --- a/src/style/_motion.scss +++ b/src/style/_motion.scss @@ -7,7 +7,7 @@ transform: none; } - > .toolbar-shell > nav.buttons > button:hover::after { + > nav.buttons > button:hover::after { transform: none; } } diff --git a/src/style/_toolbar.scss b/src/style/_toolbar.scss index b2ca779..1e3615d 100644 --- a/src/style/_toolbar.scss +++ b/src/style/_toolbar.scss @@ -39,14 +39,19 @@ html > body > aside.control-dock > .toolbar-row { --toolbar-background-opacity: 0%; --toolbar-background-strength: 0; - display: flex; + display: grid; + grid-template-areas: + 'previous controls next' + 'previous buttons next'; + grid-template-columns: auto minmax(0, 1fr) auto; align-items: stretch; justify-content: center; - width: fit-content; + width: 100%; max-width: 100%; margin: 0 auto; padding-inline: clamp(8px, 1.4vw, 14px); - gap: clamp(6px, 1.8vw, 14px); + column-gap: 0; + row-gap: clamp(6px, 1.8vw, 14px); border-radius: 12px; color: rgb(245 250 244 / 92%); background-color: rgb(5 8 13 / var(--toolbar-background-opacity)); @@ -91,16 +96,15 @@ html > body > aside.control-dock > .toolbar-row { } > .toolbar-shell { + grid-area: controls; display: grid; - grid-template-areas: - 'swatches' - 'nav'; + grid-template-areas: 'swatches'; grid-template-columns: minmax(0, 1fr); align-items: center; justify-content: center; - gap: 8px; + justify-self: center; + width: min(100%, max-content); min-width: 0; - min-height: 86px; padding: 8px 9px; } @@ -150,13 +154,22 @@ html > body > aside.control-dock > .toolbar-row { } } - > .toolbar-shell > nav.buttons { - grid-area: nav; + > .previous-vibe { + grid-area: previous; + } + + > .next-vibe { + grid-area: next; + } + + > nav.buttons { + grid-area: buttons; display: flex; - flex-wrap: wrap; + flex-wrap: nowrap; align-items: center; - justify-content: center; + justify-content: space-between; gap: 4px; + width: 100%; min-width: 0; padding-top: 7px; border-top: 1px solid rgb(255 255 255 / 12%); @@ -166,6 +179,9 @@ html > body > aside.control-dock > .toolbar-row { position: relative; width: 44px; height: 44px; + flex: 1 1 44px; + max-width: 54px; + min-width: 0; border: 1px solid transparent; border-radius: 8px; background: transparent; @@ -235,7 +251,8 @@ html > body > aside.control-dock > .toolbar-row { align-items: center; width: 132px; height: 44px; - flex: 0 0 132px; + flex: 2 1 132px; + max-width: 150px; min-width: 0; padding-right: 10px; border: 1px solid transparent; @@ -252,18 +269,13 @@ html > body > aside.control-dock > .toolbar-row { background: rgb(255 255 255 / 7%); } - &:focus-within { - outline: 2px solid white; - outline-offset: 2px; - } - > button { flex: 0 0 42px; min-width: 42px; border-color: transparent; &:focus-visible { - outline: none; + outline-offset: -4px; } } @@ -296,7 +308,9 @@ html > body > aside.control-dock > .toolbar-row { touch-action: pan-y; &:focus-visible { - outline: none; + border-radius: 8px; + outline: 2px solid white; + outline-offset: -4px; } &::-webkit-slider-runnable-track { @@ -558,11 +572,12 @@ html > body > aside.control-dock > .toolbar-row { @include on-small-screen { width: 100%; - padding-inline: 6px; - gap: 6px; + padding-inline: 4px; + column-gap: 0; + row-gap: 4px; > .vibe-button { - width: 44px; + width: 36px; min-height: 44px; &::before { @@ -572,46 +587,52 @@ html > body > aside.control-dock > .toolbar-row { } > .toolbar-shell { - flex: 1 1 auto; - padding: 4px 8px; + padding: 4px; + } - > nav.buttons { - gap: 2px; - padding-top: 3px; + > nav.buttons { + gap: clamp(1px, 0.55vw, 2px); + padding-top: 3px; - > button { - height: 38px; - min-height: 38px; + > button { + width: auto; + height: 38px; + flex: 1 1 clamp(28px, 8vw, 38px); + max-width: 38px; + min-height: 38px; - &::after { - width: 17px; - height: 17px; - } - } - - > .audio-control { - width: 118px; - height: 38px; - flex-basis: 118px; - padding-right: 9px; - - > button { - flex-basis: 38px; - min-width: 38px; - } - - > .volume-control { - height: 38px; - } - } - - > .export-status { - flex-basis: 100%; - max-width: 100%; - text-align: center; + &::after { + width: 17px; + height: 17px; } } + > .audio-control { + width: auto; + height: 38px; + flex: 2 1 clamp(58px, 18vw, 118px); + max-width: 118px; + padding-right: clamp(4px, 1.8vw, 9px); + + > button { + width: auto; + flex: 1 1 clamp(28px, 8vw, 38px); + min-width: 0; + } + + > .volume-control { + height: 38px; + } + } + + > .export-status { + flex-basis: 0; + max-width: 0; + text-align: center; + } + } + + > .toolbar-shell { > .garden-controls { padding: 2px 4px; diff --git a/src/utils/browser-storage.ts b/src/utils/browser-storage.ts index b02db6c..834744b 100644 --- a/src/utils/browser-storage.ts +++ b/src/utils/browser-storage.ts @@ -11,7 +11,10 @@ export const writeBrowserStorage = (key: string, value: string): void => { if (typeof localStorage !== 'undefined') { localStorage.setItem(key, value); } - } catch { - // Storage can be unavailable in private browsing or embedded contexts. + } catch (error) { + console.warn( + 'Storage can be unavailable in private browsing or embedded contexts.', + error, + ); } }; diff --git a/src/utils/clamp.ts b/src/utils/clamp.ts deleted file mode 100644 index 45da555..0000000 --- a/src/utils/clamp.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const clamp = (value: number, min: number, max: number): number => - Math.min(max, Math.max(min, value)); - -export const clamp01 = (value: number): number => Math.min(1, Math.max(0, value)); diff --git a/src/utils/delta-time-calculator.ts b/src/utils/delta-time-calculator.ts index 9603bea..200a7f6 100644 --- a/src/utils/delta-time-calculator.ts +++ b/src/utils/delta-time-calculator.ts @@ -1,5 +1,5 @@ import { appConfig } from '../config'; -import { clamp } from './clamp'; +import { clamp } from './math'; export class DeltaTimeCalculator { private previousTime: DOMHighResTimeStamp | null = null; diff --git a/src/utils/graphics/cached-buffer-write.test.ts b/src/utils/graphics/cached-buffer-write.test.ts deleted file mode 100644 index 3fb15b3..0000000 --- a/src/utils/graphics/cached-buffer-write.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -import { - createCachedFloat32BufferWrite, - writeFloat32BufferIfChanged, -} from './cached-buffer-write'; - -const createGpuWriteStub = () => { - const writeBuffer = vi.fn(); - const device = { - queue: { - writeBuffer, - }, - } as unknown as GPUDevice; - - return { device, writeBuffer }; -}; - -describe('cached float32 buffer writes', () => { - it('writes the first value set and skips unchanged values', () => { - const { device, writeBuffer } = createGpuWriteStub(); - const buffer = {} as GPUBuffer; - const cache = createCachedFloat32BufferWrite(3); - const values = new Float32Array([1, 2, 3]); - - expect(writeFloat32BufferIfChanged(device, buffer, values, cache)).toBe(true); - expect(writeBuffer).toHaveBeenCalledTimes(1); - expect(writeBuffer).toHaveBeenLastCalledWith(buffer, 0, values); - - expect(writeFloat32BufferIfChanged(device, buffer, values, cache)).toBe(false); - expect(writeBuffer).toHaveBeenCalledTimes(1); - }); - - it('writes again when any float changes', () => { - const { device, writeBuffer } = createGpuWriteStub(); - const buffer = {} as GPUBuffer; - const cache = createCachedFloat32BufferWrite(3); - - expect( - writeFloat32BufferIfChanged(device, buffer, new Float32Array([1, 2, 3]), cache) - ).toBe(true); - expect( - writeFloat32BufferIfChanged(device, buffer, new Float32Array([1, 2, 4]), cache) - ).toBe(true); - expect(writeBuffer).toHaveBeenCalledTimes(2); - }); - - it('rejects cache length mismatches before writing', () => { - const { device, writeBuffer } = createGpuWriteStub(); - const buffer = {} as GPUBuffer; - const cache = createCachedFloat32BufferWrite(2); - - expect(() => - writeFloat32BufferIfChanged(device, buffer, new Float32Array([1]), cache) - ).toThrow('Cached buffer write length mismatch'); - expect(writeBuffer).not.toHaveBeenCalled(); - }); -}); diff --git a/src/utils/graphics/get-workgroup-count.test.ts b/src/utils/graphics/get-workgroup-count.test.ts deleted file mode 100644 index e539f2d..0000000 --- a/src/utils/graphics/get-workgroup-count.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { getWorkgroupCount } from './get-workgroup-count'; - -describe('getWorkgroupCount', () => { - it('returns at least one workgroup for positive invocation counts', () => { - expect(getWorkgroupCount(1, 64)).toBe(1); - expect(getWorkgroupCount(65, 64)).toBe(2); - }); - - it('rejects zero and non-finite dispatch inputs', () => { - expect(() => getWorkgroupCount(0, 64)).toThrow(/positive finite/); - expect(() => getWorkgroupCount(-1, 64)).toThrow(/positive finite/); - expect(() => getWorkgroupCount(Number.POSITIVE_INFINITY, 64)).toThrow( - /positive finite/ - ); - expect(() => getWorkgroupCount(1, 0)).toThrow(/positive finite/); - }); -}); diff --git a/src/utils/graphics/initialize-gpu.test.ts b/src/utils/graphics/initialize-gpu.test.ts deleted file mode 100644 index cdabd2b..0000000 --- a/src/utils/graphics/initialize-gpu.test.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import { ErrorCode, ErrorHandler, RuntimeError, Severity } from '../error-handler'; -import { initializeGpu } from './initialize-gpu'; - -const gpuLimits = { - maxBufferSize: 256 * 1024 * 1024, - maxComputeWorkgroupsPerDimension: 65_535, - maxStorageBufferBindingSize: 128 * 1024 * 1024, -} as GPUSupportedLimits; - -const observedErrors: Array<{ - code?: string; - message: string; - severity: Severity; -}> = []; - -ErrorHandler.addOnErrorListener((error) => { - observedErrors.push(error); -}); - -const defer = () => { - let resolve!: (value: T) => void; - const promise = new Promise((nextResolve) => { - resolve = nextResolve; - }); - - return { promise, resolve }; -}; - -const stubBrowser = ({ - gpu, - isSecureContext = true, -}: { - gpu?: GPU; - isSecureContext?: boolean; -}) => { - vi.stubGlobal('window', { isSecureContext }); - vi.stubGlobal('navigator', { gpu }); -}; - -const createDevice = ( - lost: Promise = new Promise(() => {}) -) => { - const listeners = new Map(); - const device = { - addEventListener: vi.fn((type: string, listener: EventListener) => { - listeners.set(type, listener); - }), - lost, - } as unknown as GPUDevice; - - return { device, listeners }; -}; - -const createAdapter = ({ - requestDevice = vi.fn(), -}: { - requestDevice?: ReturnType; -} = {}) => - ({ - features: new Set(), - info: { - architecture: 'test', - description: 'unit-test adapter', - device: 'test-device', - isFallbackAdapter: false, - subgroupMaxSize: 0, - subgroupMinSize: 0, - vendor: 'test-vendor', - }, - limits: gpuLimits, - requestDevice, - }) as unknown as GPUAdapter; - -const captureInitializeGpuError = async (): Promise => { - try { - await initializeGpu(); - } catch (error) { - expect(error).toBeInstanceOf(RuntimeError); - return error as RuntimeError; - } - - throw new Error('Expected initializeGpu to reject.'); -}; - -describe('initializeGpu', () => { - afterEach(() => { - observedErrors.length = 0; - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - }); - - it('rejects insecure contexts before touching WebGPU', async () => { - stubBrowser({ isSecureContext: false }); - - const error = await captureInitializeGpuError(); - - expect(error.code).toBe(ErrorCode.WEBGPU_INSECURE_CONTEXT); - expect(error.message).toContain('WebGPU requires a secure context'); - }); - - it('rejects browsers without navigator.gpu', async () => { - stubBrowser({}); - - const error = await captureInitializeGpuError(); - - expect(error.code).toBe(ErrorCode.WEBGPU_UNSUPPORTED); - expect(error.message).toContain('Fleeting Garden needs WebGPU'); - expect(error.details).toMatchObject({ - hasNavigatorGpu: false, - isSecureContext: true, - }); - }); - - it('wraps adapter request exceptions with adapter diagnostics', async () => { - const requestAdapter = vi.fn(async () => { - throw new Error('adapter request failed'); - }); - stubBrowser({ gpu: { requestAdapter } as unknown as GPU }); - - const error = await captureInitializeGpuError(); - - expect(requestAdapter).toHaveBeenCalledOnce(); - expect(requestAdapter).toHaveBeenCalledWith({ powerPreference: 'high-performance' }); - expect(error.code).toBe(ErrorCode.WEBGPU_ADAPTER_UNAVAILABLE); - expect(error.message).toBe('Could not request a WebGPU adapter.'); - expect(error.details).toMatchObject({ - causeMessage: 'adapter request failed', - powerPreference: 'high-performance', - }); - }); - - it('tries the default adapter before reporting adapter unavailability', async () => { - const requestAdapter = vi.fn(async () => null); - stubBrowser({ gpu: { requestAdapter } as unknown as GPU }); - - const error = await captureInitializeGpuError(); - - expect(requestAdapter).toHaveBeenNthCalledWith(1, { - powerPreference: 'high-performance', - }); - expect(requestAdapter).toHaveBeenNthCalledWith(2, undefined); - expect(error.code).toBe(ErrorCode.WEBGPU_ADAPTER_UNAVAILABLE); - expect(error.message).toContain('could not provide a compatible GPU adapter'); - }); - - it('requests the device with the adapter limits needed by the pipelines', async () => { - const { device } = createDevice(); - const requestDevice = vi.fn(async () => device); - const adapter = createAdapter({ requestDevice }); - stubBrowser({ - gpu: { requestAdapter: vi.fn(async () => adapter) } as unknown as GPU, - }); - - await expect(initializeGpu()).resolves.toBe(device); - - expect(requestDevice).toHaveBeenCalledWith({ - requiredLimits: { - maxBufferSize: gpuLimits.maxBufferSize, - maxComputeWorkgroupsPerDimension: gpuLimits.maxComputeWorkgroupsPerDimension, - maxStorageBufferBindingSize: gpuLimits.maxStorageBufferBindingSize, - }, - }); - expect(device.addEventListener).toHaveBeenCalledWith( - 'uncapturederror', - expect.any(Function) - ); - }); - - it('wraps device request failures with required limit details', async () => { - const requestDevice = vi.fn(async () => { - throw new Error('device request failed'); - }); - const adapter = createAdapter({ requestDevice }); - stubBrowser({ - gpu: { requestAdapter: vi.fn(async () => adapter) } as unknown as GPU, - }); - - const error = await captureInitializeGpuError(); - - expect(error.code).toBe(ErrorCode.WEBGPU_DEVICE_UNAVAILABLE); - expect(error.message).toBe('Could not create a WebGPU device for this adapter.'); - expect(error.details).toMatchObject({ - causeMessage: 'device request failed', - requiredLimits: { - maxBufferSize: gpuLimits.maxBufferSize, - maxComputeWorkgroupsPerDimension: gpuLimits.maxComputeWorkgroupsPerDimension, - maxStorageBufferBindingSize: gpuLimits.maxStorageBufferBindingSize, - }, - }); - }); - - it('routes uncaptured GPU errors through the runtime error handler', async () => { - const { device, listeners } = createDevice(); - const adapter = createAdapter({ requestDevice: vi.fn(async () => device) }); - stubBrowser({ - gpu: { requestAdapter: vi.fn(async () => adapter) } as unknown as GPU, - }); - - await initializeGpu(); - listeners.get('uncapturederror')?.({ - error: new Error('uncaptured GPU validation failure'), - } as unknown as GPUUncapturedErrorEvent); - - expect(observedErrors.at(-1)).toMatchObject({ - code: ErrorCode.WEBGPU_UNCAPTURED_ERROR, - message: 'uncaptured GPU validation failure', - severity: Severity.ERROR, - }); - }); - - it('reports unexpected device loss but ignores intentional destruction', async () => { - const unexpectedLoss = defer(); - const { device } = createDevice(unexpectedLoss.promise); - const adapter = createAdapter({ requestDevice: vi.fn(async () => device) }); - stubBrowser({ - gpu: { requestAdapter: vi.fn(async () => adapter) } as unknown as GPU, - }); - - await initializeGpu(); - unexpectedLoss.resolve({ - message: 'device lost during rendering', - reason: 'unknown', - } as GPUDeviceLostInfo); - await Promise.resolve(); - - expect(observedErrors.at(-1)).toMatchObject({ - code: ErrorCode.WEBGPU_DEVICE_LOST, - message: 'device lost during rendering', - severity: Severity.ERROR, - }); - - observedErrors.length = 0; - const destroyedLoss = defer(); - const { device: destroyedDevice } = createDevice(destroyedLoss.promise); - const destroyedAdapter = createAdapter({ - requestDevice: vi.fn(async () => destroyedDevice), - }); - stubBrowser({ - gpu: { requestAdapter: vi.fn(async () => destroyedAdapter) } as unknown as GPU, - }); - - await initializeGpu(); - destroyedLoss.resolve({ - message: 'device destroyed intentionally', - reason: 'destroyed', - } as GPUDeviceLostInfo); - await Promise.resolve(); - - expect(observedErrors).toEqual([]); - }); -}); diff --git a/src/utils/math.test.ts b/src/utils/math.test.ts deleted file mode 100644 index 3e0f367..0000000 --- a/src/utils/math.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { clamp, clamp01 } from './clamp'; - -describe('clamp', () => { - it('returns value when within bounds', () => { - expect(clamp(5, 0, 10)).toBe(5); - }); - it('clamps below to lower bound', () => { - expect(clamp(-3, 0, 10)).toBe(0); - }); - it('clamps above to upper bound', () => { - expect(clamp(42, 0, 10)).toBe(10); - }); -}); - -describe('clamp01', () => { - it('passes through values in [0, 1]', () => { - expect(clamp01(0.25)).toBe(0.25); - }); - it('clamps negatives to 0', () => { - expect(clamp01(-1)).toBe(0); - }); - it('clamps above 1 to 1', () => { - expect(clamp01(2)).toBe(1); - }); -}); diff --git a/src/vibes.test.ts b/src/vibes.test.ts deleted file mode 100644 index 6a6cde5..0000000 --- a/src/vibes.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import { gardenAudioConfig } from './audio/garden-audio-config'; -import { getInitialVibe, hexToRgb, VIBE_PRESETS, VibeId } from './vibes'; - -const originalLocalStorage = globalThis.localStorage; - -const setBrowserVibeState = ({ - storedVibeId = null, -}: { - storedVibeId?: string | null; -}) => { - Object.defineProperty(globalThis, 'localStorage', { - configurable: true, - value: { - getItem: vi.fn((key: string) => - key === 'fleeting-garden:vibe' ? storedVibeId : null - ), - }, - }); -}; - -describe('vibe selection', () => { - afterEach(() => { - Object.defineProperty(globalThis, 'localStorage', { - configurable: true, - value: originalLocalStorage, - }); - }); - - it('uses a valid stored vibe id', () => { - setBrowserVibeState({ storedVibeId: VibeId.SunlitMoss }); - - expect(getInitialVibe().id).toBe(VibeId.SunlitMoss); - }); - - it('falls back to the default preset for an unknown stored vibe id', () => { - setBrowserVibeState({ storedVibeId: 'unknown' }); - - expect(getInitialVibe()).toBe(VIBE_PRESETS[0]); - }); -}); - -describe('vibe and audio config contract', () => { - it('keeps preset ids unique and URL-safe', () => { - const vibeIds = VIBE_PRESETS.map((vibe) => vibe.id); - - expect(new Set(vibeIds).size).toBe(vibeIds.length); - expect(vibeIds.every((id) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id))).toBe(true); - }); - - 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 = vibe.audio; - 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 style voices aligned with the rotating style pools', () => { - expect(gardenAudioConfig.styleVoices).toHaveLength( - gardenAudioConfig.generativePiano.stylePools.length - ); - gardenAudioConfig.styleVoices.forEach((voice) => { - expect(Number.isFinite(voice.scaleDegreeOffset)).toBe(true); - expect(voice.velocityMultiplier).toBeGreaterThan(0); - expect(Math.abs(voice.panOffset)).toBeLessThanOrEqual(1); - }); - }); - - it('keeps audio timing, graph, and density settings bounded', () => { - const { delay, generativePiano, graph, piano, rhythm } = gardenAudioConfig; - - expect(rhythm.bpm).toBeGreaterThan(0); - expect(rhythm.stepsPerBeat).toBeGreaterThan(0); - expect(rhythm.stepsPerBar).toBeGreaterThanOrEqual(rhythm.stepsPerBeat); - expect(rhythm.lookaheadSeconds).toBeGreaterThanOrEqual(piano.scheduleAheadSeconds); - - expect(delay.feedbackMin).toBeLessThanOrEqual(delay.feedback); - expect(delay.feedback).toBeLessThanOrEqual(delay.feedbackMax); - expect(delay.feedbackHighPassHz).toBeLessThan(delay.feedbackLowPassHz); - expect(delay.returnLowPassHz).toBeGreaterThan(delay.feedbackHighPassHz); - - generativePiano.stylePools.forEach((register) => { - expect(register.midiMin).toBeLessThan(register.preferredMidi); - expect(register.preferredMidi).toBeLessThan(register.midiMax); - }); - generativePiano.padRegisters.forEach((register) => { - expect(register.midiMin).toBeLessThan(register.preferredMidi); - expect(register.preferredMidi).toBeLessThan(register.midiMax); - }); - - expect(generativePiano.brushStreamIdleIntervalBeats).toBeGreaterThanOrEqual( - generativePiano.brushStreamActiveIntervalBeats - ); - expect(generativePiano.brushStreamActiveIntervalBeats).toBeGreaterThanOrEqual( - generativePiano.brushStreamIntenseIntervalBeats - ); - expect(generativePiano.brushStreamIntenseIntervalBeats).toBeGreaterThanOrEqual( - generativePiano.brushStreamManicIntervalBeats - ); - expect(generativePiano.maxBrushPhraseLayers).toBeLessThanOrEqual(3); - expect(generativePiano.maxBrushStreamNotesPerBar).toBeLessThanOrEqual( - rhythm.stepsPerBar - ); - - Object.values(graph.pianoBusGains).forEach((gain) => { - expect(gain).toBeGreaterThan(0); - expect(gain).toBeLessThanOrEqual(1.2); - }); - }); - - it('falls back to finite RGB channels for malformed hex colors', () => { - expect(hexToRgb('not-a-color')).toEqual([0, 0, 0]); - expect(hexToRgb('#abcdzz')).toEqual([0, 0, 0]); - }); -}); diff --git a/src/vibes.ts b/src/vibes.ts index 42db194..47a3314 100644 --- a/src/vibes.ts +++ b/src/vibes.ts @@ -7,19 +7,6 @@ export type { VibePreset } from './config'; export const VIBE_PRESETS: Array = appConfig.vibes.presets; const VIBE_IDS = new Set(VIBE_PRESETS.map((vibe) => vibe.id)); -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]; -}; - export const isVibeId = (value: unknown): value is VibeId => typeof value === 'string' && VIBE_IDS.has(value as VibeId); diff --git a/tsconfig.playwright.json b/tsconfig.playwright.json index 139efca..fca3dfe 100644 --- a/tsconfig.playwright.json +++ b/tsconfig.playwright.json @@ -1,7 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "types": ["node", "@playwright/test"] + "types": ["node"] }, "include": ["playwright.config.ts", "e2e/**/*.ts"] } diff --git a/vite.config.ts b/vite.config.ts index 795e630..cfe208b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,13 +1,15 @@ import basicSsl from '@vitejs/plugin-basic-ssl'; import browserslist from 'browserslist'; +import browserslistToEsbuild from 'browserslist-to-esbuild'; import { browserslistToTargets } from 'lightningcss'; import { viteSingleFile } from 'vite-plugin-singlefile'; import { defineConfig } from 'vitest/config'; const cssTargets = browserslistToTargets(browserslist()); +const esbuildTargets = browserslistToEsbuild(); export default defineConfig(({ command }) => ({ - base: command === 'build' ? './' : '/', + base: './', plugins: [ viteSingleFile({ useRecommendedBuildConfig: false }), ...(command === 'serve' ? [basicSsl()] : []), @@ -19,7 +21,7 @@ export default defineConfig(({ command }) => ({ }, }, build: { - target: 'es2022', + target: esbuildTargets, cssCodeSplit: false, cssMinify: 'lightningcss', assetsInlineLimit: Number.MAX_SAFE_INTEGER,