diff --git a/definitions.d.ts b/definitions.d.ts index 934370e..c90ad44 100644 --- a/definitions.d.ts +++ b/definitions.d.ts @@ -2,3 +2,7 @@ declare module '*.wgsl?raw' { const content: string; export default content; } + +interface HTMLCanvasElement { + getContext(contextId: 'webgpu'): GPUCanvasContext | null; +} diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts index e42de2f..f0381bf 100644 --- a/e2e/app.spec.ts +++ b/e2e/app.spec.ts @@ -1,12 +1,77 @@ -import { expect, test } from '@playwright/test'; +import { expect, test, type Page } from '@playwright/test'; -test('loads the app shell and WebGPU fallback in Chromium', async ({ page }) => { +type WebGpuFailureMode = 'adapter-null' | 'adapter-rejects' | 'device-rejects'; + +const disableWebGpu = async (page: Page) => { await page.addInitScript(() => { Object.defineProperty(navigator, 'gpu', { configurable: true, value: undefined, }); }); +}; + +const emulateWebGpuFailure = async (page: Page, mode: WebGpuFailureMode) => { + await page.addInitScript((failureMode) => { + const limits = { + maxBufferSize: 256 * 1024 * 1024, + maxComputeWorkgroupsPerDimension: 65_535, + maxStorageBufferBindingSize: 128 * 1024 * 1024, + }; + const adapter = { + features: new Set(), + info: { + architecture: 'test', + description: 'Playwright fake adapter', + device: 'test-device', + isFallbackAdapter: false, + subgroupMaxSize: 0, + subgroupMinSize: 0, + vendor: 'test-vendor', + }, + limits, + requestDevice: async () => { + if (failureMode === 'device-rejects') { + throw new Error('Playwright fake device failure'); + } + + return {}; + }, + }; + + Object.defineProperty(navigator, 'gpu', { + configurable: true, + value: { + getPreferredCanvasFormat: () => 'rgba8unorm', + requestAdapter: async () => { + if (failureMode === 'adapter-null') { + return null; + } + + if (failureMode === 'adapter-rejects') { + throw new Error('Playwright fake adapter failure'); + } + + return adapter; + }, + }, + }); + }, mode); +}; + +const getFirstSwatchColor = (page: Page) => + page + .locator('.color-swatch') + .first() + .evaluate((element) => getComputedStyle(element).backgroundColor); + +const getGardenBackground = (page: Page) => + page.evaluate(() => + document.documentElement.style.getPropertyValue('--garden-background').trim() + ); + +test('loads the app shell and WebGPU fallback in Chromium', async ({ page }) => { + await disableWebGpu(page); await page.goto('/'); @@ -21,3 +86,224 @@ test('loads the app shell and WebGPU fallback in Chromium', async ({ page }) => await page.getByRole('button', { name: 'About' }).click(); await expect(page.getByRole('heading', { name: 'Fleeting Garden' })).toBeVisible(); }); + +test('keeps fallback controls interactive and accessible', async ({ page }) => { + await disableWebGpu(page); + + await page.goto('/'); + await expect(page.locator('body')).not.toHaveClass(/is-loading/); + + const aboutButton = page.getByRole('button', { name: 'About' }); + const aboutPanel = page.locator('#info-panel'); + await expect(aboutButton).toHaveAttribute('aria-expanded', 'false'); + await aboutButton.click(); + await expect(aboutButton).toHaveAttribute('aria-expanded', 'true'); + await expect(aboutPanel).toHaveAttribute('aria-hidden', 'false'); + await expect(aboutPanel).not.toHaveAttribute('inert', ''); + await expect(page.getByRole('heading', { name: 'Fleeting Garden' })).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(aboutButton).toHaveAttribute('aria-expanded', 'false'); + await expect(aboutPanel).toHaveAttribute('aria-hidden', 'true'); + await expect(aboutPanel).toHaveAttribute('inert', ''); + + const settingsButton = page.getByRole('button', { name: 'Show config overlay' }); + await settingsButton.click(); + await expect(page.getByRole('button', { name: 'Hide config overlay' })).toHaveAttribute( + 'aria-expanded', + 'true' + ); + await expect(page.locator('.config-pane')).toBeVisible(); + + const soundButton = page.locator('button.sound'); + await expect(soundButton).toHaveAttribute('aria-pressed', 'false'); + await soundButton.click(); + await expect(soundButton).toHaveAttribute('aria-pressed', 'true'); + await expect(soundButton).toHaveAttribute('aria-label', 'Unmute audio'); + await page.reload(); + await expect(page.locator('body')).not.toHaveClass(/is-loading/); + await expect(page.locator('button.sound')).toHaveAttribute('aria-pressed', 'true'); + + const initialSwatchColor = await getFirstSwatchColor(page); + const initialBackground = await getGardenBackground(page); + await page.getByRole('button', { name: 'Next vibe' }).click(); + await expect.poll(() => getFirstSwatchColor(page)).not.toBe(initialSwatchColor); + await expect.poll(() => getGardenBackground(page)).not.toBe(initialBackground); + + await page.getByRole('button', { name: 'Draw colour 2' }).click(); + await expect(page.locator('.color-swatch').nth(1)).toHaveClass(/active/); + await expect(page.locator('.color-swatch').first()).not.toHaveClass(/active/); + + const mirrorSlider = page.locator('.mirror-segment-slider'); + await mirrorSlider.evaluate((input) => { + const slider = input as HTMLInputElement; + slider.value = '3'; + slider.dispatchEvent(new Event('input', { bubbles: true })); + }); + await expect(page.locator('.mirror-segment-control')).toHaveAttribute( + 'title', + '3 thirds' + ); + await expect(page.locator('.mirror-segment-control')).toHaveClass(/active/); +}); + +( + [ + { + expectedCode: 'webgpu-adapter-unavailable', + expectedMessage: + 'WebGPU is available, but this browser could not provide a compatible GPU adapter.', + mode: 'adapter-null', + }, + { + expectedCode: 'webgpu-adapter-unavailable', + expectedMessage: 'Could not request a WebGPU adapter.', + mode: 'adapter-rejects', + }, + { + expectedCode: 'webgpu-device-unavailable', + expectedMessage: 'Could not create a WebGPU device for this adapter.', + mode: 'device-rejects', + }, + ] satisfies Array<{ + expectedCode: string; + expectedMessage: string; + mode: WebGpuFailureMode; + }> +).forEach(({ expectedCode, expectedMessage, mode }) => { + test(`reports ${mode} startup failures without leaving the shell loading`, async ({ + page, + }) => { + await emulateWebGpuFailure(page, mode); + + await page.goto('/'); + + await expect(page.locator('body')).not.toHaveClass(/is-loading/); + await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible(); + await expect(page.getByRole('alert')).toContainText(expectedMessage); + await expect(page.getByRole('alert')).toContainText(expectedCode); + }); +}); + +test('serves the production bundle without missing browser assets', async ({ page }) => { + const browserFailures: Array = []; + + page.on('requestfailed', (request) => { + const failure = request.failure(); + browserFailures.push(`${request.method()} ${request.url()} ${failure?.errorText}`); + }); + page.on('response', (response) => { + if (response.status() >= 400) { + browserFailures.push(`${response.status()} ${response.url()}`); + } + }); + + await disableWebGpu(page); + await page.goto('/'); + await expect(page.locator('body')).not.toHaveClass(/is-loading/); + + expect(browserFailures).toEqual([]); +}); + +[ + { height: 720, name: 'desktop', width: 1280 }, + { height: 844, name: 'mobile', width: 390 }, +].forEach(({ height, name, width }) => { + test(`keeps the fallback shell usable on ${name}`, async ({ page }) => { + await page.setViewportSize({ height, width }); + await disableWebGpu(page); + + await page.goto('/'); + await expect(page.locator('body')).not.toHaveClass(/is-loading/); + + const canvasBox = await page + .getByRole('img', { name: 'Interactive generative garden canvas' }) + .boundingBox(); + expect(canvasBox?.width).toBeGreaterThan(0); + expect(canvasBox?.height).toBeGreaterThan(0); + await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'About' })).toBeVisible(); + await expect(page.getByRole('alert')).toContainText('Fleeting Garden needs WebGPU'); + + const aboutButtonReceivesPointer = await page + .getByRole('button', { name: 'About' }) + .evaluate((button) => { + const rect = button.getBoundingClientRect(); + const target = document.elementFromPoint( + rect.left + rect.width / 2, + rect.top + rect.height / 2 + ); + + return button === target || button.contains(target); + }); + + expect(aboutButtonReceivesPointer).toBe(true); + }); +}); + +test('hides the bottom dock after the cursor leaves fullscreen controls', async ({ + page, +}) => { + await disableWebGpu(page); + + await page.goto('/'); + await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible(); + + await page.getByRole('button', { name: 'Enter fullscreen' }).click(); + await expect + .poll(() => page.evaluate(() => Boolean(document.fullscreenElement))) + .toBe(true); + + await page.mouse.move(640, 120); + await page.evaluate(() => { + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + }); + + await expect(page.locator('aside.control-dock')).toHaveClass(/menu-hidden/, { + timeout: 6000, + }); + await expect(page.locator('.garden-controls')).not.toBeVisible(); + await expect + .poll(() => + page + .locator('aside.control-dock') + .evaluate((dock) => dock.getBoundingClientRect().top >= window.innerHeight) + ) + .toBe(true); + + await page.mouse.move(640, 700); + await expect(page.locator('aside.control-dock')).not.toHaveClass(/menu-hidden/); + await expect(page.locator('.garden-controls')).toBeVisible(); + await expect + .poll(() => + page + .locator('aside.control-dock') + .evaluate((dock) => dock.getBoundingClientRect().bottom <= window.innerHeight) + ) + .toBe(true); +}); + +test('keeps the bottom dock visible in mobile fullscreen', async ({ page }) => { + await page.setViewportSize({ height: 844, width: 390 }); + await disableWebGpu(page); + + await page.goto('/'); + await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible(); + + await page.getByRole('button', { name: 'Enter fullscreen' }).click(); + await expect + .poll(() => page.evaluate(() => Boolean(document.fullscreenElement))) + .toBe(true); + + await page.mouse.move(195, 120); + await page.evaluate(() => { + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + }); + await page.waitForTimeout(5200); + + await expect(page.locator('aside.control-dock')).not.toHaveClass(/menu-hidden/); + await expect(page.getByRole('button', { name: 'Show config overlay' })).toBeVisible(); +}); diff --git a/public/apple-touch-icon-180x180.png b/public/apple-touch-icon-180x180.png index 78ea11e..257c79a 100644 Binary files a/public/apple-touch-icon-180x180.png and b/public/apple-touch-icon-180x180.png differ diff --git a/public/favicon.ico b/public/favicon.ico index 5307896..d26cebe 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/favicon.svg b/public/favicon.svg index 1c0ddbe..c0ad1e9 100644 --- a/public/favicon.svg +++ b/public/favicon.svg @@ -7,7 +7,7 @@ - + - - - - + + + + diff --git a/public/maskable-icon-512x512.png b/public/maskable-icon-512x512.png index a7224f9..ea4b3c2 100644 Binary files a/public/maskable-icon-512x512.png and b/public/maskable-icon-512x512.png differ diff --git a/public/pwa-192x192.png b/public/pwa-192x192.png index 22bb33e..e05b2d3 100644 Binary files a/public/pwa-192x192.png and b/public/pwa-192x192.png differ diff --git a/public/pwa-512x512.png b/public/pwa-512x512.png index 0809d42..c171454 100644 Binary files a/public/pwa-512x512.png and b/public/pwa-512x512.png differ diff --git a/public/pwa-64x64.png b/public/pwa-64x64.png index 7a20069..cfe8667 100644 Binary files a/public/pwa-64x64.png and b/public/pwa-64x64.png differ diff --git a/scripts/check-unused-exports.mjs b/scripts/check-unused-exports.mjs index 6114bba..9eeeddd 100644 --- a/scripts/check-unused-exports.mjs +++ b/scripts/check-unused-exports.mjs @@ -33,16 +33,23 @@ const resolveModule = (fromFile, specifier) => { base.endsWith('.ts') ? base : null, ].filter(Boolean); - return candidates.find((candidate) => existsSync(candidate) && fileSet.has(candidate)) ?? null; + return ( + candidates.find((candidate) => existsSync(candidate) && fileSet.has(candidate)) ?? + null + ); }; const exportKey = (file, name) => `${path.resolve(file)}:${name}`; const isExported = (node) => ts.canHaveModifiers(node) && - (ts.getModifiers(node) ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword); + (ts.getModifiers(node) ?? []).some( + (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword + ); const isDefaultExported = (node) => ts.canHaveModifiers(node) && - (ts.getModifiers(node) ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword); + (ts.getModifiers(node) ?? []).some( + (modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword + ); const exportedDeclarations = new Map(); const usedExports = new Set(); @@ -167,10 +174,15 @@ const parsedFiles = files.map((file) => ({ })); parsedFiles.forEach(({ file, sourceFile }) => collectImportUsage(file, sourceFile)); -parsedFiles.forEach(({ file, sourceFile }) => collectExportedDeclarations(file, sourceFile)); +parsedFiles.forEach(({ file, sourceFile }) => + collectExportedDeclarations(file, sourceFile) +); const unusedExports = Array.from(exportedDeclarations.entries()) - .filter(([key, declaration]) => !usedExports.has(key) && !wildcardUsedFiles.has(declaration.file)) + .filter( + ([key, declaration]) => + !usedExports.has(key) && !wildcardUsedFiles.has(declaration.file) + ) .map(([, declaration]) => declaration) .sort((left, right) => `${left.file}:${left.name}`.localeCompare(`${right.file}:${right.name}`) diff --git a/src/audio/garden-audio-energy.test.ts b/src/audio/garden-audio-energy.test.ts index b82c074..39f8051 100644 --- a/src/audio/garden-audio-energy.test.ts +++ b/src/audio/garden-audio-energy.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from 'vitest'; +import { appConfig } from '../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(); + const energy = new GardenAudioEnergy(appConfig.audioEngine); energy.beginGesture(0); energy.recordStroke(0.8, 0.1); @@ -24,7 +25,7 @@ describe('GardenAudioEnergy', () => { }); it('uses recent stroke intensity rather than gesture duration alone', () => { - const energy = new GardenAudioEnergy(); + const energy = new GardenAudioEnergy(appConfig.audioEngine); energy.beginGesture(0); energy.recordStroke(1, 0.1); @@ -38,7 +39,7 @@ describe('GardenAudioEnergy', () => { }); it('raises activity immediately when a stroke is recorded', () => { - const energy = new GardenAudioEnergy(); + const energy = new GardenAudioEnergy(appConfig.audioEngine); energy.beginGesture(0); energy.recordStroke(0.12, 0.05); diff --git a/src/audio/garden-audio-gesture-state.ts b/src/audio/garden-audio-gesture-state.ts new file mode 100644 index 0000000..ce5aa4f --- /dev/null +++ b/src/audio/garden-audio-gesture-state.ts @@ -0,0 +1,385 @@ +import type { GardenAudioEngineConfig } from '../config'; +import { clamp, clamp01 } from '../utils/clamp'; +import type { + GardenAudioColorIndex, + GardenAudioStroke, + GardenAudioTouchDown, +} from './garden-audio-types'; +import type { GardenAudioStrokeMetrics } from './garden-audio-input'; + +type GardenAudioGestureMode = 'calm' | 'active' | 'manic' | 'afterglow'; + +interface GardenAudioGestureFrame { + mode: GardenAudioGestureMode; + activity: number; + maniaAmount: number; + panBias: number; + registerBias: number; + brightnessBias: number; + contour: number; + pressure: number; + pressureDelta: number; + mirrorAmount: number; + speedAmount: number; +} + +interface GestureSample { + at: number; + speed: number; + acceleration: number; + distancePixels: number; + turned: boolean; +} + +const WINDOW_SECONDS = 0.75; +const BIN_SECONDS = 0.05; +const MIN_TURN_DEGREES = 55; +const MIN_TURN_DISTANCE_PIXELS = 6; + +const DEFAULT_FRAME: GardenAudioGestureFrame = { + mode: 'calm', + activity: 0, + maniaAmount: 0, + panBias: 0, + registerBias: 0, + brightnessBias: 0, + contour: 0, + pressure: 0, + pressureDelta: 0, + mirrorAmount: 0, + speedAmount: 0, +}; + +export class GardenAudioGestureState { + private readonly samples: Array = []; + private gestureClockSeconds = 0; + private isGestureActive = false; + private previousPressure = 0; + private previousVelocityPixelsPerSecond = 0; + private previousVector: [number, number] | null = null; + private maniaAmount = 0; + private peakActivity = 0; + private lastFrame: GardenAudioGestureFrame = DEFAULT_FRAME; + + public constructor( + private readonly speedForFullEnergyPixelsPerSecond: number, + private readonly inputConfig: GardenAudioEngineConfig['input'] + ) {} + + public beginGesture(): void { + this.samples.length = 0; + this.gestureClockSeconds = 0; + this.isGestureActive = true; + this.previousPressure = 0; + this.previousVelocityPixelsPerSecond = 0; + this.previousVector = null; + this.maniaAmount = 0; + this.peakActivity = 0; + this.lastFrame = DEFAULT_FRAME; + } + + public endGesture(): GardenAudioGestureFrame { + this.isGestureActive = false; + this.samples.length = 0; + this.previousVector = null; + this.previousVelocityPixelsPerSecond = 0; + this.maniaAmount = 0; + this.lastFrame = { + ...this.lastFrame, + mode: this.peakActivity >= 0.42 ? 'afterglow' : 'calm', + activity: 0, + maniaAmount: 0, + speedAmount: 0, + }; + return this.lastFrame; + } + + public recordTouchDown({ + touch, + colorIndex, + mirrorAmount, + pressure, + strength, + }: { + touch: GardenAudioTouchDown; + colorIndex: GardenAudioColorIndex; + mirrorAmount: number; + pressure: number; + strength: number; + }): GardenAudioGestureFrame { + const spatial = getSpatialBias(touch.position, touch.canvasSize); + const normalizedStrength = clamp01(strength); + + this.previousPressure = pressure; + this.peakActivity = Math.max(this.peakActivity, normalizedStrength); + this.lastFrame = { + mode: normalizedStrength >= 0.38 ? 'active' : 'calm', + activity: normalizedStrength, + maniaAmount: 0, + panBias: spatial.panBias, + registerBias: spatial.registerBias, + brightnessBias: spatial.brightnessBias, + contour: colorIndex === 2 ? 0.25 : colorIndex === 0 ? -0.15 : 0, + pressure, + pressureDelta: 0, + mirrorAmount, + speedAmount: 0, + }; + + return this.lastFrame; + } + + public recordStroke({ + stroke, + metrics, + mirrorAmount, + }: { + stroke: GardenAudioStroke; + metrics: GardenAudioStrokeMetrics; + mirrorAmount: number; + }): GardenAudioGestureFrame { + const elapsedSeconds = this.getElapsedSeconds(stroke); + this.gestureClockSeconds += elapsedSeconds; + + const dx = stroke.to[0] - stroke.from[0]; + const dy = stroke.to[1] - stroke.from[1]; + const distancePixels = metrics.distancePixels; + const speedRatio = + metrics.speedPixelsPerSecond / + Math.max(1, this.speedForFullEnergyPixelsPerSecond); + const speed = smoothstep(0.45, 1.2, speedRatio); + const acceleration = smoothstep( + 3, + 12, + Math.abs(metrics.speedPixelsPerSecond - this.previousVelocityPixelsPerSecond) / + (Math.max(1, this.speedForFullEnergyPixelsPerSecond) * elapsedSeconds) + ); + const currentVector: [number, number] = + distancePixels > 0.001 ? [dx / distancePixels, dy / distancePixels] : [0, 0]; + const turned = this.getTurned(currentVector, distancePixels, metrics.speedAmount); + const spatial = getSpatialBias(stroke.to, stroke.canvasSize); + const pressureDelta = clamp(metrics.pressure - this.previousPressure, -1, 1); + const contour = distancePixels > 0.001 ? clamp(-dy / distancePixels, -1, 1) : 0; + + if (distancePixels > 0.5) { + this.samples.push({ + at: this.gestureClockSeconds, + speed, + acceleration, + distancePixels, + turned, + }); + } + this.trimSamples(); + + const features = this.getWindowFeatures(); + const distanceFeature = smoothstep(10, 90, metrics.distancePixels); + const normalIntensity = clamp01( + 0.1 + + features.speed * 0.46 + + metrics.pressure * 0.2 + + distanceFeature * 0.16 + + mirrorAmount * 0.08 + ); + const hasKineticChange = features.acceleration > 0.35 || features.turns > 0.35; + const maniaGate = + !stroke.isErasing && + this.isGestureActive && + this.gestureClockSeconds > 0.2 && + features.pathPixels > 60 && + features.speed > 0.45 && + hasKineticChange; + const maniaEvidence = maniaGate + ? clamp01( + features.speed * 0.34 + + features.acceleration * 0.26 + + features.strokeFrequency * 0.2 + + features.turns * 0.2 + ) * + (1 + mirrorAmount * 0.22) + : 0; + const maniaTarget = smoothstep(0.55, 0.85, maniaEvidence); + const timeConstant = maniaTarget > this.maniaAmount ? 0.12 : 0.65; + const maniaMove = 1 - Math.exp(-elapsedSeconds / timeConstant); + + this.maniaAmount += (maniaTarget - this.maniaAmount) * maniaMove; + this.previousPressure = metrics.pressure; + this.previousVelocityPixelsPerSecond = metrics.speedPixelsPerSecond; + this.previousVector = currentVector; + + const activity = clamp01(normalIntensity + this.maniaAmount * 0.28); + this.peakActivity = Math.max(this.peakActivity, activity); + this.lastFrame = { + mode: this.getMode(activity, this.maniaAmount), + activity, + maniaAmount: clamp01(this.maniaAmount), + panBias: spatial.panBias, + registerBias: spatial.registerBias, + brightnessBias: clamp01( + spatial.brightnessBias * 0.65 + metrics.pressure * 0.2 + speed * 0.15 + ), + contour, + pressure: metrics.pressure, + pressureDelta, + mirrorAmount, + speedAmount: metrics.speedAmount, + }; + + return this.lastFrame; + } + + public getFrame(): GardenAudioGestureFrame { + return this.lastFrame; + } + + public reset(): void { + this.samples.length = 0; + this.gestureClockSeconds = 0; + this.isGestureActive = false; + this.previousPressure = 0; + this.previousVelocityPixelsPerSecond = 0; + this.previousVector = null; + this.maniaAmount = 0; + this.peakActivity = 0; + this.lastFrame = DEFAULT_FRAME; + } + + private getElapsedSeconds(stroke: GardenAudioStroke): number { + if ( + stroke.elapsedSeconds !== undefined && + Number.isFinite(stroke.elapsedSeconds) && + stroke.elapsedSeconds > 0 + ) { + return clamp(stroke.elapsedSeconds, 0.001, 0.15); + } + + return this.inputConfig.fallbackFrameSeconds; + } + + private getTurned( + currentVector: [number, number], + distancePixels: number, + speedAmount: number + ): boolean { + if ( + !this.previousVector || + distancePixels <= MIN_TURN_DISTANCE_PIXELS || + speedAmount <= 0.35 + ) { + return false; + } + + const dot = clamp( + this.previousVector[0] * currentVector[0] + + this.previousVector[1] * currentVector[1], + -1, + 1 + ); + const degrees = (Math.acos(dot) * 180) / Math.PI; + return degrees > MIN_TURN_DEGREES; + } + + private trimSamples(): void { + const earliest = this.gestureClockSeconds - WINDOW_SECONDS; + while (this.samples.length > 0 && this.samples[0].at < earliest) { + this.samples.shift(); + } + } + + private getWindowFeatures(): { + speed: number; + acceleration: number; + strokeFrequency: number; + turns: number; + pathPixels: number; + } { + if (this.samples.length === 0) { + return { + speed: 0, + acceleration: 0, + strokeFrequency: 0, + turns: 0, + pathPixels: 0, + }; + } + + const first = this.samples[0]; + const last = this.samples[this.samples.length - 1]; + const spanSeconds = clamp(last.at - first.at, 0.2, WINDOW_SECONDS); + const bins = new Set(); + let pathPixels = 0; + let turnCount = 0; + + this.samples.forEach((sample) => { + if (sample.distancePixels > 1) { + bins.add(Math.floor(sample.at / BIN_SECONDS)); + } + if (sample.turned) { + turnCount += 1; + } + pathPixels += sample.distancePixels; + }); + + return { + speed: percentile(this.samples.map((sample) => sample.speed), 0.75), + acceleration: percentile( + this.samples.map((sample) => sample.acceleration), + 0.75 + ), + strokeFrequency: smoothstep(6, 14, bins.size / spanSeconds), + turns: smoothstep(2, 7, turnCount / spanSeconds), + pathPixels, + }; + } + + private getMode(activity: number, maniaAmount: number): GardenAudioGestureMode { + if (maniaAmount >= 0.72) { + return 'manic'; + } + + return activity >= 0.38 ? 'active' : 'calm'; + } +} + +const getSpatialBias = ( + position: ArrayLike | undefined, + canvasSize: ArrayLike | undefined +): { + panBias: number; + registerBias: number; + brightnessBias: number; +} => { + if (!position || !canvasSize) { + return { + panBias: 0, + registerBias: 0, + brightnessBias: 0.5, + }; + } + + const width = Math.max(1, canvasSize[0]); + const height = Math.max(1, canvasSize[1]); + const x = clamp01(position[0] / width); + const y = clamp01(position[1] / height); + + return { + panBias: clamp(x * 2 - 1, -1, 1), + registerBias: clamp(1 - y * 2, -1, 1), + brightnessBias: clamp01(1 - y * 0.72), + }; +}; + +const percentile = (values: Array, amount: number): number => { + if (values.length === 0) { + return 0; + } + + const sorted = [...values].sort((a, b) => a - b); + const index = clamp(Math.floor((sorted.length - 1) * amount), 0, sorted.length - 1); + return sorted[index]; +}; + +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-input.ts b/src/audio/garden-audio-input.ts index 015e881..b3fa64b 100644 --- a/src/audio/garden-audio-input.ts +++ b/src/audio/garden-audio-input.ts @@ -5,6 +5,7 @@ import { GardenAudioStroke } from './garden-audio-types'; export interface GardenAudioStrokeMetrics { distancePixels: number; pressure: number; + speedPixelsPerSecond: number; speedAmount: number; effectiveEnergy: number; } @@ -35,6 +36,7 @@ export const getStrokeMetrics = ( return { distancePixels, pressure, + speedPixelsPerSecond, speedAmount, effectiveEnergy, }; diff --git a/src/audio/garden-audio-types.ts b/src/audio/garden-audio-types.ts index b6edc29..71eb314 100644 --- a/src/audio/garden-audio-types.ts +++ b/src/audio/garden-audio-types.ts @@ -18,6 +18,7 @@ export interface GardenAudioStroke { isErasing: boolean; pressure?: number; velocityPixelsPerSecond?: number; + elapsedSeconds?: number; eraserSizePixels?: number; mirrorSegmentCount?: number; pointerType?: string; @@ -26,6 +27,8 @@ export interface GardenAudioStroke { export interface GardenAudioTouchDown { vibe: VibePreset; colorIndex: number; + position?: ArrayLike; + canvasSize?: ArrayLike; mirrorSegmentCount?: number; pressure?: number; pointerType?: string; diff --git a/src/audio/garden-audio.test.ts b/src/audio/garden-audio.test.ts index c026afe..6d4f7da 100644 --- a/src/audio/garden-audio.test.ts +++ b/src/audio/garden-audio.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { appConfig } from '../config'; import { VIBE_PRESETS } from '../vibes'; import { GardenAudio } from './garden-audio'; import { gardenAudioConfig, GardenAudioConfig } from './garden-audio-config'; @@ -7,6 +8,7 @@ import { gardenAudioConfig, GardenAudioConfig } from './garden-audio-config'; const calls = { constructed: 0, resumed: 0, + sourcesStarted: 0, }; let contextState: AudioContextState = 'suspended'; @@ -22,13 +24,16 @@ class FakeAudioParam { class FakeAudioNode { public readonly gain = new FakeAudioParam(); public readonly frequency = 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(); } @@ -78,6 +83,10 @@ class FakeAudioContext { return new FakeAudioNode() as unknown as DelayNode; } + 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; } @@ -89,7 +98,9 @@ class FakeAudioContext { stop: () => void; }; node.buffer = null; - node.start = vi.fn(); + node.start = vi.fn(() => { + calls.sourcesStarted += 1; + }); node.stop = vi.fn(); return node; } @@ -108,6 +119,7 @@ describe('GardenAudio startup policy', () => { beforeEach(() => { calls.constructed = 0; calls.resumed = 0; + calls.sourcesStarted = 0; contextState = 'suspended'; vi.stubGlobal('AudioContext', FakeAudioContext); vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('not loaded in tests'))); @@ -118,7 +130,11 @@ describe('GardenAudio startup policy', () => { }); it('does not create an AudioContext from passive audio paths', () => { - const audio = new GardenAudio(makeConfig()); + const audio = new GardenAudio( + makeConfig(), + appConfig.audioEngine, + appConfig.simulation.maxMirrorSegmentCount + ); const vibe = VIBE_PRESETS[0]; audio.start(vibe); @@ -135,7 +151,11 @@ describe('GardenAudio startup policy', () => { }); it('only resumes a suspended context from a user gesture start', () => { - const audio = new GardenAudio(makeConfig()); + const audio = new GardenAudio( + makeConfig(), + appConfig.audioEngine, + appConfig.simulation.maxMirrorSegmentCount + ); const vibe = VIBE_PRESETS[0]; audio.start(vibe, { userGesture: true }); @@ -150,4 +170,51 @@ describe('GardenAudio startup policy', () => { expect(calls.resumed).toBe(1); }); + + it('skips cold piano fallback while preserving eraser noise', () => { + const audio = new GardenAudio( + makeConfig(), + appConfig.audioEngine, + appConfig.simulation.maxMirrorSegmentCount + ); + const vibe = VIBE_PRESETS[0]; + + audio.start(vibe, { userGesture: true }); + expect(calls.sourcesStarted).toBe(1); + + audio.beginGesture(); + audio.touchDown({ + vibe, + colorIndex: 1, + position: [30, 40], + canvasSize: [100, 100], + pressure: 0.7, + }); + audio.stroke({ + vibe, + from: [30, 40], + to: [60, 60], + canvasSize: [100, 100], + colorIndex: 1, + isErasing: false, + pressure: 0.7, + velocityPixelsPerSecond: 1600, + }); + + expect(calls.sourcesStarted).toBe(1); + + audio.stroke({ + vibe, + from: [60, 60], + to: [75, 80], + canvasSize: [100, 100], + colorIndex: 1, + eraserSizePixels: 30, + isErasing: true, + pressure: 0.7, + velocityPixelsPerSecond: 1200, + }); + + expect(calls.sourcesStarted).toBe(2); + }); }); diff --git a/src/audio/garden-audio.ts b/src/audio/garden-audio.ts index 01d5a5d..b736d9c 100644 --- a/src/audio/garden-audio.ts +++ b/src/audio/garden-audio.ts @@ -1,10 +1,11 @@ -import { appConfig } from '../config'; +import type { GardenAudioEngineConfig } from '../config'; import { clamp, clamp01 } from '../utils/clamp'; import { VibePreset } from '../vibes'; import { GardenAudioConfig } from './garden-audio-config'; import { GardenAudioEnergy } from './garden-audio-energy'; +import { GardenAudioGestureState } from './garden-audio-gesture-state'; import { GardenAudioGraph } from './garden-audio-graph'; -import { GardenAudioStrokeMetrics, getStrokeMetrics } from './garden-audio-input'; +import { getStrokeMetrics } from './garden-audio-input'; import { getVibeProfile, normalizeColorIndex } from './garden-audio-music'; import type { GardenAudioColorIndex, @@ -29,6 +30,7 @@ export class GardenAudio { private readonly piano: PianoSampler; private readonly noise: NoiseBurstPlayer; private readonly energy: GardenAudioEnergy; + private readonly gestureState: GardenAudioGestureState; private readonly pianoEngine: GenerativePianoEngine; private currentVibeId: string | null = null; @@ -41,12 +43,22 @@ export class GardenAudio { private lastEraserAt = Number.NEGATIVE_INFINITY; private lastVibeStingerAt = Number.NEGATIVE_INFINITY; - public constructor(private readonly config: GardenAudioConfig) { - this.graph = new GardenAudioGraph(config); - this.piano = new PianoSampler(config, this.graph); - this.noise = new NoiseBurstPlayer(this.graph); - this.energy = new GardenAudioEnergy(); - this.pianoEngine = new GenerativePianoEngine(config, (note) => this.piano.play(note)); + public constructor( + private readonly config: GardenAudioConfig, + private readonly engineConfig: GardenAudioEngineConfig, + private readonly maxMirrorSegmentCount: number + ) { + this.graph = new GardenAudioGraph(config, engineConfig); + this.piano = new PianoSampler(config, engineConfig, this.graph); + this.noise = new NoiseBurstPlayer(engineConfig, this.graph); + this.energy = new GardenAudioEnergy(engineConfig); + this.gestureState = new GardenAudioGestureState( + config.rhythm.speedForFullEnergyPixelsPerSecond, + engineConfig.input + ); + this.pianoEngine = new GenerativePianoEngine(config, engineConfig, (note) => + this.piano.play(note) + ); } public start(vibe: VibePreset, options: GardenAudioStartOptions = {}): void { @@ -76,7 +88,7 @@ export class GardenAudio { this.graph.setMasterGain( this.config.masterVolume, options.userGesture === true - ? appConfig.audioEngine.muteRampSeconds + ? this.engineConfig.muteRampSeconds : this.config.fadeInSeconds ); @@ -110,8 +122,8 @@ export class GardenAudio { public setMuted(isMuted: boolean): void { this.isMuted = isMuted; this.graph.setMasterGain( - isMuted ? appConfig.audioEngine.muteGain : this.config.masterVolume, - isMuted ? appConfig.audioEngine.muteRampSeconds : this.config.fadeInSeconds + isMuted ? this.engineConfig.muteGain : this.config.masterVolume, + isMuted ? this.engineConfig.muteRampSeconds : this.config.fadeInSeconds ); } @@ -122,11 +134,13 @@ export class GardenAudio { } this.isGestureActive = true; + this.gestureState.beginGesture(); this.energy.beginGesture(context.currentTime); this.pianoEngine.beginGesture(); } public endGesture(): void { + this.gestureState.endGesture(); this.isGestureActive = false; this.energy.endGesture(); this.pianoEngine.endGesture(); @@ -146,6 +160,13 @@ export class GardenAudio { const mirrorAmount = this.getMirrorAmount(touch.mirrorSegmentCount ?? 1); const pressure = this.getTouchPressure(touch.pressure, touch.pointerType); const strength = clamp01(0.36 + pressure * 0.34 + mirrorAmount * 0.22); + const frame = this.gestureState.recordTouchDown({ + touch, + colorIndex: this.selectedColorIndex, + mirrorAmount, + pressure, + strength, + }); this.energy.recordStroke(strength, context.currentTime); this.pianoEngine.recordTouchDown({ @@ -154,6 +175,13 @@ export class GardenAudio { strength, selectedColorIndex: this.selectedColorIndex, mirrorAmount, + panBias: frame.panBias, + registerBias: frame.registerBias, + brightnessBias: frame.brightnessBias, + contour: frame.contour, + pressureAmount: frame.pressure, + pressureDelta: frame.pressureDelta, + maniaAmount: frame.maniaAmount, }); } @@ -197,7 +225,8 @@ export class GardenAudio { const metrics = getStrokeMetrics( stroke, this.config.rhythm.speedForFullEnergyPixelsPerSecond, - this.config.input.pressureFallback + this.config.input.pressureFallback, + this.engineConfig.input ); const now = context.currentTime; @@ -210,7 +239,8 @@ export class GardenAudio { } const mirrorAmount = this.getMirrorAmount(stroke.mirrorSegmentCount ?? 1); - const strokeEnergy = this.getStrokeMusicActivity(stroke, metrics, mirrorAmount); + const frame = this.gestureState.recordStroke({ stroke, metrics, mirrorAmount }); + const strokeEnergy = frame.activity; this.energy.recordStroke(strokeEnergy, now); this.pianoEngine.recordStroke({ vibe: stroke.vibe, @@ -218,6 +248,13 @@ export class GardenAudio { activity: strokeEnergy, selectedColorIndex: this.selectedColorIndex, mirrorAmount, + panBias: frame.panBias, + registerBias: frame.registerBias, + brightnessBias: frame.brightnessBias, + contour: frame.contour, + pressureAmount: frame.pressure, + pressureDelta: frame.pressureDelta, + maniaAmount: frame.maniaAmount, }); } @@ -227,6 +264,7 @@ export class GardenAudio { this.piano.reset(); this.energy.reset(); + this.gestureState.reset(); this.pianoEngine.reset(); this.currentVibeId = null; this.hasStarted = false; @@ -246,7 +284,7 @@ export class GardenAudio { const now = context.currentTime; if ( now - this.lastVibeStingerAt < - appConfig.audioEngine.vibeChangeStingerMinIntervalSeconds + this.engineConfig.vibeChangeStingerMinIntervalSeconds ) { return; } @@ -266,10 +304,10 @@ export class GardenAudio { } const sizeAmount = clamp01( - (stroke.eraserSizePixels ?? appConfig.audioEngine.eraser.defaultSizePixels) / + (stroke.eraserSizePixels ?? this.engineConfig.eraser.defaultSizePixels) / Math.max( 1, - stroke.canvasSize[0] * appConfig.audioEngine.eraser.canvasWidthRatioForFullSize + stroke.canvasSize[0] * this.engineConfig.eraser.canvasWidthRatioForFullSize ) ); const x = clamp01(stroke.to[0] / Math.max(1, stroke.canvasSize[0])); @@ -277,22 +315,22 @@ export class GardenAudio { this.config.eraser.filterMinHz + (this.config.eraser.filterMaxHz - this.config.eraser.filterMinHz) * clamp01( - speedAmount * appConfig.audioEngine.eraser.filterSpeedWeight + - pressure * appConfig.audioEngine.eraser.filterPressureWeight + - sizeAmount * appConfig.audioEngine.eraser.filterSizeWeight + speedAmount * this.engineConfig.eraser.filterSpeedWeight + + pressure * this.engineConfig.eraser.filterPressureWeight + + sizeAmount * this.engineConfig.eraser.filterSizeWeight ); if (now - this.lastEraserAt >= this.config.eraser.minIntervalSeconds) { this.lastEraserAt = now; this.noise.play({ startTime: now, - durationSeconds: appConfig.audioEngine.eraser.durationSeconds, + durationSeconds: this.engineConfig.eraser.durationSeconds, gain: this.config.eraser.noiseGain * - (appConfig.audioEngine.eraser.gainBase + - speedAmount * appConfig.audioEngine.eraser.gainSpeedWeight + - pressure * appConfig.audioEngine.eraser.gainPressureWeight + - sizeAmount * appConfig.audioEngine.eraser.gainSizeWeight), + (this.engineConfig.eraser.gainBase + + speedAmount * this.engineConfig.eraser.gainSpeedWeight + + pressure * this.engineConfig.eraser.gainPressureWeight + + sizeAmount * this.engineConfig.eraser.gainSizeWeight), filterHz, pan: clamp(x * 2 - 1, -1, 1), }); @@ -307,7 +345,7 @@ export class GardenAudio { const profile = getVibeProfile(this.config, snapshot.vibe); const activity = snapshot.isErasing - ? appConfig.audioEngine.delay.erasingActivity + ? this.engineConfig.delay.erasingActivity : this.energy.getLevel(); this.graph.updateDelay(profile, activity); } @@ -323,7 +361,7 @@ export class GardenAudio { } private getMirrorAmount(mirrorSegmentCount: number): number { - const maxMirrorSegmentCount = Math.max(1, appConfig.simulation.maxMirrorSegmentCount); + const maxMirrorSegmentCount = Math.max(1, this.maxMirrorSegmentCount); const segmentCount = clamp( Number.isFinite(mirrorSegmentCount) ? mirrorSegmentCount : 1, 1, @@ -337,44 +375,16 @@ export class GardenAudio { return clamp01((segmentCount - 1) / (maxMirrorSegmentCount - 1)); } - private getStrokeMusicActivity( - stroke: GardenAudioStroke, - metrics: GardenAudioStrokeMetrics, - mirrorAmount: number - ): number { - const speedRatio = - (stroke.velocityPixelsPerSecond ?? 0) / - Math.max(1, this.config.rhythm.speedForFullEnergyPixelsPerSecond); - const speedDrive = smoothstep(0.35, 1.1, speedRatio); - const speedOverdrive = smoothstep(1.15, 1.8, speedRatio); - const distanceDrive = smoothstep(10, 90, metrics.distancePixels); - const baseStroke = clamp01( - 0.08 + speedDrive * 0.5 + metrics.pressure * 0.2 + distanceDrive * 0.22 - ); - const mirrorWild = smoothstep(0.45, 0.9, mirrorAmount); - const maniaDrive = speedOverdrive * smoothstep(0.62, 0.82, baseStroke); - const maniaBoost = maniaDrive * (0.18 + mirrorWild * 0.62); - - return clamp01( - baseStroke * (0.68 + mirrorAmount * 0.3) + - 0.025 + - mirrorAmount * 0.045 + - maniaBoost - ); - } - private getTouchPressure(pressure: number | undefined, pointerType?: string): number { if (pressure !== undefined && Number.isFinite(pressure) && pressure > 0) { return clamp01(pressure); } return pointerType === 'pen' - ? Math.max(appConfig.audioEngine.input.penMinPressure, this.config.input.pressureFallback) + ? Math.max( + this.engineConfig.input.penMinPressure, + this.config.input.pressureFallback + ) : this.config.input.pressureFallback; } } - -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/generative-piano.test.ts b/src/audio/generative-piano.test.ts index f75bf8c..90a65ad 100644 --- a/src/audio/generative-piano.test.ts +++ b/src/audio/generative-piano.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; +import { appConfig } from '../config'; import { VIBE_PRESETS } from '../vibes'; import { gardenAudioConfig } from './garden-audio-config'; import { PianoNote } from './garden-audio-types'; @@ -7,9 +8,13 @@ import { GenerativePianoEngine } from './generative-piano'; const makeEngine = () => { const notes: Array = []; - const engine = new GenerativePianoEngine(gardenAudioConfig, (note) => { - notes.push(note); - }); + const engine = new GenerativePianoEngine( + gardenAudioConfig, + appConfig.audioEngine, + (note) => { + notes.push(note); + } + ); return { engine, notes }; }; @@ -17,7 +22,9 @@ const makeEngine = () => { const getBeatSeconds = (): number => 60 / gardenAudioConfig.rhythm.bpm; const getBeatsPerBar = (): number => - Math.round(gardenAudioConfig.rhythm.stepsPerBar / gardenAudioConfig.rhythm.stepsPerBeat); + Math.round( + gardenAudioConfig.rhythm.stepsPerBar / gardenAudioConfig.rhythm.stepsPerBeat + ); const renderBars = ( engine: GenerativePianoEngine, @@ -45,9 +52,8 @@ const countNotesBetween = ( startSeconds: number, endSeconds: number ): number => - notes.filter( - (note) => note.startTime >= startSeconds && note.startTime < endSeconds - ).length; + notes.filter((note) => note.startTime >= startSeconds && note.startTime < endSeconds) + .length; describe('GenerativePianoEngine', () => { it('plays quiet background music even when the garden is idle', () => { @@ -56,10 +62,8 @@ describe('GenerativePianoEngine', () => { renderBars(engine, 0); expect(notes.length).toBeGreaterThan(0); - expect(notes.some((note) => note.durationSeconds > getBeatSeconds() * 12)).toBe( - true - ); - expect(Math.max(...notes.map((note) => note.velocity))).toBeLessThan(0.16); + 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', () => { diff --git a/src/audio/generative-piano.ts b/src/audio/generative-piano.ts index 503f030..2e3ce1e 100644 --- a/src/audio/generative-piano.ts +++ b/src/audio/generative-piano.ts @@ -1,4 +1,4 @@ -import { appConfig } from '../config'; +import type { GardenAudioEngineConfig } from '../config'; import { clamp, clamp01 } from '../utils/clamp'; import { VibePreset } from '../vibes'; import { @@ -6,7 +6,11 @@ import { GardenAudioConfig, GardenAudioVibeProfile, } from './garden-audio-config'; -import { degreeToSemitone, getChordIntervals, getVibeProfile } from './garden-audio-music'; +import { + degreeToSemitone, + getChordIntervals, + getVibeProfile, +} from './garden-audio-music'; import { GardenAudioColorIndex, PianoNote } from './garden-audio-types'; interface RenderLookaheadRequest { @@ -23,6 +27,13 @@ interface StrokeAccentRequest { activity: number; selectedColorIndex: GardenAudioColorIndex; mirrorAmount?: number; + panBias?: number; + registerBias?: number; + brightnessBias?: number; + contour?: number; + pressureAmount?: number; + pressureDelta?: number; + maniaAmount?: number; } interface TouchDownRequest { @@ -31,6 +42,13 @@ interface TouchDownRequest { strength: number; selectedColorIndex: GardenAudioColorIndex; mirrorAmount?: number; + panBias?: number; + registerBias?: number; + brightnessBias?: number; + contour?: number; + pressureAmount?: number; + pressureDelta?: number; + maniaAmount?: number; } interface Register { @@ -61,6 +79,14 @@ interface BrushPhraseLayer { selectedColorIndex: GardenAudioColorIndex; energy: number; mirrorAmount: number; + motifOffsets: Array; + panBias: number; + registerBias: number; + brightnessBias: number; + contour: number; + pressureAmount: number; + pressureDelta: number; + maniaAmount: number; } const COLOR_POOLS: [ColorPool, ColorPool, ColorPool] = [ @@ -134,6 +160,9 @@ const BRUSH_STREAM_IDLE_INTERVAL_BEATS = 2; const BRUSH_STREAM_ACTIVE_INTERVAL_BEATS = 1; const BRUSH_STREAM_INTENSE_INTERVAL_BEATS = 0.5; const BRUSH_STREAM_MANIC_INTERVAL_BEATS = 0.25; +const BRUSH_MOTIF_MAX_STEPS = 8; +const BRUSH_MOTIF_CANON_DELAY_SECONDS = 0.055; +const PAD_DURATION_BAR_SCALE = 0.46; export class GenerativePianoEngine { private nextBeatAt: number | null = null; @@ -154,22 +183,23 @@ export class GenerativePianoEngine { public constructor( private readonly config: GardenAudioConfig, + private readonly engineConfig: GardenAudioEngineConfig, private readonly playNote: (note: PianoNote) => void ) {} public prime(now: number): void { if (this.nextBeatAt === null) { - this.nextBeatAt = now + appConfig.audioEngine.startDelaySeconds; + this.nextBeatAt = now + this.engineConfig.startDelaySeconds; } this.timelineStartedAt ??= now; - this.nextBrushStreamAt ??= now + appConfig.audioEngine.startDelaySeconds; + this.nextBrushStreamAt ??= now + this.engineConfig.startDelaySeconds; } public cue(now: number): void { - this.nextBeatAt = now + appConfig.audioEngine.startDelaySeconds; + this.nextBeatAt = now + this.engineConfig.startDelaySeconds; this.timelineStartedAt = now; this.beatIndex = 0; - this.nextBrushStreamAt = now + appConfig.audioEngine.startDelaySeconds; + this.nextBrushStreamAt = now + this.engineConfig.startDelaySeconds; this.brushStreamNoteIndex = 0; this.lastBrushStreamMidi = null; } @@ -188,9 +218,25 @@ export class GenerativePianoEngine { strength, selectedColorIndex, mirrorAmount = 0, + panBias = 0, + registerBias = 0, + brightnessBias = 0.5, + contour = 0, + pressureAmount = 0, + pressureDelta = 0, + maniaAmount = 0, }: TouchDownRequest): void { const normalizedStrength = clamp01(strength); const normalizedMirrorAmount = clamp01(mirrorAmount); + const normalizedMotif = this.normalizeMotif({ + panBias, + registerBias, + brightnessBias, + contour, + pressureAmount, + pressureDelta, + maniaAmount, + }); this.isWaitingForGestureAccent = false; this.lastGestureAccentAt = now; @@ -201,8 +247,17 @@ export class GenerativePianoEngine { strength: normalizedStrength, selectedColorIndex, mirrorAmount: normalizedMirrorAmount, + ...normalizedMotif, + }); + this.playTouchNote({ + vibe, + now, + selectedColorIndex, + strength: normalizedStrength, + panBias: normalizedMotif.panBias, + registerBias: normalizedMotif.registerBias, + brightnessBias: normalizedMotif.brightnessBias, }); - this.playTouchNote(vibe, now, selectedColorIndex, normalizedStrength); } public recordStroke({ @@ -211,9 +266,25 @@ export class GenerativePianoEngine { activity, selectedColorIndex, mirrorAmount = 0, + panBias = 0, + registerBias = 0, + brightnessBias = 0.5, + contour = 0, + pressureAmount = 0, + pressureDelta = 0, + maniaAmount = 0, }: StrokeAccentRequest): void { const strength = clamp01(activity); const normalizedMirrorAmount = clamp01(mirrorAmount); + const normalizedMotif = this.normalizeMotif({ + panBias, + registerBias, + brightnessBias, + contour, + pressureAmount, + pressureDelta, + maniaAmount, + }); if ( this.isWaitingForGestureAccent && @@ -225,11 +296,19 @@ export class GenerativePianoEngine { strength, selectedColorIndex, mirrorAmount: normalizedMirrorAmount, + ...normalizedMotif, }); return; } this.isWaitingForGestureAccent = false; + this.updateBrushPhraseLayer({ + now, + strength, + selectedColorIndex, + mirrorAmount: normalizedMirrorAmount, + ...normalizedMotif, + }); if ( strength >= STROKE_ACCENT_THRESHOLD && now - this.lastStrokeAccentAt >= STROKE_ACCENT_MIN_INTERVAL_SECONDS @@ -385,22 +464,22 @@ export class GenerativePianoEngine { const chord = this.getChord(profile, barIndex); const intervals = getChordIntervals(chord, true); const rootMidi = profile.rootMidi + chord.rootOffset; - const durationSeconds = this.getBarDurationSeconds() * CHORD_BARS * 0.88; + const durationSeconds = this.getBarDurationSeconds() * CHORD_BARS * PAD_DURATION_BAR_SCALE; const notes = [ { source: { baseMidi: rootMidi, offsets: [0] }, register: PAD_REGISTERS[0], - velocity: 0.082, + velocity: 0.052, }, { source: { baseMidi: rootMidi, offsets: [intervals[1]] }, register: PAD_REGISTERS[1], - velocity: 0.064, + velocity: 0.041, }, { source: { baseMidi: rootMidi, offsets: [intervals[3], intervals[2]] }, register: PAD_REGISTERS[2], - velocity: 0.052, + velocity: 0.033, }, ]; @@ -412,8 +491,8 @@ export class GenerativePianoEngine { startTime, durationSeconds, pan: register.pan, - delaySend: 0.018, - lowpassHz: this.getLowpassHz(profile, midi, expression * 0.45), + delaySend: 0.008, + lowpassHz: this.getLowpassHz(profile, midi, expression * 0.28), }); }); } @@ -519,7 +598,7 @@ export class GenerativePianoEngine { this.config.colorVoices[selectedColorIndex].velocityMultiplier, startTime: now + - appConfig.audioEngine.startDelaySeconds + + this.engineConfig.startDelaySeconds + index * GESTURE_ACCENT_SPACING_SECONDS, durationSeconds: 0.48 + strength * 0.22, pan: this.getColorPan(selectedColorIndex), @@ -529,14 +608,26 @@ export class GenerativePianoEngine { } } - private playTouchNote( - vibe: VibePreset, - now: number, - selectedColorIndex: GardenAudioColorIndex, - strength: number - ): void { + private playTouchNote({ + vibe, + now, + selectedColorIndex, + strength, + panBias, + registerBias, + brightnessBias, + }: { + vibe: VibePreset; + now: number; + selectedColorIndex: GardenAudioColorIndex; + strength: number; + panBias: number; + registerBias: number; + brightnessBias: number; + }): void { const profile = getVibeProfile(this.config, vibe); const pool = COLOR_POOLS[selectedColorIndex]; + const register = this.getBiasedRegister(pool, registerBias, 0); const chord = this.getChord(profile, this.getGlobalBarIndex(now)); const chordIntervals = getChordIntervals(chord, false); const rootMidi = profile.rootMidi + chord.rootOffset; @@ -545,7 +636,7 @@ export class GenerativePianoEngine { baseMidi: rootMidi, offsets: this.getSupportOffsets(chordIntervals, selectedColorIndex), }, - pool, + register, this.lastMidiByColor[selectedColorIndex], true ); @@ -559,9 +650,13 @@ export class GenerativePianoEngine { this.config.colorVoices[selectedColorIndex].velocityMultiplier, startTime: now, durationSeconds: 0.55 + strength * 0.18, - pan: this.getColorPan(selectedColorIndex), + pan: this.getLayerPan(selectedColorIndex, panBias, 0, 0), delaySend: 0.006, - lowpassHz: this.getLowpassHz(profile, midi, clamp01(0.45 + strength * 0.45)), + lowpassHz: this.getLowpassHz( + profile, + midi, + clamp01(0.45 + strength * 0.35 + brightnessBias * 0.2) + ), }); } @@ -571,12 +666,26 @@ export class GenerativePianoEngine { strength, selectedColorIndex, mirrorAmount, + panBias, + registerBias, + brightnessBias, + contour, + pressureAmount, + pressureDelta, + maniaAmount, }: { vibe: VibePreset; now: number; strength: number; selectedColorIndex: GardenAudioColorIndex; mirrorAmount: number; + panBias: number; + registerBias: number; + brightnessBias: number; + contour: number; + pressureAmount: number; + pressureDelta: number; + maniaAmount: number; }): void { const lifetimeSeconds = BRUSH_LAYER_BASE_SECONDS + @@ -590,6 +699,18 @@ export class GenerativePianoEngine { selectedColorIndex, energy: strength, mirrorAmount, + motifOffsets: this.getInitialMotifOffsets({ + selectedColorIndex, + registerBias, + contour, + }), + panBias, + registerBias, + brightnessBias, + contour, + pressureAmount, + pressureDelta, + maniaAmount, }); if (this.brushPhraseLayers.length > MAX_BRUSH_PHRASE_LAYERS) { @@ -597,6 +718,55 @@ export class GenerativePianoEngine { } } + private updateBrushPhraseLayer({ + now, + strength, + selectedColorIndex, + mirrorAmount, + panBias, + registerBias, + brightnessBias, + contour, + pressureAmount, + pressureDelta, + maniaAmount, + }: { + now: number; + strength: number; + selectedColorIndex: GardenAudioColorIndex; + mirrorAmount: number; + panBias: number; + registerBias: number; + brightnessBias: number; + contour: number; + pressureAmount: number; + pressureDelta: number; + maniaAmount: number; + }): void { + const layer = this.brushPhraseLayers[this.brushPhraseLayers.length - 1]; + if (!layer || layer.expiresAt <= now) { + return; + } + + const followAmount = 0.24 + clamp01(strength) * 0.24; + layer.selectedColorIndex = selectedColorIndex; + layer.energy = Math.max(layer.energy * 0.94, strength); + layer.mirrorAmount = Math.max(layer.mirrorAmount * 0.96, mirrorAmount); + layer.panBias = mix(layer.panBias, panBias, followAmount); + layer.registerBias = mix(layer.registerBias, registerBias, followAmount); + layer.brightnessBias = mix(layer.brightnessBias, brightnessBias, followAmount); + layer.contour = mix(layer.contour, contour, followAmount); + layer.pressureAmount = mix(layer.pressureAmount, pressureAmount, followAmount); + layer.pressureDelta = pressureDelta; + layer.maniaAmount = Math.max(layer.maniaAmount * 0.92, maniaAmount); + layer.motifOffsets.push( + this.getMotifOffset({ registerBias, contour, pressureDelta, strength }) + ); + if (layer.motifOffsets.length > BRUSH_MOTIF_MAX_STEPS) { + layer.motifOffsets = layer.motifOffsets.slice(-BRUSH_MOTIF_MAX_STEPS); + } + } + private renderBrushPhraseLayers({ vibe, now, @@ -610,8 +780,8 @@ export class GenerativePianoEngine { activity: number; selectedColorIndex: GardenAudioColorIndex; }): void { - const earliestStart = now + appConfig.audioEngine.piano.scheduleAheadSeconds; - this.nextBrushStreamAt ??= now + appConfig.audioEngine.startDelaySeconds; + const earliestStart = now + this.engineConfig.piano.scheduleAheadSeconds; + this.nextBrushStreamAt ??= now + this.engineConfig.startDelaySeconds; this.brushPhraseLayers = this.brushPhraseLayers.filter( (layer) => layer.expiresAt > earliestStart @@ -631,6 +801,7 @@ export class GenerativePianoEngine { startTime: this.nextBrushStreamAt, intensity: frame.intensity, selectedColorIndex: frame.selectedColorIndex ?? selectedColorIndex, + layer: frame.layer, }); } this.nextBrushStreamAt += this.getBrushStreamIntervalSeconds(frame.intensity); @@ -643,14 +814,22 @@ export class GenerativePianoEngine { startTime, intensity, selectedColorIndex, + layer, }: { vibe: VibePreset; startTime: number; intensity: number; selectedColorIndex: GardenAudioColorIndex; + layer: BrushPhraseLayer | null; }): void { const profile = getVibeProfile(this.config, vibe); const pool = COLOR_POOLS[selectedColorIndex]; + const maniaAmount = layer?.maniaAmount ?? clamp01((intensity - 0.82) / 0.18); + const register = this.getBiasedRegister( + pool, + layer?.registerBias ?? 0, + maniaAmount * 0.45 + ); const chord = this.getChord(profile, this.getGlobalBarIndex(startTime)); const chordIntervals = getChordIntervals(chord, false); const rootMidi = profile.rootMidi + chord.rootOffset; @@ -662,12 +841,29 @@ export class GenerativePianoEngine { } : { baseMidi: profile.rootMidi, - offsets: this.rotate( - pool.scaleDegrees, - this.brushStreamNoteIndex + selectedColorIndex - ).map((degree) => degreeToSemitone(profile, degree)), + offsets: this.getBrushMotifDegrees({ + layer, + pool, + selectedColorIndex, + }).map((degree) => degreeToSemitone(profile, degree)), }; - const midi = this.chooseMidi(source, pool, this.lastBrushStreamMidi, true); + const midi = this.chooseMidi(source, register, this.lastBrushStreamMidi, true); + const pan = this.getLayerPan( + selectedColorIndex, + layer?.panBias ?? 0, + maniaAmount, + layer?.mirrorAmount ?? 0 + ); + const durationSeconds = clamp( + 0.48 + intensity * 0.08 - maniaAmount * 0.34, + 0.14, + 0.62 + ); + const delaySend = clamp( + 0.012 + intensity * 0.011 + (layer?.mirrorAmount ?? 0) * 0.004 - maniaAmount * 0.006, + 0.006, + 0.032 + ); this.lastBrushStreamMidi = midi; this.lastMidiByColor[selectedColorIndex] = midi; @@ -677,11 +873,38 @@ export class GenerativePianoEngine { (0.1 + intensity * 0.13) * this.config.colorVoices[selectedColorIndex].velocityMultiplier, startTime, - durationSeconds: 0.42 + intensity * 0.22, - pan: this.getColorPan(selectedColorIndex), - delaySend: 0.012 + intensity * 0.01, - lowpassHz: this.getLowpassHz(profile, midi, clamp01(0.35 + intensity * 0.65)), + durationSeconds, + pan, + delaySend, + lowpassHz: this.getLowpassHz( + profile, + midi, + clamp01( + 0.32 + + intensity * 0.48 + + (layer?.brightnessBias ?? 0.5) * 0.14 + + maniaAmount * 0.18 + ) + ), }); + + if (maniaAmount >= 0.62 && (this.brushStreamNoteIndex % 2 === 1 || intensity >= 0.9)) { + const echoMidi = midi + 12 <= 88 ? midi + 12 : midi - 12; + this.playNote({ + midi: echoMidi, + velocity: + (0.045 + intensity * 0.05) * + this.config.colorVoices[selectedColorIndex].velocityMultiplier, + startTime: + startTime + + BRUSH_MOTIF_CANON_DELAY_SECONDS + + (layer?.mirrorAmount ?? 0) * 0.04, + durationSeconds: Math.max(0.11, durationSeconds * 0.68), + pan: clamp(-pan * 0.75, -1, 1), + delaySend: Math.max(0.006, delaySend * 0.72), + lowpassHz: this.getLowpassHz(profile, echoMidi, 0.62 + maniaAmount * 0.24), + }); + } } private getBrushStreamFrame( @@ -690,17 +913,19 @@ export class GenerativePianoEngine { ): { intensity: number; selectedColorIndex: GardenAudioColorIndex | null; + layer: BrushPhraseLayer | null; } { const layerStates = this.brushPhraseLayers.map((layer) => ({ layer, intensity: layer.energy * this.getBrushPhraseFade(layer, startTime) * - (0.8 + layer.mirrorAmount * 0.45), + (0.8 + layer.mirrorAmount * 0.45 + layer.maniaAmount * 0.42), })); - const dominant = layerStates.reduce< - { layer: BrushPhraseLayer; intensity: number } | null - >((best, state) => { + const dominant = layerStates.reduce<{ + layer: BrushPhraseLayer; + intensity: number; + } | null>((best, state) => { if (state.intensity <= 0) { return best; } @@ -712,8 +937,11 @@ export class GenerativePianoEngine { ); return { - intensity: clamp01(activity * 0.45 + layeredIntensity), + intensity: clamp01( + activity * 0.42 + layeredIntensity + (dominant?.layer.maniaAmount ?? 0) * 0.18 + ), selectedColorIndex: dominant?.layer.selectedColorIndex ?? null, + layer: dominant?.layer ?? null, }; } @@ -735,6 +963,142 @@ export class GenerativePianoEngine { return clamp01(1 - ageSeconds / Math.max(0.001, lifetimeSeconds)); } + private normalizeMotif({ + panBias, + registerBias, + brightnessBias, + contour, + pressureAmount, + pressureDelta, + maniaAmount, + }: { + panBias: number; + registerBias: number; + brightnessBias: number; + contour: number; + pressureAmount: number; + pressureDelta: number; + maniaAmount: number; + }): { + panBias: number; + registerBias: number; + brightnessBias: number; + contour: number; + pressureAmount: number; + pressureDelta: number; + maniaAmount: number; + } { + return { + panBias: clamp(panBias, -1, 1), + registerBias: clamp(registerBias, -1, 1), + brightnessBias: clamp01(brightnessBias), + contour: clamp(contour, -1, 1), + pressureAmount: clamp01(pressureAmount), + pressureDelta: clamp(pressureDelta, -1, 1), + maniaAmount: clamp01(maniaAmount), + }; + } + + private getInitialMotifOffsets({ + selectedColorIndex, + registerBias, + contour, + }: { + selectedColorIndex: GardenAudioColorIndex; + registerBias: number; + contour: number; + }): Array { + const start = selectedColorIndex - 1 + Math.round(registerBias); + const motion = contour > 0.2 ? 1 : contour < -0.2 ? -1 : 0; + return [start, start + motion, start + motion * 2, start + motion]; + } + + private getMotifOffset({ + registerBias, + contour, + pressureDelta, + strength, + }: { + registerBias: number; + contour: number; + pressureDelta: number; + strength: number; + }): number { + const contourStep = contour > 0.3 ? 1 : contour < -0.3 ? -1 : 0; + const registerStep = Math.round(registerBias * 2); + const pressureStep = pressureDelta > 0.08 ? 1 : pressureDelta < -0.08 ? -1 : 0; + const energyStep = strength >= 0.82 ? 1 : strength >= 0.55 ? 0 : -1; + return clamp(contourStep + registerStep + pressureStep + energyStep, -3, 4); + } + + private getBrushMotifDegrees({ + layer, + pool, + selectedColorIndex, + }: { + layer: BrushPhraseLayer | null; + pool: ColorPool; + selectedColorIndex: GardenAudioColorIndex; + }): Array { + const colorOffset = this.config.colorVoices[selectedColorIndex].scaleDegreeOffset; + if (!layer || layer.motifOffsets.length === 0) { + return this.rotate(pool.scaleDegrees, this.brushStreamNoteIndex + colorOffset); + } + + const motifOffset = + layer.motifOffsets[this.brushStreamNoteIndex % layer.motifOffsets.length]; + const contourOffset = + layer.contour > 0.28 + ? this.brushStreamNoteIndex % 3 + : layer.contour < -0.28 + ? -(this.brushStreamNoteIndex % 3) + : 0; + const pressureLift = layer.pressureAmount > 0.68 ? 1 : 0; + const baseOffset = colorOffset + motifOffset + contourOffset + pressureLift; + + return this.rotate( + pool.scaleDegrees.map((degree) => degree + baseOffset), + this.brushStreamNoteIndex + ); + } + + private getBiasedRegister( + register: Register, + registerBias: number, + maniaAmount: number + ): Register { + const shift = Math.round(registerBias * 7 + maniaAmount * 4); + const midiMin = clamp(register.midiMin + shift, 36, 86); + const midiMax = clamp(register.midiMax + shift, midiMin + 4, 91); + + return { + midiMin, + midiMax, + preferredMidi: clamp(register.preferredMidi + shift, midiMin, midiMax), + pan: register.pan, + }; + } + + private getLayerPan( + selectedColorIndex: GardenAudioColorIndex, + panBias: number, + maniaAmount: number, + mirrorAmount: number + ): number { + const shimmer = + maniaAmount > 0.4 + ? Math.sin(this.brushStreamNoteIndex * Math.PI * 0.5) * mirrorAmount * 0.14 + : 0; + + return clamp( + this.getColorPan(selectedColorIndex) + + panBias * (0.18 + maniaAmount * 0.42) + + shimmer, + -1, + 1 + ); + } + private chooseMidi( pitchSource: PitchSource, register: Register, @@ -822,10 +1186,7 @@ export class GenerativePianoEngine { return [chordIntervals[2], 12, chordIntervals[3], chordIntervals[1] + 12]; } - private getChord( - profile: GardenAudioVibeProfile, - barIndex: number - ): GardenAudioChord { + private getChord(profile: GardenAudioVibeProfile, barIndex: number): GardenAudioChord { const progressionIndex = Math.floor(barIndex / CHORD_BARS) % profile.progression.length; return profile.progression[progressionIndex]; @@ -852,8 +1213,8 @@ export class GenerativePianoEngine { return clamp( this.config.piano.lowpassHz * profile.brightness * (0.58 + expression * 0.32) + midiLift, - appConfig.audioEngine.piano.lowpassMinHz, - appConfig.audioEngine.piano.lowpassMaxHz + this.engineConfig.piano.lowpassMinHz, + this.engineConfig.piano.lowpassMaxHz ); } @@ -862,7 +1223,7 @@ export class GenerativePianoEngine { return; } - const earliestStart = now + appConfig.audioEngine.piano.scheduleAheadSeconds; + const earliestStart = now + this.engineConfig.piano.scheduleAheadSeconds; if (this.nextBeatAt >= earliestStart) { return; } @@ -898,3 +1259,6 @@ export class GenerativePianoEngine { return values.map((_, index) => values[(index + offset) % values.length]); } } + +const mix = (from: number, to: number, amount: number): number => + from + (to - from) * clamp01(amount); diff --git a/src/audio/noise-burst-player.ts b/src/audio/noise-burst-player.ts index 3280978..31b3045 100644 --- a/src/audio/noise-burst-player.ts +++ b/src/audio/noise-burst-player.ts @@ -28,10 +28,7 @@ export class NoiseBurstPlayer { filter.type = 'bandpass'; filter.frequency.setValueAtTime(filterHz, scheduledStart); filter.Q.value = this.engineConfig.noiseBurst.filterQ; - envelope.gain.setValueAtTime( - this.engineConfig.noiseBurst.silentGain, - scheduledStart - ); + envelope.gain.setValueAtTime(this.engineConfig.noiseBurst.silentGain, scheduledStart); envelope.gain.exponentialRampToValueAtTime( Math.max(this.engineConfig.noiseBurst.silentGain, gain), scheduledStart + this.engineConfig.noiseBurst.attackSeconds diff --git a/src/audio/piano-sampler.ts b/src/audio/piano-sampler.ts index d0a678e..fab0e13 100644 --- a/src/audio/piano-sampler.ts +++ b/src/audio/piano-sampler.ts @@ -58,15 +58,6 @@ export class PianoSampler { const sample = this.findNearestSample(midi); if (!sample) { - this.playFallbackPluck({ - midi, - velocity, - startTime, - durationSeconds, - pan, - delaySend, - lowpassHz, - }); return; } @@ -84,7 +75,8 @@ export class PianoSampler { (this.engineConfig.piano.sustainBase + noteVelocity * this.engineConfig.piano.sustainVelocityRange); const sustainAt = - scheduledStart + Math.max(this.engineConfig.piano.minDurationSeconds, durationSeconds); + scheduledStart + + Math.max(this.engineConfig.piano.minDurationSeconds, durationSeconds); const releaseAt = sustainAt + sustainSeconds; const releaseSeconds = this.config.piano.releaseSeconds; const stopAt = releaseAt + releaseSeconds; @@ -108,10 +100,7 @@ export class PianoSampler { source.buffer = sample.buffer; source.playbackRate.setValueAtTime( - Math.pow( - 2, - (midi - sample.midi) / this.engineConfig.piano.pitchSemitonesPerOctave - ), + Math.pow(2, (midi - sample.midi) / this.engineConfig.piano.pitchSemitonesPerOctave), scheduledStart ); filter.type = 'lowpass'; @@ -140,11 +129,7 @@ export class PianoSampler { sustainSeconds * this.engineConfig.piano.sustainBase ) ); - gain.gain.setTargetAtTime( - this.engineConfig.piano.minGain, - releaseAt, - releaseSeconds - ); + gain.gain.setTargetAtTime(this.engineConfig.piano.minGain, releaseAt, releaseSeconds); panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart); source.connect(filter); @@ -196,90 +181,4 @@ export class PianoSampler { private trimActiveVoices(now: number): void { this.activeVoices = this.activeVoices.filter((voice) => voice.stopAt > now); } - - private playFallbackPluck({ - midi, - velocity, - startTime, - durationSeconds, - pan, - delaySend = 0, - lowpassHz = this.config.piano.lowpassHz, - }: PianoNote): void { - const { context, eventBus, delayInput } = this.graph; - if (!context || !eventBus) { - return; - } - - const scheduledStart = Math.max( - context.currentTime + this.engineConfig.piano.scheduleAheadSeconds, - startTime - ); - const oscillator = context.createOscillator(); - const filter = context.createBiquadFilter(); - const gain = context.createGain(); - const panner = context.createStereoPanner(); - let sendGain: GainNode | null = null; - const noteVelocity = clamp01(velocity); - const noteGainValue = Math.max( - this.engineConfig.piano.minGain, - this.config.piano.gain * noteVelocity * 0.42 - ); - const releaseAt = - scheduledStart + Math.max(this.engineConfig.piano.minDurationSeconds, durationSeconds); - const stopAt = releaseAt + this.config.piano.releaseSeconds; - - oscillator.type = 'triangle'; - oscillator.frequency.setValueAtTime( - 440 * Math.pow(2, (midi - 69) / appConfig.audioEngine.piano.pitchSemitonesPerOctave), - scheduledStart - ); - filter.type = 'lowpass'; - filter.frequency.setValueAtTime( - clamp( - lowpassHz * 0.72, - this.engineConfig.piano.lowpassMinHz, - this.engineConfig.piano.lowpassMaxHz - ), - scheduledStart - ); - filter.Q.value = this.engineConfig.piano.filterQ; - gain.gain.setValueAtTime(this.engineConfig.piano.minGain, scheduledStart); - gain.gain.exponentialRampToValueAtTime( - noteGainValue, - scheduledStart + this.engineConfig.piano.gainAttackSeconds - ); - gain.gain.setTargetAtTime( - this.engineConfig.piano.minGain, - releaseAt, - this.config.piano.releaseSeconds - ); - panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart); - - oscillator.connect(filter); - filter.connect(gain); - gain.connect(panner); - panner.connect(eventBus); - - if (delayInput && delaySend > 0) { - sendGain = context.createGain(); - sendGain.gain.value = delaySend * 0.5; - panner.connect(sendGain); - sendGain.connect(delayInput); - } - - oscillator.start(scheduledStart); - oscillator.stop(stopAt + this.engineConfig.piano.tailStopExtraSeconds); - oscillator.addEventListener( - 'ended', - () => { - oscillator.disconnect(); - filter.disconnect(); - gain.disconnect(); - panner.disconnect(); - sendGain?.disconnect(); - }, - { once: true } - ); - } } diff --git a/src/config.ts b/src/config.ts index d45cb61..a31e01a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -169,8 +169,8 @@ export const appConfig = { }, menuHider: { bottomRevealDistancePx: 96, - intervalMs: 50, - timeToLiveMs: 3500, + desktopMediaQuery: '(min-width: 600px) and (hover: hover) and (pointer: fine)', + hideDelayMs: 3000, }, pipelines: { brush: { @@ -194,9 +194,6 @@ export const appConfig = { fpsHeadroom: 0.95, fpsSmoothingNew: 0.06, fpsSmoothingRetain: 0.94, - initialTargetAgentBudget: 20_000, - rampAgentsPerSecond: 20_000, - refreshTargetDecay: 0.995, }, brushEffectFramesPerSecond: 60, globalAgentCap: 10_000_000, diff --git a/src/config/color-interactions.ts b/src/config/color-interactions.ts index b6c870e..cfb5ff8 100644 --- a/src/config/color-interactions.ts +++ b/src/config/color-interactions.ts @@ -1,7 +1,4 @@ -import type { - AgentColorInteractionSettings, - NumberControlConfig, -} from './types'; +import type { AgentColorInteractionSettings, NumberControlConfig } from './types'; const agentInteractionOptions: Record = { Follow: 1, @@ -46,7 +43,8 @@ export const createColorInteractionSettings = ( const random = createSeededRandom(hashString(seedSource)); const values = Object.values(agentInteractionOptions); const randomInteraction = () => - values[Math.floor(random() * values.length)] ?? defaultColorInteractionSettings.color1ToColor2; + values[Math.floor(random() * values.length)] ?? + defaultColorInteractionSettings.color1ToColor2; return { color1ToColor1: 1, diff --git a/src/config/types.ts b/src/config/types.ts index 6557e4f..0bb8989 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -169,8 +169,8 @@ export interface GardenAppConfig { }; menuHider: { bottomRevealDistancePx: number; - intervalMs: number; - timeToLiveMs: number; + desktopMediaQuery: string; + hideDelayMs: number; }; pipelines: { brush: { @@ -197,9 +197,6 @@ export interface GardenAppConfig { fpsHeadroom: number; fpsSmoothingNew: number; fpsSmoothingRetain: number; - initialTargetAgentBudget: number; - rampAgentsPerSecond: number; - refreshTargetDecay: number; }; brushEffectFramesPerSecond: number; globalAgentCap: number; diff --git a/src/game-loop/agent-population.test.ts b/src/game-loop/agent-population.test.ts index 93dbcba..43e5917 100644 --- a/src/game-loop/agent-population.test.ts +++ b/src/game-loop/agent-population.test.ts @@ -32,14 +32,9 @@ const createPopulation = () => { return new AgentPopulation(pipeline); }; -const setPopulationCounts = ( - population: AgentPopulation, - activeCount: number, - targetBudget: number -) => { +const setPopulationActiveCount = (population: AgentPopulation, activeCount: number) => { Object.assign(population as unknown as Record, { activeCount, - targetBudget, }); }; @@ -60,7 +55,7 @@ describe('AgentPopulation adaptive budget', () => { it('expands beyond the 1M start cap only when new agents arrive under healthy FPS', () => { const population = createPopulation(); - setPopulationCounts(population, 1_000_000, 1_000_000); + setPopulationActiveCount(population, 1_000_000); population.growBudget(1 / 60, 60, 60); population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(16, 0)); @@ -74,7 +69,7 @@ describe('AgentPopulation adaptive budget', () => { it('decreases the cap and active count slowly when FPS falls below the threshold', () => { const population = createPopulation(); - setPopulationCounts(population, 1_000_000, 1_000_000); + setPopulationActiveCount(population, 1_000_000); population.growBudget(10, 50, 60); diff --git a/src/game-loop/agent-population.ts b/src/game-loop/agent-population.ts index 8919472..ca47029 100644 --- a/src/game-loop/agent-population.ts +++ b/src/game-loop/agent-population.ts @@ -18,7 +18,6 @@ const ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND = export class AgentPopulation { private activeCount = 0; - private targetBudget = appConfig.simulation.budget.initialTargetAgentBudget; private replacementCursor = 0; private canExpandAdaptiveCap = true; private shouldCompactAfterErase = false; @@ -33,24 +32,16 @@ export class AgentPopulation { return this.activeCount; } - public get targetAgentBudget(): number { - return this.targetBudget; - } - public get maxAgentCount(): number { return this.pipeline.maxAgentCount; } public initializeIntroAgents(canvasSize: vec2): void { settings.agentBudgetMax = this.clampAdaptiveCap(settings.agentBudgetMax); - this.targetBudget = Math.min( - this.pipeline.maxAgentCount, - settings.agentBudgetMax, - INITIAL_AGENT_COUNT - ); + const introAgentCount = Math.min(settings.agentBudgetMax, INITIAL_AGENT_COUNT); this.writeAgentBatch( createIntroTitleAgents({ - count: this.targetBudget, + count: introAgentCount, width: canvasSize[0], height: canvasSize[1], }) @@ -59,11 +50,7 @@ export class AgentPopulation { public onVibeChanged(): void { settings.agentBudgetMax = this.clampAdaptiveCap(settings.agentBudgetMax); - this.targetBudget = Math.min( - this.targetBudget, - settings.agentBudgetMax, - this.pipeline.maxAgentCount - ); + this.trimActiveCountToBudget(); } public growBudget( @@ -72,18 +59,6 @@ export class AgentPopulation { refreshTargetFps: number ): void { this.updateAdaptiveCap(deltaTime, smoothedFps, refreshTargetFps); - - const cap = this.clampAdaptiveCap(settings.agentBudgetMax); - if ( - this.targetBudget < cap && - smoothedFps > refreshTargetFps * appConfig.simulation.budget.fpsHeadroom - ) { - this.targetBudget = Math.min( - cap, - this.targetBudget + - Math.ceil(appConfig.simulation.budget.rampAgentsPerSecond * deltaTime) - ); - } } public resizeAgents(scale: vec2): void { @@ -110,7 +85,6 @@ export class AgentPopulation { this.activeCount = compactedAgentCount; this.replacementCursor = compactedAgentCount === 0 ? 0 : this.replacementCursor % compactedAgentCount; - this.targetBudget = Math.max(this.targetBudget, compactedAgentCount); } finally { this.isCompacting = false; } @@ -157,7 +131,7 @@ export class AgentPopulation { const count = data.length / AGENT_FLOAT_COUNT; this.expandAdaptiveCapForPendingAgents(count); - const available = Math.max(0, this.targetBudget - this.activeCount); + const available = Math.max(0, settings.agentBudgetMax - this.activeCount); const appendCount = Math.min(count, available); if (appendCount > 0) { @@ -196,10 +170,12 @@ export class AgentPopulation { ): void { const previousCap = this.clampAdaptiveCap(settings.agentBudgetMax); this.canExpandAdaptiveCap = + refreshTargetFps <= 0 || smoothedFps >= refreshTargetFps * appConfig.simulation.budget.fpsHeadroom; if (this.canExpandAdaptiveCap) { settings.agentBudgetMax = previousCap; + this.trimActiveCountToBudget(); return; } @@ -209,33 +185,31 @@ export class AgentPopulation { ); const nextCap = this.clampAdaptiveCap(previousCap - decrease); settings.agentBudgetMax = nextCap; - this.targetBudget = Math.min(this.targetBudget, nextCap); - - if (this.activeCount > this.targetBudget) { - this.activeCount = Math.max(this.targetBudget, this.activeCount - decrease); - this.replacementCursor = - this.activeCount === 0 ? 0 : this.replacementCursor % this.activeCount; - } + this.trimActiveCountToBudget(decrease); } private expandAdaptiveCapForPendingAgents(requestedAgentCount: number): void { - const available = Math.max(0, this.targetBudget - this.activeCount); + const available = Math.max(0, settings.agentBudgetMax - this.activeCount); if (requestedAgentCount <= available || !this.canExpandAdaptiveCap) { return; } const currentCap = this.clampAdaptiveCap(settings.agentBudgetMax); - if (this.targetBudget < currentCap) { + const pendingAgentCount = requestedAgentCount - available; + settings.agentBudgetMax = this.clampAdaptiveCap(currentCap + pendingAgentCount); + } + + private trimActiveCountToBudget(maxDecrease = Number.POSITIVE_INFINITY): void { + if (this.activeCount <= settings.agentBudgetMax) { return; } - const pendingAgentCount = requestedAgentCount - available; - const nextCap = this.clampAdaptiveCap(currentCap + pendingAgentCount); - settings.agentBudgetMax = nextCap; - this.targetBudget = Math.max( - this.targetBudget, - Math.min(nextCap, this.activeCount + requestedAgentCount) + this.activeCount = Math.max( + settings.agentBudgetMax, + this.activeCount - Math.max(1, Math.ceil(maxDecrease)) ); + this.replacementCursor = + this.activeCount === 0 ? 0 : this.replacementCursor % this.activeCount; } private clampAdaptiveCap(value: number): number { diff --git a/src/game-loop/frame-performance.test.ts b/src/game-loop/frame-performance.test.ts new file mode 100644 index 0000000..cd74739 --- /dev/null +++ b/src/game-loop/frame-performance.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; + +import { FramePerformance } from './frame-performance'; + +describe('FramePerformance refresh target', () => { + it('uses 60 FPS as the fixed adaptive budget target', () => { + const performance = new FramePerformance(); + + [123, 126, 130, 121, 60, 30].forEach((fps) => performance.update(1 / fps)); + + expect(performance.refreshTargetFps).toBe(60); + }); + + it('keeps latest and smoothed FPS separate from the fixed target', () => { + const performance = new FramePerformance(); + + performance.update(1 / 120); + + expect(performance.latestFps).toBe(120); + expect(performance.smoothedFps).toBeGreaterThan(60); + expect(performance.refreshTargetFps).toBe(60); + }); + + it('snaps the display refresh estimate to a stable screen frequency', () => { + const performance = new FramePerformance(); + + [123, 126, 130, 121, 124, 127, 125, 122].forEach((fps) => + performance.update(1 / fps) + ); + + expect(performance.refreshTargetFps).toBe(60); + expect(performance.displayRefreshFps).toBe(120); + }); + + it('ignores a single startup spike before settling the display refresh estimate', () => { + const performance = new FramePerformance(); + + performance.update(1 / 240); + + expect(performance.displayRefreshFps).toBe(60); + + Array.from({ length: 8 }).forEach(() => performance.update(1 / 120)); + + expect(performance.refreshTargetFps).toBe(60); + expect(performance.displayRefreshFps).toBe(120); + }); +}); diff --git a/src/game-loop/frame-performance.ts b/src/game-loop/frame-performance.ts index 6edb415..ea82d71 100644 --- a/src/game-loop/frame-performance.ts +++ b/src/game-loop/frame-performance.ts @@ -4,18 +4,28 @@ interface TelemetrySnapshot { frameCpuStartedAt: number; encodeCpuMs: number; activeAgentCount: number; - targetAgentBudget: number; + agentBudgetMax: number; canvas: HTMLCanvasElement; devicePixelRatio: number; renderSpeed: number; } +const COMMON_DISPLAY_REFRESH_RATES = [ + 50, 60, 72, 75, 90, 100, 120, 144, 165, 180, 240, +] as const; +const DISPLAY_REFRESH_CONFIRMATION_FRAMES = 8; +const DISPLAY_REFRESH_SNAP_TOLERANCE = 0.15; + export class FramePerformance { public latestFps = 60; public smoothedFps = 60; - public refreshTargetFps = 60; + public displayRefreshFps = 60; + public readonly refreshTargetFps = 60; private lastTelemetryAt = 0; + private hasConfirmedDisplayRefreshFps = false; + private pendingDisplayRefreshFps = 0; + private pendingDisplayRefreshFrameCount = 0; public markCpuStart(): number { return appConfig.telemetry.enabled ? performance.now() : 0; @@ -28,10 +38,7 @@ export class FramePerformance { public update(deltaTime: number): void { const fps = 1 / Math.max(deltaTime, appConfig.deltaTime.minDeltaTimeSeconds); this.latestFps = fps; - this.refreshTargetFps = Math.max( - this.refreshTargetFps * appConfig.simulation.budget.refreshTargetDecay, - fps - ); + this.updateDisplayRefreshEstimate(fps); this.smoothedFps = this.smoothedFps * appConfig.simulation.budget.fpsSmoothingRetain + fps * appConfig.simulation.budget.fpsSmoothingNew; @@ -41,7 +48,7 @@ export class FramePerformance { frameCpuStartedAt, encodeCpuMs, activeAgentCount, - targetAgentBudget, + agentBudgetMax, canvas, devicePixelRatio, renderSpeed, @@ -60,8 +67,9 @@ export class FramePerformance { fps: Math.round(this.latestFps), smoothedFps: Math.round(this.smoothedFps), refreshTargetFps: Math.round(this.refreshTargetFps), + displayRefreshFps: Math.round(this.displayRefreshFps), activeAgentCount, - targetAgentBudget, + agentBudgetMax, canvasWidth: canvas.width, canvasHeight: canvas.height, dpr: devicePixelRatio, @@ -70,4 +78,61 @@ export class FramePerformance { encodeCpuMs, }); } + + private updateDisplayRefreshEstimate(fps: number): void { + const displayRefreshFps = this.snapDisplayRefreshRate(fps); + if (displayRefreshFps === null) { + this.resetPendingDisplayRefreshEstimate(); + return; + } + + if ( + this.hasConfirmedDisplayRefreshFps && + displayRefreshFps < this.displayRefreshFps + ) { + this.resetPendingDisplayRefreshEstimate(); + return; + } + + if (displayRefreshFps !== this.pendingDisplayRefreshFps) { + this.pendingDisplayRefreshFps = displayRefreshFps; + this.pendingDisplayRefreshFrameCount = 1; + } else { + this.pendingDisplayRefreshFrameCount += 1; + } + + if (this.pendingDisplayRefreshFrameCount < DISPLAY_REFRESH_CONFIRMATION_FRAMES) { + return; + } + + this.displayRefreshFps = displayRefreshFps; + this.hasConfirmedDisplayRefreshFps = true; + this.resetPendingDisplayRefreshEstimate(); + } + + private snapDisplayRefreshRate(fps: number): number | null { + if (!Number.isFinite(fps) || fps <= 0) { + return null; + } + + let nearestRefreshRate: number = COMMON_DISPLAY_REFRESH_RATES[0]; + let nearestDifference = Math.abs(fps - nearestRefreshRate); + + COMMON_DISPLAY_REFRESH_RATES.forEach((refreshRate) => { + const difference = Math.abs(fps - refreshRate); + if (difference < nearestDifference) { + nearestRefreshRate = refreshRate; + nearestDifference = difference; + } + }); + + return nearestDifference / nearestRefreshRate <= DISPLAY_REFRESH_SNAP_TOLERANCE + ? nearestRefreshRate + : null; + } + + private resetPendingDisplayRefreshEstimate(): void { + this.pendingDisplayRefreshFps = 0; + this.pendingDisplayRefreshFrameCount = 0; + } } diff --git a/src/game-loop/game-loop.ts b/src/game-loop/game-loop.ts index 01c8448..c919112 100644 --- a/src/game-loop/game-loop.ts +++ b/src/game-loop/game-loop.ts @@ -22,7 +22,11 @@ export default class GameLoop { private static readonly DEV_STATS_INTERVAL_MS = 250; private readonly resources: GameLoopResources; - private readonly audio = new GardenAudio(gardenAudioConfig); + private readonly audio = new GardenAudio( + gardenAudioConfig, + appConfig.audioEngine, + appConfig.simulation.maxMirrorSegmentCount + ); private readonly renderInputs = new RenderInputCache(); private readonly introPrompt: IntroPrompt; private readonly eraserPreview: EraserPreview; @@ -30,12 +34,13 @@ export default class GameLoop { private readonly agentPopulation: AgentPopulation; private readonly export4KRenderer: Export4KRenderer; private readonly framePerformance = new FramePerformance(); - private readonly devStatsElement: HTMLDivElement | null = null; + private readonly devStatsElement: HTMLDivElement | null; private readonly seed = Math.floor(Math.random() * 0xffffffff).toString(16); private readonly resizeListener = this.resize.bind(this); private readonly keydownListener: (event: KeyboardEvent) => void; private lastDevStatsUpdateAt = 0; + private isStatsOverlayPinned = false; private hasFinished = false; private readonly finished = Promise.withResolvers(); @@ -46,9 +51,8 @@ export default class GameLoop { ui: GardenUi ) { this.resize(); - if (import.meta.env.DEV) { - this.devStatsElement = this.createDevStatsElement(); - } + this.devStatsElement = this.createDevStatsElement(); + this.syncDevStatsVisibility(); this.resources = new GameLoopResources(canvas, device, this.canvasSize); this.introPrompt = new IntroPrompt(ui.prompt); this.eraserPreview = new EraserPreview(canvas, ui.eraserPreview); @@ -108,6 +112,17 @@ export default class GameLoop { this.audio.setMuted(isMuted); } + public setStatsOverlayPinned(isPinned: boolean): void { + const wasVisible = this.shouldShowDevStats; + this.isStatsOverlayPinned = isPinned; + this.syncDevStatsVisibility(); + + if (!wasVisible && this.shouldShowDevStats) { + this.lastDevStatsUpdateAt = Number.NEGATIVE_INFINITY; + this.updateDevStats(performance.now()); + } + } + public startAudio(userGesture = false): void { this.audio.start(activeVibe, { userGesture }); } @@ -205,7 +220,7 @@ export default class GameLoop { frameCpuStartedAt, encodeCpuMs, activeAgentCount: this.agentPopulation.activeAgentCount, - targetAgentBudget: this.agentPopulation.targetAgentBudget, + agentBudgetMax: settings.agentBudgetMax, canvas: this.canvas, devicePixelRatio: this.devicePixelRatio, renderSpeed: settings.renderSpeed, @@ -235,22 +250,31 @@ export default class GameLoop { private updateDevStats(time: DOMHighResTimeStamp): void { if ( !this.devStatsElement || + !this.shouldShowDevStats || time - this.lastDevStatsUpdateAt < GameLoop.DEV_STATS_INTERVAL_MS ) { return; } this.lastDevStatsUpdateAt = time; + const displayRefreshFps = Math.round(this.framePerformance.displayRefreshFps); this.devStatsElement.textContent = [ - `FPS ${this.framePerformance.smoothedFps.toFixed(1)} / ${Math.round( - this.framePerformance.refreshTargetFps - )}`, + `FPS ${this.framePerformance.smoothedFps.toFixed(1)} / ${displayRefreshFps}`, `Agents ${this.formatDevStatNumber(this.agentPopulation.activeAgentCount)}`, - `Target ${this.formatDevStatNumber(this.agentPopulation.targetAgentBudget)}`, `Cap ${this.formatDevStatNumber(settings.agentBudgetMax)}`, ].join('\n'); } + private syncDevStatsVisibility(): void { + if (!this.devStatsElement) { + return; + } + + const isVisible = this.shouldShowDevStats; + this.devStatsElement.hidden = !isVisible; + this.devStatsElement.setAttribute('aria-hidden', String(!isVisible)); + } + private formatDevStatNumber(value: number): string { return Math.max(0, Math.round(value)).toLocaleString('en-US'); } @@ -298,4 +322,8 @@ export default class GameLoop { : 1; return Math.min(GameLoop.MAX_MIRROR_SEGMENT_COUNT, Math.max(1, Math.round(count))); } + + private get shouldShowDevStats(): boolean { + return import.meta.env.DEV || this.isStatsOverlayPinned; + } } diff --git a/src/game-loop/pointer-input.test.ts b/src/game-loop/pointer-input.test.ts index 8f99179..dbe7381 100644 --- a/src/game-loop/pointer-input.test.ts +++ b/src/game-loop/pointer-input.test.ts @@ -183,6 +183,14 @@ describe('GardenPointerInput drawing startup', () => { expect(onStartDrawing).toHaveBeenCalledTimes(1); expect(audio.start).toHaveBeenCalledWith(expect.anything(), { userGesture: true }); expect(audio.beginGesture).toHaveBeenCalledTimes(1); + expect(audio.touchDown).toHaveBeenCalledWith( + expect.objectContaining({ + canvasSize: [300, 200], + colorIndex: 0, + position: expect.any(Float32Array), + }) + ); + expect(audio.stroke).not.toHaveBeenCalled(); expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(1); expect(spawnStrokeAgents).toHaveBeenCalledTimes(1); expect(canvas.capturedPointerIds).toEqual([9]); diff --git a/src/game-loop/pointer-input.ts b/src/game-loop/pointer-input.ts index 6ac1c35..f7c873c 100644 --- a/src/game-loop/pointer-input.ts +++ b/src/game-loop/pointer-input.ts @@ -110,11 +110,14 @@ export class GardenPointerInput { return; } + const position = this.getCanvasPointerPosition(event); this.options.audio.start(activeVibe, { userGesture: event.isTrusted }); this.options.audio.beginGesture(); this.options.audio.touchDown({ vibe: activeVibe, colorIndex: settings.selectedColorIndex, + position, + canvasSize: this.options.getCanvasSize(), mirrorSegmentCount: this.options.getMirrorSegmentCount(), pressure: this.getPointerPressure(event), pointerType: event.pointerType, @@ -174,12 +177,8 @@ export class GardenPointerInput { }; private addSwipeAt(event: PointerEvent, options: { emitAudio?: boolean } = {}): void { - const rect = this.canvas.getBoundingClientRect(); const devicePixelRatio = this.options.getDevicePixelRatio(); - const position = vec2.fromValues( - (event.clientX - rect.left) * devicePixelRatio, - (event.clientY - rect.top) * devicePixelRatio - ); + const position = this.getCanvasPointerPosition(event); const previousPosition = this.lastPointerPosition ?? position; const previousTimeMs = this.lastPointerEventTimeMs ?? event.timeStamp; const elapsedSeconds = Math.max( @@ -219,6 +218,7 @@ export class GardenPointerInput { isErasing: this.isErasing, pressure: pressure > 0 ? pressure : this.lastPointerPressure, velocityPixelsPerSecond, + elapsedSeconds, eraserSizePixels: settings.eraserSize * devicePixelRatio, mirrorSegmentCount: this.options.getMirrorSegmentCount(), pointerType: event.pointerType, @@ -228,6 +228,15 @@ export class GardenPointerInput { this.lastPointerEventTimeMs = event.timeStamp; } + private getCanvasPointerPosition(event: PointerEvent): vec2 { + const rect = this.canvas.getBoundingClientRect(); + const devicePixelRatio = this.options.getDevicePixelRatio(); + return vec2.fromValues( + (event.clientX - rect.left) * devicePixelRatio, + (event.clientY - rect.top) * devicePixelRatio + ); + } + private addSmoothedBrushSample(position: vec2): void { const previousSample = this.smoothedStrokePoints[this.smoothedStrokePoints.length - 1]; diff --git a/src/index.ts b/src/index.ts index c4a78a1..c191b60 100644 --- a/src/index.ts +++ b/src/index.ts @@ -68,9 +68,9 @@ const renderRuntimeMessage = ( }; const elements = { - aside: queryRequiredElement('aside', HTMLDivElement), + aside: queryRequiredElement('aside', HTMLElement), infoButton: queryRequiredElement('button.info', HTMLButtonElement), - infoElement: queryRequiredElement('.info-page', HTMLDivElement), + infoElement: queryRequiredElement('.info-page', HTMLElement), minimizeFullScreenButton: queryRequiredElement( 'button.minimize-full-screen', HTMLButtonElement @@ -84,20 +84,14 @@ const elements = { restartButton: queryRequiredElement('button.restart', HTMLButtonElement), canvas: queryRequiredElement('canvas', HTMLCanvasElement), eraserPreview: queryRequiredElement('.eraser-preview', HTMLDivElement), - errorContainer: queryRequiredElement('.errors-container', 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 - ), + 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), @@ -222,6 +216,7 @@ const main = async () => { const configPane = new ConfigPane({ settingsButton: elements.settingsButton, onConfigChange: syncRuntimeUi, + onOpenChange: (isOpen) => game?.setStatsOverlayPinned(isOpen), onRuntimeChange: syncRuntimeUi, onRuntimeReset: () => { resetSettings(); @@ -241,8 +236,7 @@ const main = async () => { () => FullScreenHandler.isInFullScreenMode() && !configPane.isOpen && - !infoPageHandler.isOpen, - { persistentElement: elements.settingsButton } + !infoPageHandler.isOpen ); new FullScreenHandler( elements.minimizeFullScreenButton, @@ -250,13 +244,6 @@ const main = async () => { document.body ); - const fontsReady = document.fonts.ready.catch(() => undefined); - setLoadingStage('Connecting to GPU…', 0.1); - const gpu = await initializeGpu(); - setLoadingStage('Loading fonts…', 0.4); - await fontsReady; - setLoadingStage('Compiling shaders…', 0.7); - elements.restartButton.addEventListener('click', () => game?.destroy()); elements.soundButton.addEventListener('click', (event) => { isAudioMuted = !isAudioMuted; @@ -267,8 +254,6 @@ const main = async () => { } }); - const deltaTimeCalculator = new DeltaTimeCalculator(); - elements.previousVibe.addEventListener('click', (event) => { const current = VIBE_PRESETS.findIndex((vibe) => vibe.id === activeVibe.id); const vibe = @@ -345,6 +330,15 @@ const main = async () => { renderMirrorSegmentUi(); renderAudioUi(game); + const fontsReady = document.fonts.ready.catch(() => undefined); + setLoadingStage('Connecting to GPU…', 0.1); + const gpu = await initializeGpu(); + setLoadingStage('Loading fonts…', 0.4); + await fontsReady; + setLoadingStage('Compiling shaders…', 0.7); + + const deltaTimeCalculator = new DeltaTimeCalculator(); + let isFirstStart = true; while (!shouldStop) { game = new GameLoop(elements.canvas, gpu, deltaTimeCalculator, { @@ -352,6 +346,7 @@ const main = async () => { eraserPreview: elements.eraserPreview, exportStatus: elements.exportStatus, }); + game.setStatsOverlayPinned(configPane.isOpen); renderPaletteUi(game); renderEraserSizeUi(game); renderMirrorSegmentUi(); diff --git a/src/page/config-pane.ts b/src/page/config-pane.ts index bf27c78..730d691 100644 --- a/src/page/config-pane.ts +++ b/src/page/config-pane.ts @@ -38,6 +38,7 @@ const isColorReactionKey = (key: string): key is ColorReactionKey => interface ConfigPaneOptions { onConfigChange: () => void; + onOpenChange?: (isOpen: boolean) => void; onRestart: () => void; onRuntimeChange: () => void; onRuntimeReset: () => void; @@ -90,10 +91,7 @@ const getNumberBindingParams = ( export class ConfigPane { private readonly container: HTMLDivElement; private readonly pane: Pane; - private readonly colorReactionSelects = new Map< - ColorReactionKey, - HTMLSelectElement - >(); + private readonly colorReactionSelects = new Map(); private readonly colorReactionSwatches: Array<{ colorIndex: number; element: HTMLElement; @@ -139,7 +137,7 @@ export class ConfigPane { this.setUpRuntimeTab(tabs.pages[0]); this.setUpConfigTab(tabs.pages[1]); - this.syncButton(); + this.syncOpenState(); } public get isOpen(): boolean { @@ -150,17 +148,17 @@ export class ConfigPane { this.state.activeVibeId = activeVibe.id; this.pane.refresh(); this.syncColorReactionMatrix(); - this.syncButton(); + this.syncOpenState(); } private readonly toggle = () => { this.pane.hidden = !this.pane.hidden; - this.syncButton(); + this.syncOpenState(); }; private setHidden(isHidden: boolean): void { this.pane.hidden = isHidden; - this.syncButton(); + this.syncOpenState(); } private setUpRuntimeTab(container: PaneContainer): void { @@ -428,6 +426,11 @@ export class ConfigPane { : 'Show config overlay'; } + private syncOpenState(): void { + this.syncButton(); + this.options.onOpenChange?.(this.isOpen); + } + public close(): void { this.setHidden(true); } diff --git a/src/page/menu-hider.ts b/src/page/menu-hider.ts index b2ea459..5b26b0e 100644 --- a/src/page/menu-hider.ts +++ b/src/page/menu-hider.ts @@ -1,107 +1,144 @@ import { appConfig } from '../config'; -interface MenuHiderOptions { - persistentElement?: HTMLElement; -} - export class MenuHider { - private static readonly DEFAULT_TIME_TO_LIVE = appConfig.menuHider.timeToLiveMs; - private static readonly INTERVAL = appConfig.menuHider.intervalMs; - private static readonly BOTTOM_REVEAL_DISTANCE = - appConfig.menuHider.bottomRevealDistancePx; - private readonly interactiveElements: Array; - private timeToLive = MenuHider.DEFAULT_TIME_TO_LIVE; + private readonly desktopMediaQuery = window.matchMedia( + appConfig.menuHider.desktopMediaQuery + ); + private hideTimeout: number | undefined; private isHidden = false; + private pointerInside = false; public constructor( private readonly element: HTMLElement, - private readonly shouldBeHidden: () => boolean, - private readonly options: MenuHiderOptions = {} + private readonly shouldBeHidden: () => boolean ) { - this.interactiveElements = Array.from( - element.querySelectorAll( - 'a[href], button, input, select, textarea, [tabindex]' - ) + element.addEventListener('pointerenter', this.onPointerEnter); + element.addEventListener('pointerleave', this.onPointerLeave); + element.addEventListener('focusin', this.onFocusIn); + element.addEventListener('focusout', this.onFocusOut); + window.addEventListener('pointermove', this.onPointerMove, { passive: true }); + document.addEventListener('fullscreenchange', this.onVisibilityContextChange); + this.desktopMediaQuery.addEventListener('change', this.onVisibilityContextChange); + + this.reveal(); + } + + private get canAutoHide(): boolean { + return ( + this.desktopMediaQuery.matches && + this.shouldBeHidden() && + !this.pointerInside && + !this.element.contains(document.activeElement) ); - - if (options.persistentElement) { - element.classList.add('has-persistent-settings'); - } - - setInterval(() => { - this.timeToLive = Math.max(0, this.timeToLive - MenuHider.INTERVAL); - this.updateVisibility(); - }, MenuHider.INTERVAL); - - element.addEventListener('mouseover', this.wakeUp); - element.addEventListener('focusin', this.wakeUp); - element.addEventListener('pointerdown', this.wakeUp); - window.addEventListener('pointermove', this.wakeUpNearViewportBottom, { - passive: true, - }); - window.addEventListener('pointerdown', this.wakeUp, { - capture: true, - passive: true, - }); - window.addEventListener('touchstart', this.wakeUp, { - capture: true, - passive: true, - }); - window.addEventListener('keydown', this.wakeUp, { capture: true }); - window.addEventListener('focusin', this.wakeUp, { capture: true }); - - this.updateVisibility(); } - private readonly wakeUp = () => { - this.timeToLive = MenuHider.DEFAULT_TIME_TO_LIVE; - this.updateVisibility(); + private readonly onPointerEnter = () => { + this.pointerInside = true; + this.reveal(); }; - private readonly wakeUpNearViewportBottom = (event: PointerEvent) => { - const viewportHeight = window.innerHeight || document.documentElement.clientHeight; - const revealStart = viewportHeight - MenuHider.BOTTOM_REVEAL_DISTANCE; - - if (event.clientY >= revealStart) { - this.wakeUp(); - } + private readonly onPointerLeave = () => { + this.pointerInside = false; + this.scheduleHide(); }; - private updateVisibility() { - const focusWithin = this.element.contains(document.activeElement); - const shouldHide = this.timeToLive === 0 && this.shouldBeHidden() && !focusWithin; + private readonly onFocusIn = () => { + this.reveal(); + }; - if (this.isHidden === shouldHide) { + private readonly onFocusOut = () => { + window.setTimeout(() => this.scheduleHide(), 0); + }; + + private readonly onPointerMove = (event: PointerEvent) => { + if (!this.desktopMediaQuery.matches || !this.shouldBeHidden()) { + this.reveal(); return; } - this.isHidden = shouldHide; - this.element.classList.toggle('menu-hidden', shouldHide); - this.syncAccessibility(shouldHide); - } - - private syncAccessibility(shouldHide: boolean): void { - const persistentElement = this.options.persistentElement; - - if (!persistentElement) { - this.element.style.opacity = shouldHide ? '0' : '1'; - this.element.setAttribute('aria-hidden', String(shouldHide)); - this.element.inert = shouldHide; + if (this.isPointerOverDock(event.clientX, event.clientY)) { + this.pointerInside = true; + this.reveal(); return; } - this.element.style.opacity = ''; + this.pointerInside = false; + + if (this.isHidden) { + if (this.isNearViewportBottom(event.clientY)) { + this.reveal(); + this.scheduleHide(); + } + return; + } + + this.scheduleHide(); + }; + + private readonly onVisibilityContextChange = () => { + if (!this.desktopMediaQuery.matches || !this.shouldBeHidden()) { + this.reveal(); + return; + } + + this.scheduleHide(); + }; + + private scheduleHide(): void { + if (!this.canAutoHide) { + this.clearHideTimeout(); + this.reveal(); + return; + } + + if (this.hideTimeout !== undefined) { + return; + } + + this.hideTimeout = window.setTimeout(() => { + this.hideTimeout = undefined; + if (this.canAutoHide) { + this.hide(); + } + }, appConfig.menuHider.hideDelayMs); + } + + private reveal(): void { + this.clearHideTimeout(); + this.isHidden = false; + this.element.classList.remove('menu-hidden'); this.element.setAttribute('aria-hidden', 'false'); this.element.inert = false; + } - this.interactiveElements.forEach((interactiveElement) => { - const isPersistentElement = interactiveElement === persistentElement; + private hide(): void { + this.isHidden = true; + this.element.classList.add('menu-hidden'); + this.element.setAttribute('aria-hidden', 'true'); + this.element.inert = true; + } - interactiveElement.inert = shouldHide && !isPersistentElement; - interactiveElement.toggleAttribute( - 'aria-hidden', - shouldHide && !isPersistentElement - ); - }); + private clearHideTimeout(): void { + if (this.hideTimeout === undefined) { + return; + } + + window.clearTimeout(this.hideTimeout); + this.hideTimeout = undefined; + } + + private isPointerOverDock(clientX: number, clientY: number): boolean { + const rect = this.element.getBoundingClientRect(); + return ( + clientX >= rect.left && + clientX <= rect.right && + clientY >= rect.top && + clientY <= rect.bottom + ); + } + + private isNearViewportBottom(clientY: number): boolean { + const viewportHeight = window.innerHeight || document.documentElement.clientHeight; + return clientY >= viewportHeight - appConfig.menuHider.bottomRevealDistancePx; } } diff --git a/src/pipelines/agents/agent-pipeline.ts b/src/pipelines/agents/agent-pipeline.ts index 4e1515f..843fe83 100644 --- a/src/pipelines/agents/agent-pipeline.ts +++ b/src/pipelines/agents/agent-pipeline.ts @@ -106,6 +106,10 @@ export class AgentPipeline { trailMapOut: GPUTextureView, sourceMap: GPUTextureView ) { + if (this.agentCount <= 0) { + return; + } + const bindGroup = this.getBindGroup(trailMapIn, trailMapOut, sourceMap); const passEncoder = commandEncoder.beginComputePass(); diff --git a/src/pipelines/brush/brush-pipeline.ts b/src/pipelines/brush/brush-pipeline.ts index d228b79..6c4987c 100644 --- a/src/pipelines/brush/brush-pipeline.ts +++ b/src/pipelines/brush/brush-pipeline.ts @@ -21,6 +21,7 @@ export class BrushPipeline { private static readonly MAX_LINE_COUNT = appConfig.pipelines.brush.maxLineCount; private static readonly VERTICES_PER_LINE_SEGMENT = 6; private static readonly ATTRIBUTES_PER_LINE_SEGMENT = 6; + private static readonly FEATHER_RADIUS_RATIO = 0.22; private readonly bindGroupLayout: GPUBindGroupLayout; private readonly bindGroup: GPUBindGroup; @@ -92,6 +93,18 @@ export class BrushPipeline { targets: [ { format: 'rgba16float', + blend: { + color: { + operation: 'max', + srcFactor: 'one', + dstFactor: 'one', + }, + alpha: { + operation: 'max', + srcFactor: 'one', + dstFactor: 'one', + }, + }, }, ], }, @@ -143,8 +156,14 @@ export class BrushPipeline { selectedColorIndex, isErasing, }: BrushSettings & { selectedColorIndex: number; isErasing: boolean }) { - this.uniformValues[0] = brushSize / 2; - this.uniformValues[1] = Math.floor((brushSize / 2) * brushSizeVariation); + const brushRadius = brushSize / 2; + const brushRadiusVariation = Math.floor(brushRadius * brushSizeVariation); + const brushFeather = Math.max(1, brushRadius * BrushPipeline.FEATHER_RADIUS_RATIO); + const brushGeometryRadius = + brushRadius + Math.max(0, brushRadiusVariation) + brushFeather; + + this.uniformValues[0] = brushRadius; + this.uniformValues[1] = brushRadiusVariation; this.uniformValues[2] = 0; this.uniformValues[3] = 0; this.uniformValues[4] = !isErasing && selectedColorIndex === 0 ? 1 : 0; @@ -178,7 +197,7 @@ export class BrushPipeline { floatOffset, segment.from, segment.to, - brushSize / 2 + brushGeometryRadius ); } diff --git a/src/pipelines/render/render.wgsl b/src/pipelines/render/render.wgsl index 5864693..0e8f860 100644 --- a/src/pipelines/render/render.wgsl +++ b/src/pipelines/render/render.wgsl @@ -39,12 +39,13 @@ fn fragment(@location(0) uv: vec2) -> @location(0) vec4 { strengths.r * settings.colorA + strengths.g * settings.colorB + strengths.b * settings.colorC; + let normalizedTraceColor = traceColor / max(1.0, strengths.r + strengths.g + strengths.b); let brushColor = sourceStrengths.r * settings.colorA + sourceStrengths.g * settings.colorB + sourceStrengths.b * settings.colorC; let brushStrength = clamp(max(max(sourceStrengths.r, sourceStrengths.g), sourceStrengths.b), 0, 1); - let color = max(traceColor, brushColor * (1.2 + brushStrength * 1.6)); + let color = max(normalizedTraceColor, brushColor * (1.2 + brushStrength * 1.6)); let strength = clamp(max(max(max(strengths.r, strengths.g), strengths.b), brushStrength), 0, 1); diff --git a/src/style/_app-shell.scss b/src/style/_app-shell.scss index 86d78b9..c7e43ae 100644 --- a/src/style/_app-shell.scss +++ b/src/style/_app-shell.scss @@ -73,6 +73,10 @@ html > body { pointer-events: none; user-select: none; white-space: pre; + + &[hidden] { + display: none; + } } > .errors-container { diff --git a/src/style/_control-dock.scss b/src/style/_control-dock.scss index 6949610..b891e5e 100644 --- a/src/style/_control-dock.scss +++ b/src/style/_control-dock.scss @@ -1,17 +1,19 @@ html > body > aside.control-dock { + --dock-hidden-translate-y: calc(100% + env(safe-area-inset-bottom, 0px) + 16px); + position: absolute; - left: 50%; - bottom: env(safe-area-inset-bottom); + left: 0; + right: 0; + bottom: env(safe-area-inset-bottom, 0px); z-index: 4; width: min(calc(100vw - 1rem), 980px); - transform: translate(-50%, 0); - translate: 0 0; + margin: 0 auto; + transform: translateY(0); visibility: visible; pointer-events: none; transition: opacity var(--transition-time-long), transform var(--transition-time-long), - translate var(--transition-time-long), visibility 0s; > .toolbar-row, @@ -22,7 +24,7 @@ html > body > aside.control-dock { &.menu-hidden { opacity: 0; visibility: hidden; - transform: translate(-50%, 10px); + transform: translateY(var(--dock-hidden-translate-y)); pointer-events: none; transition: opacity var(--transition-time-long), @@ -34,32 +36,4 @@ html > body > aside.control-dock { pointer-events: none; } } - - &.menu-hidden.has-persistent-settings { - opacity: 1; - visibility: visible; - transform: translate(-50%, 0); - - > .pages, - > .toolbar-row > .vibe-button, - > .toolbar-row > .toolbar-shell > .garden-controls, - > .toolbar-row > .toolbar-shell > nav.buttons > button:not(.settings), - > .toolbar-row > .toolbar-shell > nav.buttons > .export-status { - opacity: 0; - visibility: hidden; - pointer-events: none; - } - - > .toolbar-row, - > .toolbar-row > .toolbar-shell, - > .toolbar-row > .toolbar-shell > nav.buttons { - pointer-events: none; - } - - > .toolbar-row > .toolbar-shell > nav.buttons > button.settings { - visibility: visible; - opacity: 1; - pointer-events: auto; - } - } } diff --git a/src/style/_loading.scss b/src/style/_loading.scss index ff97098..a8ca9b8 100644 --- a/src/style/_loading.scss +++ b/src/style/_loading.scss @@ -71,11 +71,7 @@ bottom: 0; width: var(--loading-progress); border-radius: inherit; - background: linear-gradient( - 90deg, - rgb(255 255 255 / 72%), - rgb(255 255 255 / 96%) - ); + 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,7 +90,7 @@ html > body.is-loading { aside.control-dock { opacity: 0; visibility: hidden; - translate: 0 36px; + transform: translateY(var(--dock-hidden-translate-y)); } } diff --git a/src/style/_motion.scss b/src/style/_motion.scss index 005a69f..b12cc37 100644 --- a/src/style/_motion.scss +++ b/src/style/_motion.scss @@ -12,10 +12,7 @@ } > aside.control-dock { - &, - &.menu-hidden { - transform: translateX(-50%); - } + transform: translateY(0); > .toolbar-row { button:hover, @@ -30,5 +27,9 @@ } } } + + &.is-loading aside.control-dock { + transform: translateY(0); + } } } diff --git a/src/utils/graphics/get-workgroup-counts.test.ts b/src/utils/graphics/get-workgroup-counts.test.ts new file mode 100644 index 0000000..36dda74 --- /dev/null +++ b/src/utils/graphics/get-workgroup-counts.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; + +import { getWorkgroupCounts } from './get-workgroup-counts'; + +const makeDevice = (maxComputeWorkgroupsPerDimension: number): GPUDevice => + ({ + limits: { + maxComputeWorkgroupsPerDimension, + }, + }) as GPUDevice; + +describe('getWorkgroupCounts', () => { + it('returns at least one workgroup for positive invocation counts', () => { + expect(getWorkgroupCounts(makeDevice(65_535), 1, 64)).toEqual([1, 1, 1]); + expect(getWorkgroupCounts(makeDevice(65_535), 65, 64)).toEqual([2, 1, 1]); + }); + + it('rejects zero and non-finite dispatch inputs', () => { + const device = makeDevice(65_535); + + expect(() => getWorkgroupCounts(device, 0, 64)).toThrow(/positive finite/); + expect(() => getWorkgroupCounts(device, -1, 64)).toThrow(/positive finite/); + expect(() => getWorkgroupCounts(device, Number.POSITIVE_INFINITY, 64)).toThrow( + /positive finite/ + ); + expect(() => getWorkgroupCounts(device, 1, 0)).toThrow(/positive finite/); + }); + + it('rejects invocation counts that exceed device workgroup limits', () => { + expect(() => getWorkgroupCounts(makeDevice(2), 9, 1)).toThrow( + 'Cannot have this many invocations' + ); + }); +}); diff --git a/src/utils/graphics/get-workgroup-counts.ts b/src/utils/graphics/get-workgroup-counts.ts index fe016e7..e6a648f 100644 --- a/src/utils/graphics/get-workgroup-counts.ts +++ b/src/utils/graphics/get-workgroup-counts.ts @@ -3,6 +3,17 @@ export const getWorkgroupCounts = ( invocationCount: number, workgroupSize: number ): [number, number, number] => { + if ( + !Number.isFinite(invocationCount) || + !Number.isFinite(workgroupSize) || + invocationCount <= 0 || + workgroupSize <= 0 + ) { + throw new Error( + 'Invocation count and workgroup size must be positive finite numbers' + ); + } + const workgroupCount = Math.ceil(invocationCount / workgroupSize); const workgroupCountX = Math.min( diff --git a/src/utils/graphics/initialize-context.ts b/src/utils/graphics/initialize-context.ts index 94d29c1..2a50c9e 100644 --- a/src/utils/graphics/initialize-context.ts +++ b/src/utils/graphics/initialize-context.ts @@ -7,7 +7,7 @@ export const initializeContext = ({ device: GPUDevice; canvas: HTMLCanvasElement; }): GPUCanvasContext => { - const context = canvas.getContext('webgpu' as any) as GPUCanvasContext | null; + const context = canvas.getContext('webgpu'); if (!context) { throw new RuntimeError( diff --git a/src/utils/graphics/initialize-gpu.test.ts b/src/utils/graphics/initialize-gpu.test.ts new file mode 100644 index 0000000..cdabd2b --- /dev/null +++ b/src/utils/graphics/initialize-gpu.test.ts @@ -0,0 +1,253 @@ +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/tsconfig.json b/tsconfig.json index d2eb3bf..0c38b71 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,8 +15,8 @@ "forceConsistentCasingInFileNames": true, "strict": true, - "noUnusedLocals": false, - "noUnusedParameters": false + "noUnusedLocals": true, + "noUnusedParameters": true }, "include": ["src/**/*", "definitions.d.ts", "vite.config.ts"] }