v good
Some checks failed
Deploy to Pages / build (pull_request) Failing after 1m56s

This commit is contained in:
Andras Schmelczer 2026-05-16 13:46:19 +01:00
parent 2347ecd201
commit 10a81ba474
45 changed files with 1978 additions and 494 deletions

4
definitions.d.ts vendored
View file

@ -2,3 +2,7 @@ declare module '*.wgsl?raw' {
const content: string;
export default content;
}
interface HTMLCanvasElement {
getContext(contextId: 'webgpu'): GPUCanvasContext | null;
}

View file

@ -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<string> = [];
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();
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 914 B

After

Width:  |  Height:  |  Size: 892 B

Before After
Before After

View file

@ -7,7 +7,7 @@
<g clip-path="url(#icon-clip)">
<rect width="64" height="64" fill="#10151f" />
<path d="M0 64a32 32 0 0 1 64 0Z" fill="#ffd84d" />
<path d="M0 64a32 32 0 0 1 64 0Z" fill="#40d6c8" />
<path
d="M32 34c1.2-7.2 4.8-12.3 10-16"
fill="none"
@ -18,14 +18,14 @@
<path
d="M32 34c1.2-7.2 4.8-12.3 10-16"
fill="none"
stroke="#ff2fa3"
stroke="#ff5da2"
stroke-linecap="round"
stroke-width="4"
/>
<ellipse cx="42" cy="11.5" rx="4.2" ry="6.4" fill="#ff2fa3" />
<ellipse cx="48.5" cy="18" rx="6.4" ry="4.2" fill="#ff2fa3" />
<ellipse cx="42" cy="24.5" rx="4.2" ry="6.4" fill="#ff2fa3" />
<ellipse cx="35.5" cy="18" rx="6.4" ry="4.2" fill="#ff2fa3" />
<ellipse cx="42" cy="11.5" rx="4.2" ry="6.4" fill="#ff5da2" />
<ellipse cx="48.5" cy="18" rx="6.4" ry="4.2" fill="#ff5da2" />
<ellipse cx="42" cy="24.5" rx="4.2" ry="6.4" fill="#ff5da2" />
<ellipse cx="35.5" cy="18" rx="6.4" ry="4.2" fill="#ff5da2" />
<circle cx="42" cy="18" r="3.2" fill="#10151f" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 950 B

After

Width:  |  Height:  |  Size: 950 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 709 B

After

Width:  |  Height:  |  Size: 690 B

Before After
Before After

View file

@ -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}`)

View file

@ -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);

View file

@ -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<GestureSample> = [];
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<number>();
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<number> | undefined,
canvasSize: ArrayLike<number> | 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<number>, 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);
};

View file

@ -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,
};

View file

@ -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<number>;
canvasSize?: ArrayLike<number>;
mirrorSegmentCount?: number;
pressure?: number;
pointerType?: string;

View file

@ -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);
});
});

View file

@ -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);
};

View file

@ -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<PianoNote> = [];
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', () => {

View file

@ -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<number>;
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<number> {
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<number> {
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);

View file

@ -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

View file

@ -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 }
);
}
}

View file

@ -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,

View file

@ -1,7 +1,4 @@
import type {
AgentColorInteractionSettings,
NumberControlConfig,
} from './types';
import type { AgentColorInteractionSettings, NumberControlConfig } from './types';
const agentInteractionOptions: Record<string, number> = {
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,

View file

@ -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;

View file

@ -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<string, number>, {
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);

View file

@ -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 {

View file

@ -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);
});
});

View file

@ -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;
}
}

View file

@ -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<void>();
@ -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;
}
}

View file

@ -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]);

View file

@ -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];

View file

@ -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();

View file

@ -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<ColorReactionKey, HTMLSelectElement>();
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);
}

View file

@ -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<HTMLElement>;
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<HTMLElement>(
'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;
}
}

View file

@ -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();

View file

@ -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
);
}

View file

@ -39,12 +39,13 @@ fn fragment(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
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);

View file

@ -73,6 +73,10 @@ html > body {
pointer-events: none;
user-select: none;
white-space: pre;
&[hidden] {
display: none;
}
}
> .errors-container {

View file

@ -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;
}
}
}

View file

@ -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));
}
}

View file

@ -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);
}
}
}

View file

@ -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'
);
});
});

View file

@ -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(

View file

@ -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(

View file

@ -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 = <T>() => {
let resolve!: (value: T) => void;
const promise = new Promise<T>((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<GPUDeviceLostInfo> = new Promise<GPUDeviceLostInfo>(() => {})
) => {
const listeners = new Map<string, EventListener>();
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<typeof vi.fn>;
} = {}) =>
({
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<RuntimeError> => {
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<GPUDeviceLostInfo>();
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<GPUDeviceLostInfo>();
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([]);
});
});

View file

@ -15,8 +15,8 @@
"forceConsistentCasingInFileNames": true,
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["src/**/*", "definitions.d.ts", "vite.config.ts"]
}