Compare commits

...

20 commits

Author SHA1 Message Date
ed5a4379db Optimise
All checks were successful
Check & deploy / build (pull_request) Successful in 1m51s
2026-05-21 20:33:49 +01:00
6bc125be1c more 2026-05-21 07:43:10 +01:00
2fe3c69963 simplify more 2026-05-20 21:37:30 +01:00
f03da42b5e More clean up 2026-05-20 21:03:41 +01:00
c94ffcc506 more clean up 2026-05-19 21:03:59 +01:00
7c70f15e49 Clean up 2026-05-19 21:03:53 +01:00
ea0304356f more clean up 2026-05-18 08:11:58 +01:00
15e99380b5 clean up 2026-05-18 08:11:52 +01:00
d6a8f898d1 sure 2026-05-17 17:21:49 +01:00
ced0ac56f3 Move sounds 2026-05-17 15:32:26 +01:00
80ed37298b not sure 2026-05-17 14:12:01 +01:00
560398fefb more cleaning up 2026-05-16 20:45:42 +01:00
2c7d72a699 . 2026-05-16 16:15:59 +01:00
d2da0d1617 lgtm 2026-05-16 16:15:54 +01:00
ce383ce34c More css clean up 2026-05-16 15:41:36 +01:00
1fe5015056 , 2026-05-16 15:05:35 +01:00
70423851ba Clena up CSS 2026-05-16 15:05:23 +01:00
20433bd9f0 Remove rnadom script 2026-05-16 14:21:10 +01:00
9256377c13 Clean up CI 2026-05-16 14:04:43 +01:00
c719b7e5b3 Clean up 2026-05-16 14:03:27 +01:00
213 changed files with 10813 additions and 10764 deletions

View file

@ -1,5 +0,0 @@
{
"enabledPlugins": {
"frontend-design@claude-plugins-official": true
}
}

View file

@ -1,4 +1,4 @@
name: Deploy to Pages
name: Check & deploy
on:
push:
@ -25,25 +25,20 @@ jobs:
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Lint
run: npm run lint
- name: Typecheck
run: npm run typecheck
- name: Typecheck browser tests
run: npm run typecheck:e2e
run: |
npm ci
npx playwright install --with-deps chromium
- name: Test
run: npm test
run: |
npm run lint:check
npm run typecheck
npm run typecheck:e2e
npm test
- name: Browser tests
run: npm run test:e2e
- name: Test E2E
run: |
npm run test:e2e
- name: Upload Playwright report
if: failure()
@ -59,4 +54,4 @@ jobs:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
apt update && apt install -y rsync
rsync -a --delete dist/ /pages/fleeting-garden
rsync -a --delete dist/ /pages/fleeting

44
.gitignore vendored
View file

@ -1,47 +1,3 @@
# Dependency directory
node_modules
modules/
ts-node--*/
rss.xml
dist
playwright-report
test-results
# Logs
logs
*.log
npm-debug.log*
# Runtime data
pids
*.pid
*.seed
*.ssh
*.ppk
v8-compile-cache-0/
Thumbs.db
# node-waf configuration
.lock-wscript
# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release
bin
ts-node
# Personal Scripts
*.bat
*.ssh
*.sh
!system.min.js
# Editors
.vscode
.markdownlint.json
# Build Files
temp
*.js
*.map
!webpack.*

View file

@ -2,13 +2,13 @@
Fleeting Garden is a single-player WebGPU drawing garden. Pick a vibe palette,
draw persistent coloured paths, spawn agents from those strokes, erase locally,
and export the scene as a 4K wallpaper.
and export the scene as an internal render buffer snapshot.
Check out the [agent logic](./src/pipelines/agents/agent.wgsl).
## Testing
- `npm test` runs the Vitest unit suite.
- `npm run test:e2e` builds the production bundle and runs the Playwright Chromium
smoke test.
- `npm run test:e2e` runs the Playwright Chromium smoke test. The Playwright
config builds the production bundle before serving it.
- `npx playwright install chromium` installs the local browser binary when needed.

View file

@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="#000000" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 21v-4a4 4 0 1 1 4 4h-4" />
<path d="M21 3a16 16 0 0 0 -12.8 10.2" />
<path d="M21 3a16 16 0 0 1 -10.2 12.8" />
<path d="M10.6 9a9 9 0 0 1 4.4 4.4" />
</svg>

Before

Width:  |  Height:  |  Size: 332 B

View file

@ -2,7 +2,7 @@
<path
d="M12 3v11m0 0 4-4m-4 4-4-4M5 17v3h14v-3"
fill="none"
stroke="black"
stroke="white"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"

Before

Width:  |  Height:  |  Size: 239 B

After

Width:  |  Height:  |  Size: 239 B

Before After
Before After

View file

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="#FFFFFF" fill="none" stroke-linecap="round" stroke-linejoin="round">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="white" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<circle cx="12" cy="12" r="9" />
<line x1="12" y1="8" x2="12.01" y2="8" />

Before

Width:  |  Height:  |  Size: 344 B

After

Width:  |  Height:  |  Size: 342 B

Before After
Before After

View file

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="#FFFFFF" fill="none" stroke-linecap="round" stroke-linejoin="round">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="white" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M4 8v-2a2 2 0 0 1 2 -2h2" />
<path d="M4 16v2a2 2 0 0 0 2 2h2" />

Before

Width:  |  Height:  |  Size: 376 B

After

Width:  |  Height:  |  Size: 374 B

Before After
Before After

View file

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="#FFFFFF" fill="none" stroke-linecap="round" stroke-linejoin="round">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="white" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M15 19v-2a2 2 0 0 1 2 -2h2" />
<path d="M15 5v2a2 2 0 0 0 2 2h2" />

Before

Width:  |  Height:  |  Size: 376 B

After

Width:  |  Height:  |  Size: 374 B

Before After
Before After

View file

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="#FFFFFF" fill="none" stroke-linecap="round" stroke-linejoin="round">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="white" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M20 11a8.1 8.1 0 0 0 -15.5 -2m-.5 -4v4h4" />
<path d="M4 13a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4" />

Before

Width:  |  Height:  |  Size: 326 B

After

Width:  |  Height:  |  Size: 324 B

Before After
Before After

View file

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="#FFFFFF" fill="none" stroke-linecap="round" stroke-linejoin="round">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="white" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M14 6m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M4 6l8 0" />

Before

Width:  |  Height:  |  Size: 536 B

After

Width:  |  Height:  |  Size: 534 B

Before After
Before After

View file

@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M3 9.25v5.5h4.1L13 20V4L7.1 9.25H3Zm13.3-2.8-1.4 1.4A5.1 5.1 0 0 1 16.5 12a5.1 5.1 0 0 1-1.6 4.15l1.4 1.4A7.1 7.1 0 0 0 18.5 12a7.1 7.1 0 0 0-2.2-5.55Zm2.85-2.85-1.42 1.42A9.55 9.55 0 0 1 20.5 12a9.55 9.55 0 0 1-2.77 6.98l1.42 1.42A11.55 11.55 0 0 0 22.5 12a11.55 11.55 0 0 0-3.35-8.4Z" />
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white">
<path d="M3 9.25v5.5h4.1L13 20V4L7.1 9.25H3Zm13.3-2.8-1.4 1.4A5.1 5.1 0 0 1 16.5 12a5.1 5.1 0 0 1-1.6 4.15l1.4 1.4A7.1 7.1 0 0 0 18.5 12a7.1 7.1 0 0 0-2.2-5.55Zm2.85-2.85-1.42 1.42A9.55 9.55 0 0 1 20.5 12a9.55 9.55 0 0 1-2.77 6.98l1.42 1.42A11.55 11.55 0 0 0 22.5 12a11.55 11.55 0 0 0-3.35-8.4Z" />
</svg>

Before

Width:  |  Height:  |  Size: 369 B

After

Width:  |  Height:  |  Size: 384 B

Before After
Before After

View file

@ -1,6 +1,33 @@
import { expect, test, type Page } from '@playwright/test';
type WebGpuFailureMode = 'adapter-null' | 'adapter-rejects' | 'device-rejects';
const canvasName = 'Interactive generative garden canvas';
const isLocalUrl = (url: string) => {
const { hostname } = new URL(url);
return hostname === '127.0.0.1' || hostname === 'localhost';
};
const collectLocalBrowserFailures = (page: Page) => {
const failures: Array<string> = [];
page.on('requestfailed', (request) => {
if (!isLocalUrl(request.url())) {
return;
}
const failure = request.failure();
failures.push(`${request.method()} ${request.url()} ${failure?.errorText}`);
});
page.on('response', (response) => {
if (response.status() < 400 || !isLocalUrl(response.url())) {
return;
}
failures.push(`${response.status()} ${response.url()}`);
});
return failures;
};
const disableWebGpu = async (page: Page) => {
await page.addInitScript(() => {
@ -11,299 +38,190 @@ const disableWebGpu = async (page: Page) => {
});
};
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('/');
await expect(page).toHaveTitle('Fleeting Garden');
await expect(
page.getByRole('img', { name: 'Interactive generative garden canvas' })
).toBeVisible();
await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible();
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
await expect(page.getByRole('alert')).toContainText('Fleeting Garden needs WebGPU');
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()}`);
test('starts the WebGPU garden and accepts drawing input', async ({ page }) => {
const browserFailures = collectLocalBrowserFailures(page);
const consoleErrors: Array<string> = [];
page.on('console', (message) => {
if (message.type() === 'error') {
consoleErrors.push(message.text());
}
});
await disableWebGpu(page);
await page.goto('/');
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
await page.addInitScript((expectedCanvasName) => {
const captureState = { count: 0 };
Object.defineProperty(window, '__fleetingGardenPointerCaptures', {
configurable: true,
value: captureState,
});
const originalSetPointerCapture = Element.prototype.setPointerCapture;
Element.prototype.setPointerCapture = function setPointerCapture(pointerId) {
if (
this instanceof HTMLCanvasElement &&
this.getAttribute('aria-label') === expectedCanvasName
) {
captureState.count += 1;
}
return originalSetPointerCapture.call(this, pointerId);
};
}, canvasName);
await page.goto('/');
const startButton = page.getByRole('button', { name: 'Start' });
await expect(startButton).toBeVisible();
await expect(startButton).toBeEnabled({ timeout: 30_000 });
await startButton.click();
await expect(page.locator('body')).not.toHaveClass(/is-loading/, {
timeout: 30_000,
});
await expect(page.getByRole('alert')).toHaveCount(0);
await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible();
const canvas = page.getByRole('img', { name: canvasName });
await expect(canvas).toBeVisible();
const canvasSize = await canvas.evaluate((element) => {
const canvasElement = element as HTMLCanvasElement;
return {
height: canvasElement.height,
width: canvasElement.width,
};
});
expect(canvasSize.width).toBeGreaterThan(0);
expect(canvasSize.height).toBeGreaterThan(0);
const box = await canvas.boundingBox();
expect(box).not.toBeNull();
if (!box) {
return;
}
await page.mouse.move(box.x + box.width * 0.2, box.y + box.height * 0.5);
await page.mouse.down();
await page.mouse.move(box.x + box.width * 0.8, box.y + box.height * 0.5, {
steps: 16,
});
await page.mouse.up();
await expect
.poll(() =>
page.evaluate(
() =>
(
window as unknown as {
__fleetingGardenPointerCaptures?: { count: number };
}
).__fleetingGardenPointerCaptures?.count ?? 0
)
)
.toBeGreaterThan(0);
expect(consoleErrors).toEqual([]);
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);
test('shows a clear fallback when WebGPU is unavailable', async ({ page }) => {
const browserFailures = collectLocalBrowserFailures(page);
await page.goto('/');
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
await disableWebGpu(page);
await page.goto('/');
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');
await expect(page).toHaveTitle('Fleeting Garden');
await expect(page.getByRole('img', { name: canvasName })).toBeVisible();
await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible();
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
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);
});
const fallback = page.getByRole('alert');
await expect(fallback).toContainText('Fleeting Garden needs WebGPU');
await expect(fallback).toContainText('webgpu-unsupported');
expect(browserFailures).toEqual([]);
});
test('hides the bottom dock after the cursor leaves fullscreen controls', async ({
test('keeps audio focus outlines scoped to the active control', async ({ page }) => {
await disableWebGpu(page);
await page.goto('/');
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
const audioControl = page.locator('.audio-control');
const soundButton = page.locator('button.sound');
const volumeSlider = page.locator('.volume-slider');
await soundButton.click();
await expect(audioControl).toHaveCSS('outline-style', 'none');
await expect(soundButton).toHaveCSS('outline-style', 'none');
await page.mouse.click(10, 10);
for (let tabIndex = 0; tabIndex < 12; tabIndex += 1) {
await page.keyboard.press('Tab');
const activeClass = await page.evaluate(() =>
String(document.activeElement?.className ?? '')
);
if (activeClass.includes('sound')) {
break;
}
}
await expect(soundButton).toBeFocused();
await expect(soundButton).toHaveCSS('outline-style', 'solid');
await expect(soundButton).toHaveCSS('outline-offset', '-4px');
await page.keyboard.press('Tab');
await expect(volumeSlider).toBeFocused();
await expect(volumeSlider).toHaveCSS('outline-style', 'solid');
await expect(volumeSlider).toHaveCSS('outline-offset', '-4px');
});
test('keeps the config overlay scrollable and dismissible on mobile', async ({
page,
}) => {
await disableWebGpu(page);
await page.setViewportSize({ width: 390, height: 640 });
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();
}
const startButton = page.getByRole('button', { name: 'Start' });
await expect(startButton).toBeEnabled({ timeout: 30_000 });
await startButton.click();
await expect(page.locator('body')).not.toHaveClass(/is-loading/, {
timeout: 30_000,
});
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);
const settingsButton = page.locator('button.settings');
await settingsButton.click();
await page.mouse.move(640, 700);
await expect(page.locator('aside.control-dock')).not.toHaveClass(/menu-hidden/);
await expect(page.locator('.garden-controls')).toBeVisible();
const pane = page.locator('.config-pane');
const closeButton = page.locator('.config-pane-close');
await expect(pane).toBeVisible();
await expect(closeButton).toBeVisible();
const paneMetrics = await pane.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return {
bottom: rect.bottom,
clientHeight: element.clientHeight,
overflowY: style.overflowY,
scrollHeight: element.scrollHeight,
top: rect.top,
viewportHeight: window.innerHeight,
viewportWidth: window.innerWidth,
width: rect.width,
};
});
expect(paneMetrics.top).toBeGreaterThanOrEqual(0);
expect(paneMetrics.bottom).toBeLessThanOrEqual(paneMetrics.viewportHeight);
expect(Math.round(paneMetrics.width)).toBe(Math.round(paneMetrics.viewportWidth * 0.8));
expect(paneMetrics.scrollHeight).toBeGreaterThan(paneMetrics.clientHeight);
expect(['auto', 'scroll']).toContain(paneMetrics.overflowY);
await pane.evaluate((element) => {
element.scrollTop = element.scrollHeight;
});
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();
.poll(() => pane.evaluate((element) => element.scrollTop))
.toBeGreaterThan(0);
await closeButton.click();
await expect(pane).toBeHidden();
await expect(settingsButton).toHaveAttribute('aria-expanded', 'false');
});

30
eslint.config.js Normal file
View file

@ -0,0 +1,30 @@
import js from '@eslint/js';
import prettierConfig from 'eslint-config-prettier';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['node_modules/**', 'dist/**', 'public/**'],
},
js.configs.recommended,
...tseslint.configs.recommended,
prettierConfig,
{
files: ['**/*.ts'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
globals: {
...globals.browser,
},
},
rules: {
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-non-null-assertion': 'error',
'@typescript-eslint/ban-ts-comment': 'error',
'prefer-const': 'error',
},
}
);

View file

@ -7,25 +7,64 @@
content="width=device-width,initial-scale=1,viewport-fit=cover"
/>
<meta name="theme-color" content="#10151f" />
<meta name="robots" content="index,follow" />
<meta name="author" content="Andras Schmelczer" />
<meta
name="description"
content="Fleeting Garden is a joyful WebGPU drawing garden where your coloured paths bloom into moving organic trails."
content="Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser."
/>
<link rel="canonical" href="https://schmelczer.dev/fleeting/" />
<meta property="og:title" content="Fleeting Garden" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Fleeting Garden" />
<meta property="og:locale" content="en_US" />
<meta
property="og:description"
content="Pick a vibe, draw coloured paths, and watch them grow into a living WebGPU garden."
content="Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser."
/>
<meta property="og:url" content="https://schmelczer.dev" />
<meta property="og:image" content="https://schmelczer.dev/og-image.jpg" />
<meta property="og:image:width" content="1920" />
<meta property="og:image:height" content="1920" />
<meta property="og:url" content="https://schmelczer.dev/fleeting/" />
<meta property="og:image" content="https://schmelczer.dev/fleeting/og-image.jpg" />
<meta property="og:image:type" content="image/jpeg" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="Fleeting Garden social preview image." />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Fleeting Garden" />
<meta
name="twitter:description"
content="Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser."
/>
<meta name="twitter:image" content="https://schmelczer.dev/fleeting/og-image.jpg" />
<meta name="twitter:image:alt" content="Fleeting Garden social preview image." />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "Fleeting Garden",
"url": "https://schmelczer.dev/fleeting/",
"description": "Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser.",
"image": "https://schmelczer.dev/fleeting/og-image.jpg",
"applicationCategory": "DesignApplication",
"operatingSystem": "Any",
"browserRequirements": "Requires a browser with WebGPU support.",
"author": {
"@type": "Person",
"name": "Andras Schmelczer"
},
"sameAs": "https://github.com/schmelczer/webgpu"
}
</script>
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon-180x180.png" />
<link rel="manifest" href="manifest.webmanifest" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="Fleeting Garden" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>Fleeting Garden</title>
</head>
@ -41,28 +80,31 @@
</canvas>
<p id="canvas-description" class="visually-hidden">
Fleeting Garden is a pointer-driven WebGPU drawing canvas. Drag or touch the scene
to paint coloured paths, then use the toolbar to change colours, erase, adjust the
config overlay, export, restart, or open more information.
to paint coloured paths, then use the toolbar to change colours, erase, export,
adjust the config overlay, restart, or open more information.
</p>
<div class="garden-grain" aria-hidden="true"></div>
<div class="eraser-preview" aria-hidden="true"></div>
<div class="garden-prompt" aria-live="polite"></div>
<div class="loading-indicator" role="status" aria-live="polite">
<div class="loading-dots" aria-hidden="true">
<span class="loading-dot"></span>
<span class="loading-dot"></span>
<span class="loading-dot"></span>
<div class="loading-indicator" role="status">
<div class="splash" data-visible="true">
<h1 class="splash-title">Fleeting Garden</h1>
<p class="splash-description">
Tend it while you can. The garden returns to weather either way.
</p>
<button class="start-button" type="button" disabled>Start</button>
</div>
<div class="loading-status">Starting up&hellip;</div>
<div
class="loading-progress"
role="progressbar"
aria-label="Loading Fleeting Garden"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="0"
>
<div class="loading-progress-fill"></div>
<div class="loading-bar" data-visible="false" aria-hidden="true" inert>
<div class="loading-status">Starting up&hellip;</div>
<div
class="loading-progress"
role="progressbar"
aria-label="Loading Fleeting Garden"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="0"
></div>
</div>
</div>
@ -72,26 +114,36 @@
</main>
<aside class="control-dock">
<section id="info-panel" class="pages hidden info-page" aria-hidden="true" inert>
<section
id="info-panel"
class="hidden info-page"
role="region"
aria-label="About panel"
aria-hidden="true"
tabindex="-1"
inert
>
<section>
<h1>Fleeting Garden</h1>
<p>
A living sketchpad where each stroke becomes a trail that agents follow,
branch from, and weave into the scene.
A garden is what we tend; the wild is what we get the moment we look away.
Both happen here at once. Your strokes plant colour, small agents follow them,
branch off, and slowly rewrite the patch you laid down into something you
didn't quite plan.
</p>
<p>
Paint with the three colour swatches, carve space with the eraser, and raise
the mirror control when you want radial patterns instead of a single line.
Three swatches plant the line. The eraser carves a clearing. The mirror folds
one gesture into many, like footpaths around a hidden well.
</p>
<p>
Switch vibes to recolour the whole garden without clearing your drawing. Add
or mute the generated piano, restart for a blank canvas, or export the current
frame as a 4K image.
Switch vibes to change the season; your shapes stay, the light moves. Add or
quiet the piano. Restart when you want a fresh field. Take a snapshot if you
want to keep one particular instant of weather.
</p>
<p>
Built with WebGPU and running locally in your browser. Source on
<a href="https://github.com/schmelczer/webgpu" target="_blank" rel="noopener"
>GitHub</a
Built with WebGPU, running locally in your browser. More of my work at
<a href="https://schmelczer.dev" target="_blank" rel="noopener"
>schmelczer.dev</a
>.
</p>
</section>
@ -108,7 +160,7 @@
<div class="toolbar-shell">
<section class="garden-controls" aria-label="Garden controls">
<div class="swatches" aria-label="Drawing colours">
<div class="swatches" role="group" aria-label="Drawing colours">
<button
class="color-swatch"
aria-label="Draw colour 1"
@ -125,72 +177,67 @@
title="Draw colour 3"
></button>
<label class="eraser-size-control" title="Erase and resize">
<input
class="eraser-size-slider"
type="range"
min="24"
max="240"
step="1"
aria-label="Eraser size"
/>
<input class="eraser-size-slider" type="range" aria-label="Eraser size" />
</label>
<label class="mirror-segment-control" title="Mirror off">
<input
class="mirror-segment-slider"
type="range"
min="1"
max="12"
step="1"
aria-label="Mirror segments"
/>
</label>
</div>
</section>
</div>
<nav class="buttons" aria-label="App controls">
<button
class="info"
aria-label="About"
aria-controls="info-panel"
aria-expanded="false"
title="About"
></button>
<button
class="maximize-full-screen"
aria-label="Enter fullscreen"
title="Enter fullscreen"
></button>
<button
class="minimize-full-screen"
aria-label="Exit fullscreen"
hidden
title="Exit fullscreen"
></button>
<button
class="settings"
aria-label="Show config overlay"
aria-expanded="false"
title="Show config overlay"
></button>
<nav class="buttons" aria-label="App controls">
<button
class="info"
data-control="info"
aria-label="About"
aria-controls="info-panel"
aria-expanded="false"
title="About"
></button>
<button
class="full-screen-toggle"
data-control="full-screen"
aria-label="Enter fullscreen"
title="Enter fullscreen"
></button>
<button
class="settings"
data-control="settings"
aria-label="Show config overlay"
aria-expanded="false"
title="Show config overlay"
></button>
<div class="audio-control">
<button
class="sound"
data-control="sound"
aria-label="Mute audio"
aria-pressed="false"
title="Mute audio"
></button>
<button
class="export-4k"
aria-label="Download 4K upscale image"
title="Download 4K upscale of the live simulation"
></button>
<span class="export-status" aria-live="polite"></span>
<button
class="restart"
aria-label="Restart simulation"
title="Restart simulation"
></button>
</nav>
</div>
<label class="volume-control" title="Master volume">
<input class="volume-slider" type="range" aria-label="Master volume" />
</label>
</div>
<button
class="export-4k"
data-control="export"
aria-label="Download internal buffer snapshot"
title="Download internal buffer snapshot"
></button>
<span class="export-status" aria-live="polite"></span>
<button
class="restart"
data-control="restart"
aria-label="Restart simulation"
title="Restart simulation"
></button>
</nav>
<button class="next-vibe vibe-button" aria-label="Next vibe" title="Next vibe">
&rsaquo;

1069
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -3,25 +3,23 @@
"version": "0.2.0",
"private": true,
"type": "module",
"description": "A WebGPU drawing garden where coloured paths grow into organic agent trails.",
"description": "Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser.",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview",
"lint": "npm run lint:check",
"lint:check": "eslint --rule \"prettier/prettier: off\" \"src/**/*.ts\" && npm run unused:check",
"lint:fix": "eslint --fix \"src/**/*.ts\"",
"format": "prettier --write \"index.html\" \"src/**/*.{ts,scss,json,html}\" \"scripts/**/*.mjs\" \"e2e/**/*.ts\" \".forgejo/workflows/*.yml\" \"*.{json,js,ts,md}\" \".prettierrc\"",
"format:check": "prettier --check \"index.html\" \"src/**/*.{ts,scss,json,html}\" \"scripts/**/*.mjs\" \"e2e/**/*.ts\" \".forgejo/workflows/*.yml\" \"*.{json,js,ts,md}\" \".prettierrc\"",
"lint:check": "eslint . && npm run unused:check",
"lint:fix": "eslint . --fix",
"format": "prettier --write \"index.html\" \"public/manifest.webmanifest\" \"src/**/*.{ts,scss,json,html}\" \"e2e/**/*.ts\" \".forgejo/workflows/*.yml\" \"*.{json,js,ts,md}\" \".prettierrc\"",
"format:check": "prettier --check \"index.html\" \"public/manifest.webmanifest\" \"src/**/*.{ts,scss,json,html}\" \"e2e/**/*.ts\" \".forgejo/workflows/*.yml\" \"*.{json,js,ts,md}\" \".prettierrc\"",
"typecheck": "tsc --noEmit",
"typecheck:e2e": "tsc --noEmit --project tsconfig.playwright.json",
"test": "vitest run",
"test:e2e": "npm run build && playwright test",
"test:e2e:ui": "npm run build && playwright test --ui",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:watch": "vitest",
"unused:check": "node scripts/check-unused-exports.mjs",
"generate-icons": "pwa-assets-generator",
"update": "ncu"
"unused:check": "knip --production --files --dependencies && knip --exports --include-entry-exports",
"generate-icons": "pwa-assets-generator"
},
"engines": {
"node": ">=20"
@ -41,6 +39,11 @@
"browserslist": [
"supports webgpu and last 2 years"
],
"knip": {
"ignoreFiles": [
"pwa-assets.config.ts"
]
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@ianvs/prettier-plugin-sort-imports": "^4.7.1",
@ -51,14 +54,13 @@
"@vitejs/plugin-basic-ssl": "^2.3.0",
"@webgpu/types": "^0.1.69",
"browserslist": "^4.28.2",
"browserslist-to-esbuild": "^2.1.1",
"eslint": "^10.3.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-unused-imports": "^4.4.1",
"gl-matrix": "^3.4.4",
"globals": "^17.6.0",
"knip": "^6.14.1",
"lightningcss": "^1.32.0",
"npm-check-updates": "^22.1.0",
"prettier": "^3.8.3",
"sass": "^1.99.0",
"typescript": "^6.0.3",
@ -68,6 +70,7 @@
"vitest": "^4.1.5"
},
"dependencies": {
"@plausible-analytics/tracker": "^0.4.5",
"tweakpane": "^4.0.5"
}
}

View file

@ -17,16 +17,21 @@ export default defineConfig({
trace: 'on-first-retry',
},
webServer: {
command: `npm run preview -- --host 127.0.0.1 --port ${port}`,
command: `npm run build && npm run preview -- --host 127.0.0.1 --port ${port}`,
ignoreHTTPSErrors: true,
reuseExistingServer: !isCi,
reuseExistingServer: false,
timeout: 120_000,
url: baseURL,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
use: {
...devices['Desktop Chrome'],
launchOptions: {
args: ['--enable-unsafe-webgpu'],
},
},
},
],
});

View file

@ -1,43 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Not found</title>
<meta name="theme-color" content="#b7455e" />
<meta name="viewport" content="initial-scale=1.0" />
<style>
html,
body {
height: 100%;
}
body {
margin: 0;
display: flex;
justify-content: center;
align-items: center;
background-color: #b7455e;
}
div {
text-align: center;
}
h1,
a {
font-family: 'Roboto', 'Helvetica Neue', sans-serif;
font-weight: 100;
font-size: 3rem;
color: white;
padding: 2rem;
}
</style>
</head>
<body>
<div>
<h1>Page not found.</h1>
<a href="/">Go back</a>
</div>
</body>
</html>

View file

@ -1,20 +1,17 @@
{
"name": "Fleeting Garden",
"short_name": "Garden",
"description": "A joyful WebGPU drawing garden where coloured paths grow into organic agent trails.",
"description": "Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser.",
"start_url": "./",
"scope": "./",
"display": "fullscreen",
"display_override": ["fullscreen", "standalone", "minimal-ui"],
"orientation": "any",
"background_color": "#10151f",
"theme_color": "#10151f",
"icons": [
{
"src": "favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
"type": "image/svg+xml"
},
{
"src": "pwa-64x64.png",

BIN
public/og-image.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View file

@ -1,2 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://schmelczer.dev/fleeting/sitemap.xml

6
public/sitemap.xml Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://schmelczer.dev/fleeting/</loc>
</url>
</urlset>

View file

@ -1,197 +0,0 @@
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import path from 'node:path';
import ts from 'typescript';
const projectRoot = process.cwd();
const sourceRoot = path.join(projectRoot, 'src');
const toPosix = (value) => value.split(path.sep).join('/');
const listTypeScriptFiles = (directory) =>
readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
return listTypeScriptFiles(entryPath);
}
return entry.isFile() && entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')
? [entryPath]
: [];
});
const files = listTypeScriptFiles(sourceRoot);
const fileSet = new Set(files.map((file) => path.resolve(file)));
const resolveModule = (fromFile, specifier) => {
if (!specifier.startsWith('.')) {
return null;
}
const base = path.resolve(path.dirname(fromFile), specifier);
const candidates = [
`${base}.ts`,
path.join(base, 'index.ts'),
base.endsWith('.ts') ? base : null,
].filter(Boolean);
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
);
const isDefaultExported = (node) =>
ts.canHaveModifiers(node) &&
(ts.getModifiers(node) ?? []).some(
(modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword
);
const exportedDeclarations = new Map();
const usedExports = new Set();
const wildcardUsedFiles = new Set();
const markUsed = (fromFile, name) => {
usedExports.add(exportKey(fromFile, name));
};
const collectImportUsage = (file, sourceFile) => {
sourceFile.forEachChild((node) => {
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
const resolved = resolveModule(file, node.moduleSpecifier.text);
if (!resolved || !node.importClause) {
return;
}
if (node.importClause.name) {
markUsed(resolved, 'default');
}
const namedBindings = node.importClause.namedBindings;
if (namedBindings && ts.isNamedImports(namedBindings)) {
namedBindings.elements.forEach((element) => {
markUsed(resolved, (element.propertyName ?? element.name).text);
});
} else if (namedBindings && ts.isNamespaceImport(namedBindings)) {
wildcardUsedFiles.add(path.resolve(resolved));
}
return;
}
if (
ts.isExportDeclaration(node) &&
node.moduleSpecifier &&
ts.isStringLiteral(node.moduleSpecifier)
) {
const resolved = resolveModule(file, node.moduleSpecifier.text);
if (!resolved) {
return;
}
if (!node.exportClause) {
wildcardUsedFiles.add(path.resolve(resolved));
return;
}
if (ts.isNamedExports(node.exportClause)) {
node.exportClause.elements.forEach((element) => {
markUsed(resolved, (element.propertyName ?? element.name).text);
});
}
}
});
};
const collectExportedDeclarations = (file, sourceFile) => {
if (file.endsWith('.test.ts')) {
return;
}
sourceFile.forEachChild((node) => {
if (ts.isVariableStatement(node) && isExported(node)) {
node.declarationList.declarations.forEach((declaration) => {
if (ts.isIdentifier(declaration.name)) {
exportedDeclarations.set(exportKey(file, declaration.name.text), {
file,
name: declaration.name.text,
});
}
});
return;
}
if (
(ts.isFunctionDeclaration(node) ||
ts.isClassDeclaration(node) ||
ts.isInterfaceDeclaration(node) ||
ts.isTypeAliasDeclaration(node) ||
ts.isEnumDeclaration(node)) &&
isExported(node)
) {
if (isDefaultExported(node)) {
exportedDeclarations.set(exportKey(file, 'default'), { file, name: 'default' });
return;
}
if (node.name) {
exportedDeclarations.set(exportKey(file, node.name.text), {
file,
name: node.name.text,
});
}
return;
}
if (
ts.isExportDeclaration(node) &&
!node.moduleSpecifier &&
node.exportClause &&
ts.isNamedExports(node.exportClause)
) {
node.exportClause.elements.forEach((element) => {
exportedDeclarations.set(exportKey(file, element.name.text), {
file,
name: element.name.text,
});
});
}
});
};
const parsedFiles = files.map((file) => ({
file,
sourceFile: ts.createSourceFile(
file,
readFileSync(file, 'utf8'),
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS
),
}));
parsedFiles.forEach(({ file, sourceFile }) => collectImportUsage(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)
)
.map(([, declaration]) => declaration)
.sort((left, right) =>
`${left.file}:${left.name}`.localeCompare(`${right.file}:${right.name}`)
);
if (unusedExports.length > 0) {
console.error('Unused exported declarations found:');
unusedExports.forEach(({ file, name }) => {
console.error(`- ${toPosix(path.relative(projectRoot, file))}: ${name}`);
});
process.exitCode = 1;
}

67
src/analytics.ts Normal file
View file

@ -0,0 +1,67 @@
import {
init as plausibleInit,
track as plausibleTrack,
type PlausibleEventOptions,
} from '@plausible-analytics/tracker';
import type { VibeId } from './vibes';
let isInitialized = false;
const track = (eventName: string, options: PlausibleEventOptions = {}) => {
try {
plausibleTrack(eventName, options);
} catch (error) {
console.warn(`Could not track analytics event "${eventName}".`, error);
}
};
export const initAnalytics = () => {
if (isInitialized) {
return;
}
try {
plausibleInit({
domain: 'schmelczer.dev/floating',
endpoint: 'https://stats.schmelczer.dev/status',
autoCapturePageviews: true,
logging: true,
});
isInitialized = true;
} catch (error) {
console.warn('Could not initialize analytics.', error);
}
};
export const trackVibeChange = ({
vibeId,
vibeName,
source,
}: {
vibeId: VibeId;
vibeName: string;
source: string;
}) => {
track('Vibe Change', {
props: {
vibeId,
vibeName,
source,
},
});
};
export const trackStart = () => {
track('Start');
};
export const trackExport = ({ vibeId }: { vibeId: VibeId }) => {
track('Export', {
props: {
format: 'png',
resolution: 'internal-buffer',
vibeId,
},
});
};

View file

@ -1,71 +1,119 @@
import { appConfig } from '../config';
type GardenAudioChordQuality = 'major' | 'minor';
import { DEFAULT_AUDIO_VOLUME } from '../consts';
import type { PianoNoteRole } from './garden-audio-types';
export interface GardenAudioChord {
rootOffset: number;
quality: GardenAudioChordQuality;
quality: 'major' | 'minor';
}
interface GardenAudioColorVoice {
scaleDegreeOffset: number;
velocityMultiplier: number;
panOffset: number;
export interface GardenAudioVibeSettings {
idleIntensity: number;
bpm: number;
rampUpIntensity: number;
rampUpTime: number;
noteLength: number;
notePitchOffset: number;
brightness: number;
}
export interface GardenAudioVibeProfile {
export interface GardenAudioVibeProfile extends GardenAudioVibeSettings {
rootMidi: number;
scale: Array<number>;
brightness: number;
delayTimeMultiplier: number;
progression: Array<GardenAudioChord>;
}
export interface GardenAudioConfig {
masterVolume: number;
fadeInSeconds: number;
updateRampSeconds: number;
highPassFrequencyHz: number;
fallbackVibeId: string;
compressor: {
thresholdDb: number;
kneeDb: number;
ratio: number;
attackSeconds: number;
releaseSeconds: number;
};
delay: {
timeSeconds: number;
feedback: number;
wetGain: number;
};
piano: {
maxVoices: number;
gain: number;
sustainSeconds: number;
sustainLevel: number;
releaseSeconds: number;
lowpassHz: number;
};
input: {
pressureFallback: number;
};
rhythm: {
bpm: number;
stepsPerBeat: number;
stepsPerBar: number;
lookaheadSeconds: number;
speedForFullEnergyPixelsPerSecond: number;
sparseActivity: number;
};
eraser: {
minIntervalSeconds: number;
noiseGain: number;
filterMinHz: number;
filterMaxHz: number;
};
colorVoices: [GardenAudioColorVoice, GardenAudioColorVoice, GardenAudioColorVoice];
vibes: Record<string, GardenAudioVibeProfile>;
}
export const defaultGardenAudioVibeSettings: GardenAudioVibeSettings = {
idleIntensity: 0.08,
bpm: 74,
rampUpIntensity: 0.85,
rampUpTime: 0.08,
noteLength: 0.42,
notePitchOffset: 0,
brightness: 1,
};
export const gardenAudioConfig: GardenAudioConfig = appConfig.audio;
export const createGardenAudioConfig = () => ({
masterVolume: DEFAULT_AUDIO_VOLUME,
fadeInSeconds: 0.45,
updateRampSeconds: 0.08,
delay: {
timeSeconds: 0.405,
feedback: 0.12,
wetGain: 0.044,
erasingActivity: 0.12,
activityFeedbackWeight: 0.08,
feedbackMax: 0.32,
feedbackMin: 0.04,
outputActivityWeight: 0.5,
outputBase: 0.65,
outputActivityDuck: 0.28,
timeRampSeconds: 0.12,
},
piano: {
maxVoices: 24,
gain: 0.48,
sustainSeconds: 0.42,
sustainLevel: 0.26,
releaseSeconds: 0.34,
lowpassHz: 7000,
gainAttackSeconds: 0.006,
lowpassMaxHz: 12000,
lowpassMinHz: 1400,
sustainBase: 0.45,
sustainVelocityRange: 0.55,
},
rhythm: {
idleIntensity: defaultGardenAudioVibeSettings.idleIntensity,
bpm: defaultGardenAudioVibeSettings.bpm,
stepsPerBeat: 4,
stepsPerBar: 16,
sparseActivity: 0.055,
},
eraser: {
minIntervalSeconds: 0.12,
noiseGain: 0.028,
filterMinHz: 650,
filterMaxHz: 3600,
durationSeconds: 0.08,
pan: 0,
pianoActivity: 0,
},
energy: {
decaySeconds: 0.9,
releaseSeconds: 1.15,
strokeDecaySeconds: 0.32,
},
graph: {
pianoBusGains: {
pad: 0.86,
support: 0.94,
texture: 0.88,
gesture: 1,
brush: 0.9,
stinger: 0.92,
} satisfies Record<PianoNoteRole, number>,
pianoBusActivityDucking: {
pad: 0.42,
support: 0.18,
texture: -0.06,
gesture: 0,
brush: -0.08,
stinger: 0,
} satisfies Record<PianoNoteRole, number>,
noiseBusGain: 0.72,
},
input: {
fullActivitySpeed: 0.86,
activityNoiseFloorSpeed: 0.025,
activityCurve: 0.74,
activitySoftCeiling: 0.96,
activityAttackSeconds: 0.055,
activityReleaseSeconds: 0.2,
minAudibleDistance: 0.0025,
manicActivityThreshold: 0.9,
manicReleaseThreshold: 0.76,
maniaSmoothingSeconds: 0.12,
},
});
export type GardenAudioConfig = ReturnType<typeof createGardenAudioConfig>;

View file

@ -1,49 +0,0 @@
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(appConfig.audioEngine);
energy.beginGesture(0);
energy.recordStroke(0.8, 0.1);
energy.update(0.1);
energy.update(0.2);
const levelBeforeLift = energy.getLevel();
expect(energy.getActivity()).toBeGreaterThan(0);
energy.endGesture();
expect(energy.getActivity()).toBe(0);
expect(energy.getLevel()).toBe(levelBeforeLift);
energy.update(0.3);
expect(energy.getLevel()).toBeLessThan(levelBeforeLift);
expect(energy.getLevel()).toBeGreaterThan(0);
});
it('uses recent stroke intensity rather than gesture duration alone', () => {
const energy = new GardenAudioEnergy(appConfig.audioEngine);
energy.beginGesture(0);
energy.recordStroke(1, 0.1);
energy.update(0.1);
energy.update(0.2);
const activeLevel = energy.getActivity();
energy.update(1.2);
expect(energy.getActivity()).toBeLessThan(activeLevel);
});
it('raises activity immediately when a stroke is recorded', () => {
const energy = new GardenAudioEnergy(appConfig.audioEngine);
energy.beginGesture(0);
energy.recordStroke(0.12, 0.05);
expect(energy.getActivity()).toBeGreaterThan(0.09);
});
});

View file

@ -1,7 +1,5 @@
import type { GardenAudioEngineConfig } from '../config';
import { clamp01 } from '../utils/clamp';
const STROKE_IMMEDIATE_ACTIVITY_SCALE = 0.85;
import { approach, clamp01 } from '../utils/math';
import type { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
export class GardenAudioEnergy {
private isGestureActive = false;
@ -9,7 +7,7 @@ export class GardenAudioEnergy {
private targetEnergy = 0;
private lastEnergyUpdateAt = 0;
public constructor(private readonly engineConfig: GardenAudioEngineConfig) {}
public constructor(private readonly config: GardenAudioConfig) {}
public beginGesture(now: number): void {
this.isGestureActive = true;
@ -21,17 +19,11 @@ export class GardenAudioEnergy {
this.targetEnergy = 0;
}
public recordStroke(strokeEnergy: number, now: number): void {
const energy = clamp01(strokeEnergy);
this.targetEnergy = Math.max(this.targetEnergy, energy);
public recordStroke(strokeEnergy: number, profile: GardenAudioVibeProfile): void {
this.targetEnergy = Math.max(this.targetEnergy, strokeEnergy);
if (this.isGestureActive) {
this.energy = Math.max(this.energy, energy * STROKE_IMMEDIATE_ACTIVITY_SCALE);
this.energy = Math.max(this.energy, strokeEnergy * profile.rampUpIntensity);
}
this.lastEnergyUpdateAt ||= now;
}
public recordEraserStroke(): void {
this.targetEnergy = 0;
}
public silence(): void {
@ -39,35 +31,26 @@ export class GardenAudioEnergy {
this.energy = 0;
}
public update(now: number): void {
public update(now: number, profile: GardenAudioVibeProfile): void {
if (this.lastEnergyUpdateAt <= 0) {
this.lastEnergyUpdateAt = now;
return;
}
const elapsedSeconds = Math.max(0, now - this.lastEnergyUpdateAt);
const elapsedSeconds = now - this.lastEnergyUpdateAt;
this.lastEnergyUpdateAt = now;
this.targetEnergy *= Math.exp(
-elapsedSeconds / this.engineConfig.energy.strokeDecaySeconds
-elapsedSeconds / this.config.energy.strokeDecaySeconds
);
const target = this.isGestureActive ? this.targetEnergy : 0;
let timeConstant = this.engineConfig.energy.decaySeconds;
let timeConstant = this.config.energy.decaySeconds;
if (!this.isGestureActive) {
timeConstant = this.engineConfig.energy.releaseSeconds;
timeConstant = this.config.energy.releaseSeconds;
} else if (target > this.energy) {
timeConstant = this.engineConfig.energy.attackSeconds;
timeConstant = profile.rampUpTime;
}
const amount = 1 - Math.exp(-elapsedSeconds / timeConstant);
this.energy += (target - this.energy) * amount;
}
public getActivity(): number {
if (!this.isGestureActive) {
return 0;
}
return this.getLevel();
this.energy = approach(this.energy, target, elapsedSeconds, timeConstant);
}
public getLevel(): number {

View file

@ -1,385 +1,75 @@
import type { GardenAudioEngineConfig } from '../config';
import { clamp, clamp01 } from '../utils/clamp';
import type {
GardenAudioColorIndex,
GardenAudioStroke,
GardenAudioTouchDown,
} from './garden-audio-types';
import { approach, clamp, clamp01, smoothstep } from '../utils/math';
import type { GardenAudioConfig } from './garden-audio-config';
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 activity = 0;
private maniaAmount = 0;
private peakActivity = 0;
private lastFrame: GardenAudioGestureFrame = DEFAULT_FRAME;
private isManic = false;
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 constructor(private readonly inputConfig: GardenAudioConfig['input']) {}
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 targetActivity = this.getTargetActivity(metrics);
const activityTimeConstant =
targetActivity > this.activity
? this.inputConfig.activityAttackSeconds
: this.inputConfig.activityReleaseSeconds;
this.activity = approach(
this.activity,
targetActivity,
metrics.elapsedSeconds,
activityTimeConstant
);
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,
});
if (this.activity >= this.inputConfig.manicActivityThreshold) {
this.isManic = true;
} else if (this.activity <= this.inputConfig.manicReleaseThreshold) {
this.isManic = false;
}
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)
const maniaTarget = this.isManic
? smoothstep(this.inputConfig.manicReleaseThreshold, 1, this.activity)
: 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 = approach(
this.maniaAmount,
maniaTarget,
metrics.elapsedSeconds,
this.inputConfig.maniaSmoothingSeconds
);
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 {
activity: this.activity,
maniaAmount: this.maniaAmount,
};
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.activity = 0;
this.maniaAmount = 0;
this.peakActivity = 0;
this.lastFrame = DEFAULT_FRAME;
this.isManic = false;
}
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
private getTargetActivity(metrics: GardenAudioStrokeMetrics): number {
const speedRange =
this.inputConfig.fullActivitySpeed - this.inputConfig.activityNoiseFloorSpeed;
const speedAmount = clamp01(
(metrics.normalizedSpeed - this.inputConfig.activityNoiseFloorSpeed) / speedRange
);
const degrees = (Math.acos(dot) * 180) / Math.PI;
return degrees > MIN_TURN_DEGREES;
}
const distanceAmount = clamp01(
metrics.normalizedDistance / this.inputConfig.minAudibleDistance
);
const activity = Math.pow(speedAmount, this.inputConfig.activityCurve);
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';
return clamp(activity * distanceAmount, 0, this.inputConfig.activitySoftCeiling);
}
}
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

@ -1,23 +1,62 @@
import type { GardenAudioEngineConfig } from '../config';
import { clamp } from '../utils/clamp';
import { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
import { clamp } from '../utils/math';
import type { GardenAudioConfig } from './garden-audio-config';
import type { PianoNoteRole } from './garden-audio-types';
type NavigatorWithAudioSession = Navigator & {
audioSession?: {
type:
| 'auto'
| 'playback'
| 'ambient'
| 'transient'
| 'transient-solo'
| 'play-and-record';
};
};
const outputHighPassFrequencyHz = 45;
const noiseBufferDurationSeconds = 1;
const graphTuning = {
closeGain: 0.0001,
closeRampSeconds: 0.015,
delayMaxSeconds: 2,
eventBusGain: 1,
noiseMax: 1,
noiseMin: -1,
latencyHint: 'interactive' as AudioContextLatencyCategory,
outputFilterType: 'highpass' as BiquadFilterType,
compressor: {
thresholdDb: -18,
kneeDb: 18,
ratio: 2.1,
attackSeconds: 0.018,
releaseSeconds: 0.18,
},
};
const delayFilterTuning = {
feedbackHighPassHz: 180,
feedbackLowPassHz: 5200,
returnLowPassHz: 6200,
};
export class GardenAudioGraph {
public context: AudioContext | null = null;
public eventBus: GainNode | null = null;
public delayInput: GainNode | null = null;
public noiseBus: GainNode | null = null;
public noiseBuffer: AudioBuffer | null = null;
private masterGain: GainNode | null = null;
private delayNode: DelayNode | null = null;
private delayFeedback: GainNode | null = null;
private delayOutput: GainNode | null = null;
private hasUnlocked = false;
private lastPianoBusActivity = 0;
private pianoBusGainScale = 1;
private pianoBusGainScaleAutomationUntil = 0;
private pianoBusGainScaleTimeConstantSeconds = 0;
private readonly pianoBuses = new Map<PianoNoteRole, GainNode>();
public constructor(
private readonly config: GardenAudioConfig,
private readonly engineConfig: GardenAudioEngineConfig
) {}
public constructor(private readonly config: GardenAudioConfig) {}
public ensureContext(canCreate: boolean): AudioContext | null {
if (this.context) {
@ -28,19 +67,34 @@ export class GardenAudioGraph {
return null;
}
const context = new AudioContext({ latencyHint: 'interactive' });
const AudioContextConstructor = globalThis.AudioContext;
if (!AudioContextConstructor) {
return null;
}
// Tells iOS to treat this as media playback, so the hardware ringer/mute
// switch does not silence Web Audio output. No-op on browsers without the
// Audio Session API.
const audioSession = (navigator as NavigatorWithAudioSession).audioSession;
if (audioSession) {
audioSession.type = 'playback';
}
const context = new AudioContextConstructor({
latencyHint: graphTuning.latencyHint,
});
const masterGain = context.createGain();
const highPass = context.createBiquadFilter();
const compressor = context.createDynamicsCompressor();
masterGain.gain.value = 0;
highPass.type = 'highpass';
highPass.frequency.value = this.config.highPassFrequencyHz;
compressor.threshold.value = this.config.compressor.thresholdDb;
compressor.knee.value = this.config.compressor.kneeDb;
compressor.ratio.value = this.config.compressor.ratio;
compressor.attack.value = this.config.compressor.attackSeconds;
compressor.release.value = this.config.compressor.releaseSeconds;
highPass.type = graphTuning.outputFilterType;
highPass.frequency.value = outputHighPassFrequencyHz;
compressor.threshold.value = graphTuning.compressor.thresholdDb;
compressor.knee.value = graphTuning.compressor.kneeDb;
compressor.ratio.value = graphTuning.compressor.ratio;
compressor.attack.value = graphTuning.compressor.attackSeconds;
compressor.release.value = graphTuning.compressor.releaseSeconds;
masterGain.connect(highPass);
highPass.connect(compressor);
@ -55,26 +109,6 @@ export class GardenAudioGraph {
return context;
}
// iOS WebKit (Safari + Chrome iOS) only fully unlocks audio output once
// a buffer source has been started inside a user-gesture handler. Calling
// resume() alone leaves the context "running" but silent.
public unlock(): void {
if (!this.context || this.hasUnlocked) {
return;
}
const buffer = this.context.createBuffer(
1,
this.engineConfig.graph.unlockBufferLength,
this.engineConfig.graph.unlockSampleRate
);
const source = this.context.createBufferSource();
source.buffer = buffer;
source.connect(this.context.destination);
source.start(0);
this.hasUnlocked = true;
}
public setMasterGain(targetGain: number, timeConstantSeconds: number): void {
if (!this.context || !this.masterGain) {
return;
@ -87,46 +121,67 @@ export class GardenAudioGraph {
);
}
public applyDelayProfile(profile: GardenAudioVibeProfile): void {
public applyDelayProfile(): void {
if (!this.context || !this.delayNode) {
return;
}
this.delayNode.delayTime.setTargetAtTime(
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
this.config.delay.timeSeconds,
this.context.currentTime,
this.engineConfig.graph.delayTimeRampSeconds
this.config.delay.timeRampSeconds
);
}
public updateDelay(profile: GardenAudioVibeProfile, activity: number): void {
public updateDelay(activity: number): void {
if (!this.context || !this.delayNode || !this.delayFeedback || !this.delayOutput) {
return;
}
const now = this.context.currentTime;
const normalizedActivity = clamp(activity, 0, 1);
this.delayNode.delayTime.setTargetAtTime(
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
this.config.delay.timeSeconds,
now,
this.engineConfig.graph.delayTimeRampSeconds
this.config.delay.timeRampSeconds
);
this.delayFeedback.gain.setTargetAtTime(
clamp(
this.config.delay.feedback +
activity * this.engineConfig.graph.delayActivityFeedbackWeight,
this.engineConfig.graph.delayFeedbackMin,
this.engineConfig.graph.delayFeedbackMax
normalizedActivity * this.config.delay.activityFeedbackWeight,
this.config.delay.feedbackMin,
this.config.delay.feedbackMax
),
now,
this.config.updateRampSeconds
);
this.delayOutput.gain.setTargetAtTime(
this.config.delay.wetGain *
(this.engineConfig.graph.delayOutputBase +
activity * this.engineConfig.graph.delayOutputActivityWeight),
(this.config.delay.outputBase +
normalizedActivity * this.config.delay.outputActivityWeight) *
(1 - normalizedActivity * this.config.delay.outputActivityDuck),
now,
this.config.updateRampSeconds
);
this.updatePianoBusGains(normalizedActivity, now);
}
public getPianoBus(role: PianoNoteRole | undefined): GainNode | null {
return this.pianoBuses.get(role ?? 'gesture') ?? this.eventBus;
}
public setPianoBusGainScale(targetScale: number, timeConstantSeconds: number): void {
if (!this.context) {
this.pianoBusGainScale = clamp(targetScale, 0, 1);
return;
}
const now = this.context.currentTime;
this.pianoBusGainScale = clamp(targetScale, 0, 1);
this.pianoBusGainScaleTimeConstantSeconds = timeConstantSeconds;
this.pianoBusGainScaleAutomationUntil = now + timeConstantSeconds * 4;
this.updatePianoBusGains(this.lastPianoBusActivity, now, timeConstantSeconds);
}
public async close(): Promise<void> {
@ -137,9 +192,9 @@ export class GardenAudioGraph {
if (this.masterGain && context.state !== 'closed') {
this.masterGain.gain.setTargetAtTime(
this.engineConfig.graph.closeGain,
graphTuning.closeGain,
context.currentTime,
this.engineConfig.graph.closeRampSeconds
graphTuning.closeRampSeconds
);
}
@ -152,18 +207,30 @@ export class GardenAudioGraph {
private createDelay(context: AudioContext, masterGain: GainNode): void {
const delayInput = context.createGain();
const delayNode = context.createDelay(2);
const delayNode = context.createDelay(graphTuning.delayMaxSeconds);
const delayFeedback = context.createGain();
const delayOutput = context.createGain();
const feedbackHighPass = context.createBiquadFilter();
const feedbackLowPass = context.createBiquadFilter();
const returnLowPass = context.createBiquadFilter();
delayNode.delayTime.value = this.config.delay.timeSeconds;
delayFeedback.gain.value = this.config.delay.feedback;
delayOutput.gain.value = this.config.delay.wetGain;
feedbackHighPass.type = 'highpass';
feedbackHighPass.frequency.value = delayFilterTuning.feedbackHighPassHz;
feedbackLowPass.type = 'lowpass';
feedbackLowPass.frequency.value = delayFilterTuning.feedbackLowPassHz;
returnLowPass.type = 'lowpass';
returnLowPass.frequency.value = delayFilterTuning.returnLowPassHz;
delayInput.connect(delayNode);
delayNode.connect(delayFeedback);
delayNode.connect(feedbackHighPass);
feedbackHighPass.connect(feedbackLowPass);
feedbackLowPass.connect(delayFeedback);
delayFeedback.connect(delayNode);
delayNode.connect(delayOutput);
delayNode.connect(returnLowPass);
returnLowPass.connect(delayOutput);
delayOutput.connect(masterGain);
this.delayInput = delayInput;
@ -173,20 +240,61 @@ export class GardenAudioGraph {
}
private createBuses(context: AudioContext, masterGain: GainNode): void {
this.eventBus = context.createGain();
this.eventBus.gain.value = this.engineConfig.graph.eventBusGain;
this.eventBus.connect(masterGain);
const eventBus = context.createGain();
eventBus.gain.value = graphTuning.eventBusGain;
eventBus.connect(masterGain);
this.eventBus = eventBus;
this.pianoBuses.clear();
(Object.keys(this.config.graph.pianoBusGains) as Array<PianoNoteRole>).forEach(
(role) => {
const bus = context.createGain();
bus.gain.value = this.config.graph.pianoBusGains[role];
bus.connect(eventBus);
this.pianoBuses.set(role, bus);
}
);
this.noiseBus = context.createGain();
this.noiseBus.gain.value = this.config.graph.noiseBusGain;
this.noiseBus.connect(eventBus);
}
private updatePianoBusGains(
activity: number,
now: number,
timeConstantSeconds?: number
): void {
const effectiveTimeConstantSeconds =
timeConstantSeconds ??
(now < this.pianoBusGainScaleAutomationUntil
? this.pianoBusGainScaleTimeConstantSeconds
: this.config.updateRampSeconds);
this.lastPianoBusActivity = activity;
this.pianoBuses.forEach((bus, role) => {
const baseGain = this.config.graph.pianoBusGains[role];
const ducking = this.config.graph.pianoBusActivityDucking[role];
bus.gain.setTargetAtTime(
Math.max(0, baseGain * (1 - activity * ducking) * this.pianoBusGainScale),
now,
effectiveTimeConstantSeconds
);
});
}
private createNoiseBuffer(context: AudioContext): AudioBuffer {
const buffer = context.createBuffer(1, context.sampleRate, context.sampleRate);
const buffer = context.createBuffer(
1,
Math.floor(context.sampleRate * noiseBufferDurationSeconds),
context.sampleRate
);
const data = buffer.getChannelData(0);
for (let index = 0; index < data.length; index++) {
data[index] =
this.engineConfig.graph.noiseMin +
Math.random() *
(this.engineConfig.graph.noiseMax - this.engineConfig.graph.noiseMin);
graphTuning.noiseMin +
Math.random() * (graphTuning.noiseMax - graphTuning.noiseMin);
}
return buffer;
@ -196,11 +304,16 @@ export class GardenAudioGraph {
this.context = null;
this.eventBus = null;
this.delayInput = null;
this.noiseBus = null;
this.noiseBuffer = null;
this.masterGain = null;
this.delayNode = null;
this.delayFeedback = null;
this.delayOutput = null;
this.hasUnlocked = false;
this.lastPianoBusActivity = 0;
this.pianoBusGainScale = 1;
this.pianoBusGainScaleAutomationUntil = 0;
this.pianoBusGainScaleTimeConstantSeconds = 0;
this.pianoBuses.clear();
}
}

View file

@ -1,77 +1,27 @@
import type { GardenAudioEngineConfig } from '../config';
import { clamp01 } from '../utils/clamp';
import { GardenAudioStroke } from './garden-audio-types';
import type { GardenAudioStroke } from './garden-audio-types';
const minElapsedSeconds = 0.001;
export interface GardenAudioStrokeMetrics {
distancePixels: number;
pressure: number;
speedPixelsPerSecond: number;
speedAmount: number;
effectiveEnergy: number;
elapsedSeconds: number;
normalizedDistance: number;
normalizedSpeed: number;
}
export const getStrokeMetrics = (
stroke: GardenAudioStroke,
speedForFullEnergyPixelsPerSecond: number,
fallbackPressure: number,
inputConfig: GardenAudioEngineConfig['input']
): GardenAudioStrokeMetrics => {
export const getStrokeMetrics = (stroke: GardenAudioStroke): GardenAudioStrokeMetrics => {
const dx = stroke.to[0] - stroke.from[0];
const dy = stroke.to[1] - stroke.from[1];
const distancePixels = Math.hypot(dx, dy);
const speedPixelsPerSecond = getStrokeVelocity(stroke, distancePixels, inputConfig);
const pressure = getPressureAmount(stroke, fallbackPressure, inputConfig);
const speedAmount = clamp01(speedPixelsPerSecond / speedForFullEnergyPixelsPerSecond);
const strokeEnergy = clamp01(
inputConfig.strokeEnergyBase +
speedAmount * inputConfig.strokeEnergySpeedWeight +
pressure * inputConfig.strokeEnergyPressureWeight
const elapsedSeconds = Math.max(minElapsedSeconds, stroke.elapsedSeconds ?? 0);
const normalizationPixels = Math.max(
1,
Math.min(stroke.canvasSize[0], stroke.canvasSize[1])
);
const effectiveEnergy =
strokeEnergy *
(inputConfig.distanceEnergyBase +
clamp01(distancePixels / inputConfig.distanceForFullEnergyPixels) *
inputConfig.distanceEnergyScale);
const normalizedDistance = distancePixels / normalizationPixels;
return {
distancePixels,
pressure,
speedPixelsPerSecond,
speedAmount,
effectiveEnergy,
elapsedSeconds,
normalizedDistance,
normalizedSpeed: normalizedDistance / elapsedSeconds,
};
};
const getStrokeVelocity = (
stroke: GardenAudioStroke,
distancePixels: number,
inputConfig: GardenAudioEngineConfig['input']
): number => {
if (
stroke.velocityPixelsPerSecond !== undefined &&
Number.isFinite(stroke.velocityPixelsPerSecond) &&
stroke.velocityPixelsPerSecond >= 0
) {
return stroke.velocityPixelsPerSecond;
}
return distancePixels / inputConfig.fallbackFrameSeconds;
};
const getPressureAmount = (
stroke: GardenAudioStroke,
fallbackPressure: number,
inputConfig: GardenAudioEngineConfig['input']
): number => {
if (
stroke.pressure !== undefined &&
Number.isFinite(stroke.pressure) &&
stroke.pressure > 0
) {
return clamp01(stroke.pressure);
}
return stroke.pointerType === 'pen'
? Math.max(inputConfig.penMinPressure, clamp01(fallbackPressure))
: clamp01(fallbackPressure);
};

View file

@ -1,39 +1,34 @@
import { VibePreset } from '../vibes';
import {
GardenAudioChord,
GardenAudioConfig,
GardenAudioVibeProfile,
} from './garden-audio-config';
import { GardenAudioColorIndex } from './garden-audio-types';
import type { VibePreset } from '../vibes';
import type { GardenAudioChord, GardenAudioVibeProfile } from './garden-audio-config';
export const normalizeColorIndex = (index: number): GardenAudioColorIndex =>
Math.max(0, Math.min(2, Math.round(index))) as GardenAudioColorIndex;
export const PITCH_SEMITONES_PER_OCTAVE = 12;
export const getVibeProfile = (
config: GardenAudioConfig,
vibe: VibePreset
): GardenAudioVibeProfile =>
config.vibes[vibe.id] ??
config.vibes[config.fallbackVibeId] ??
Object.values(config.vibes)[0];
const DEFAULT_PROGRESSION: ReadonlyArray<GardenAudioChord> = [
{ rootOffset: 0, quality: 'major' },
{ rootOffset: 9, quality: 'minor' },
{ rootOffset: 5, quality: 'major' },
{ rootOffset: 7, quality: 'major' },
];
export const getChordIntervals = (
chord: GardenAudioChord,
openVoicing: boolean
): Array<number> => {
if (openVoicing) {
return chord.quality === 'major' ? [0, 7, 12, 16] : [0, 7, 12, 15];
const DEFAULT_ROOT_MIDI = 57;
const DEFAULT_SCALE: ReadonlyArray<number> = [0, 2, 4, 7, 9];
const profileCache = new WeakMap<VibePreset, GardenAudioVibeProfile>();
export const getVibeProfile = (vibe: VibePreset): GardenAudioVibeProfile => {
let profile = profileCache.get(vibe);
if (!profile) {
profile = {
...vibe.audio,
rootMidi: DEFAULT_ROOT_MIDI + vibe.audio.notePitchOffset,
scale: DEFAULT_SCALE as Array<number>,
progression: DEFAULT_PROGRESSION as Array<GardenAudioChord>,
};
profileCache.set(vibe, profile);
return profile;
}
return chord.quality === 'major' ? [0, 4, 7, 12, 16] : [0, 3, 7, 12, 15];
};
export const degreeToSemitone = (
profile: GardenAudioVibeProfile,
degree: number
): number => {
const scaleIndex =
((degree % profile.scale.length) + profile.scale.length) % profile.scale.length;
const octave = Math.floor(degree / profile.scale.length);
return profile.scale[scaleIndex] + octave * 12;
Object.assign(profile, vibe.audio);
profile.rootMidi = DEFAULT_ROOT_MIDI + vibe.audio.notePitchOffset;
return profile;
};

View file

@ -1,12 +1,8 @@
import { VibePreset } from '../vibes';
export type GardenAudioColorIndex = 0 | 1 | 2;
export interface GardenAudioSnapshot {
vibe: VibePreset;
selectedColorIndex: number;
isErasing: boolean;
mirrorSegmentCount?: number;
}
export interface GardenAudioStroke {
@ -14,28 +10,8 @@ export interface GardenAudioStroke {
from: ArrayLike<number>;
to: ArrayLike<number>;
canvasSize: ArrayLike<number>;
colorIndex: number;
isErasing: boolean;
pressure?: number;
velocityPixelsPerSecond?: number;
elapsedSeconds?: number;
eraserSizePixels?: number;
mirrorSegmentCount?: number;
pointerType?: string;
}
export interface GardenAudioTouchDown {
vibe: VibePreset;
colorIndex: number;
position?: ArrayLike<number>;
canvasSize?: ArrayLike<number>;
mirrorSegmentCount?: number;
pressure?: number;
pointerType?: string;
}
export interface GardenAudioStartOptions {
userGesture?: boolean;
elapsedSeconds: number;
}
export interface LoadedPianoSample {
@ -43,23 +19,26 @@ export interface LoadedPianoSample {
buffer: AudioBuffer;
}
export interface ActivePianoVoice {
gain: GainNode;
source: AudioBufferSourceNode;
startAt: number;
stopAt: number;
}
export interface PianoNote {
midi: number;
velocity: number;
startTime: number;
durationSeconds: number;
pan: number;
role?: PianoNoteRole;
delaySend?: number;
lowpassHz?: number;
sustainSeconds?: number;
}
export type PianoNoteRole =
| 'pad'
| 'support'
| 'texture'
| 'gesture'
| 'brush'
| 'stinger';
export interface NoiseBurst {
startTime: number;
durationSeconds: number;

View file

@ -1,220 +0,0 @@
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';
const calls = {
constructed: 0,
resumed: 0,
sourcesStarted: 0,
};
let contextState: AudioContextState = 'suspended';
class FakeAudioParam {
public value = 0;
public setTargetAtTime = vi.fn();
public setValueAtTime = vi.fn();
public exponentialRampToValueAtTime = vi.fn();
public cancelScheduledValues = vi.fn();
}
class FakeAudioNode {
public readonly gain = new FakeAudioParam();
public readonly frequency = new FakeAudioParam();
public readonly Q = new FakeAudioParam();
public readonly threshold = new FakeAudioParam();
public readonly knee = new FakeAudioParam();
public readonly ratio = new FakeAudioParam();
public readonly attack = new FakeAudioParam();
public readonly release = new FakeAudioParam();
public readonly delayTime = new FakeAudioParam();
public readonly pan = new FakeAudioParam();
public type = '';
public addEventListener = vi.fn();
public connect = vi.fn();
public disconnect = vi.fn();
}
class FakeAudioBuffer {
private readonly data: Float32Array;
public constructor(length: number) {
this.data = new Float32Array(length);
}
public getChannelData(): Float32Array {
return this.data;
}
}
class FakeAudioContext {
public readonly currentTime = 1;
public readonly sampleRate = 16;
public readonly destination = new FakeAudioNode() as unknown as AudioDestinationNode;
public constructor() {
calls.constructed += 1;
}
public get state(): AudioContextState {
return contextState;
}
public set state(state: AudioContextState) {
contextState = state;
}
public createGain(): GainNode {
return new FakeAudioNode() as unknown as GainNode;
}
public createBiquadFilter(): BiquadFilterNode {
return new FakeAudioNode() as unknown as BiquadFilterNode;
}
public createDynamicsCompressor(): DynamicsCompressorNode {
return new FakeAudioNode() as unknown as DynamicsCompressorNode;
}
public createDelay(): DelayNode {
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;
}
public createBufferSource(): AudioBufferSourceNode {
const node = new FakeAudioNode() as unknown as AudioBufferSourceNode & {
buffer: AudioBuffer | null;
start: () => void;
stop: () => void;
};
node.buffer = null;
node.start = vi.fn(() => {
calls.sourcesStarted += 1;
});
node.stop = vi.fn();
return node;
}
public async resume(): Promise<void> {
calls.resumed += 1;
contextState = 'running';
}
}
const makeConfig = (): GardenAudioConfig => ({
...gardenAudioConfig,
});
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')));
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('does not create an AudioContext from passive audio paths', () => {
const audio = new GardenAudio(
makeConfig(),
appConfig.audioEngine,
appConfig.simulation.maxMirrorSegmentCount
);
const vibe = VIBE_PRESETS[0];
audio.start(vibe);
audio.stroke({
vibe,
from: [0, 0],
to: [12, 0],
canvasSize: [100, 100],
colorIndex: 0,
isErasing: false,
});
expect(calls.constructed).toBe(0);
});
it('only resumes a suspended context from a user gesture start', () => {
const audio = new GardenAudio(
makeConfig(),
appConfig.audioEngine,
appConfig.simulation.maxMirrorSegmentCount
);
const vibe = VIBE_PRESETS[0];
audio.start(vibe, { userGesture: true });
expect(calls.constructed).toBe(1);
expect(calls.resumed).toBe(1);
expect(contextState).toBe('running');
contextState = 'suspended';
audio.start(vibe);
audio.setMuted(false);
expect(calls.resumed).toBe(1);
});
it('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,29 +1,24 @@
import type { GardenAudioEngineConfig } from '../config';
import { clamp, clamp01 } from '../utils/clamp';
import { VibePreset } from '../vibes';
import { GardenAudioConfig } from './garden-audio-config';
import { ErrorHandler, Severity } from '../utils/error-handler';
import { clamp01 } from '../utils/math';
import type { VibeId, VibePreset } from '../vibes';
import type { GardenAudioConfig } from './garden-audio-config';
import { GardenAudioEnergy } from './garden-audio-energy';
import { GardenAudioGestureState } from './garden-audio-gesture-state';
import { GardenAudioGraph } from './garden-audio-graph';
import { getStrokeMetrics } from './garden-audio-input';
import { getVibeProfile, normalizeColorIndex } from './garden-audio-music';
import type {
GardenAudioColorIndex,
GardenAudioSnapshot,
GardenAudioStartOptions,
GardenAudioStroke,
GardenAudioTouchDown,
} from './garden-audio-types';
import { getVibeProfile } from './garden-audio-music';
import type { GardenAudioSnapshot, GardenAudioStroke } from './garden-audio-types';
import { GenerativePianoEngine } from './generative-piano';
import { NoiseBurstPlayer } from './noise-burst-player';
import { PianoSampler } from './piano-sampler';
export type {
GardenAudioSnapshot,
GardenAudioStartOptions,
GardenAudioStroke,
GardenAudioTouchDown,
} from './garden-audio-types';
type AudioLifecycle = 'idle' | 'started' | 'destroyed';
const muteGain = 0.0001;
const muteRampSeconds = 0.02;
const brushUpPianoBusFadeSeconds = 2.4;
const brushUpPianoBusFadeSettleSeconds = 3.2;
const vibeChangeStingerMinIntervalSeconds = 0.45;
export class GardenAudio {
private readonly graph: GardenAudioGraph;
@ -33,100 +28,181 @@ export class GardenAudio {
private readonly gestureState: GardenAudioGestureState;
private readonly pianoEngine: GenerativePianoEngine;
private currentVibeId: string | null = null;
private hasStarted = false;
private isDestroyed = false;
private currentVibeId: VibeId | null = null;
private lifecycle: AudioLifecycle = 'idle';
private isReleasingPiano = false;
private isMuted = false;
private isGestureActive = false;
private selectedColorIndex: GardenAudioColorIndex = 0;
private hasQueuedPianoLoad = false;
private fadePianoAt: number | null = null;
private masterVolume: number;
private stopPianoAt: number | null = null;
private lastEraserAt = Number.NEGATIVE_INFINITY;
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
private startRequestId = 0;
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 constructor(private readonly config: GardenAudioConfig) {
this.masterVolume = clamp01(config.masterVolume);
this.graph = new GardenAudioGraph(config);
this.piano = new PianoSampler(config, this.graph);
this.noise = new NoiseBurstPlayer(this.graph);
this.energy = new GardenAudioEnergy(config);
this.gestureState = new GardenAudioGestureState(config.input);
this.pianoEngine = new GenerativePianoEngine(config, (note) => this.piano.play(note));
}
public start(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
if (this.isDestroyed || this.isMuted) {
public start(vibe: VibePreset, options: { userGesture?: boolean } = {}): void {
const isUserGesture = options.userGesture === true;
if (this.lifecycle === 'destroyed') {
return;
}
const context = this.graph.ensureContext(options.userGesture === true);
if (
this.lifecycle === 'started' &&
this.currentVibeId === vibe.id &&
this.graph.context?.state === 'running'
) {
return;
}
const context = this.graph.ensureContext(isUserGesture);
if (!context) {
return;
}
if (options.userGesture === true) {
this.graph.unlock();
}
const startupRampSeconds = isUserGesture
? muteRampSeconds
: this.config.fadeInSeconds;
const needsResume = context.state !== 'running' && context.state !== 'closed';
if (context.state === 'suspended') {
if (options.userGesture !== true) {
if (needsResume) {
if (!isUserGesture) {
return;
}
void context.resume().catch(() => undefined);
void context
.resume()
.then(() => {
if (this.graph.context === context && this.lifecycle !== 'destroyed') {
this.completeStart(vibe, { context, startupRampSeconds });
}
})
.catch((error) => {
ErrorHandler.addException(error, {
fallbackMessage: 'Could not resume audio playback.',
severity: Severity.WARNING,
});
});
return;
}
this.hasStarted = true;
this.applyVibe(vibe);
this.pianoEngine.prime(context.currentTime);
this.graph.setMasterGain(
this.config.masterVolume,
options.userGesture === true
? this.engineConfig.muteRampSeconds
: this.config.fadeInSeconds
);
this.completeStart(vibe, { context, startupRampSeconds });
}
if (!this.hasQueuedPianoLoad) {
this.hasQueuedPianoLoad = true;
void this.piano.load(context).then(() => {
if (this.graph.context === context && !this.isDestroyed) {
this.pianoEngine.cue(context.currentTime);
private completeStart(
vibe: VibePreset,
{
context,
startupRampSeconds,
}: {
context: AudioContext;
startupRampSeconds: number;
}
): void {
if (this.graph.context !== context || this.lifecycle === 'destroyed') {
return;
}
if (this.isMuted) {
this.graph.setMasterGain(muteGain, muteRampSeconds);
return;
}
const startRequestId = ++this.startRequestId;
void this.piano
.load(context)
.then(() => {
if (!this.canCompleteStart(context, startRequestId)) {
return;
}
this.activateStart(vibe, context, startupRampSeconds, true);
})
.catch((error) => {
if (this.canCompleteStart(context, startRequestId)) {
this.activateStart(vibe, context, startupRampSeconds, false);
}
ErrorHandler.addException(error, {
fallbackMessage: 'Could not load piano samples.',
severity: Severity.WARNING,
});
});
}
private canCompleteStart(context: AudioContext, startRequestId: number): boolean {
return (
this.graph.context === context &&
this.lifecycle !== 'destroyed' &&
!this.isMuted &&
this.startRequestId === startRequestId
);
}
private activateStart(
vibe: VibePreset,
context: AudioContext,
startupRampSeconds: number,
cuePiano: boolean
): void {
this.lifecycle = 'started';
this.currentVibeId = vibe.id;
this.graph.applyDelayProfile();
this.graph.setMasterGain(this.masterVolume, startupRampSeconds);
if (cuePiano) {
this.pianoEngine.cue(context.currentTime, getVibeProfile(vibe));
}
}
public changeVibe(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
public changeVibe(vibe: VibePreset, options: { userGesture?: boolean } = {}): void {
const previousVibeId = this.currentVibeId;
this.start(vibe, options);
const didChangeVibe = previousVibeId !== null && previousVibeId !== vibe.id;
if (didChangeVibe) {
this.piano.stopAll();
}
const context = this.graph.context;
if (
context &&
(context.state === 'running' || options.userGesture === true) &&
!this.isMuted &&
!this.isDestroyed &&
previousVibeId !== null &&
previousVibeId !== vibe.id
this.lifecycle !== 'destroyed' &&
didChangeVibe
) {
this.playVibeChangeStinger(vibe);
}
}
public setMuted(isMuted: boolean): void {
if (this.isMuted === isMuted) {
return;
}
this.isMuted = isMuted;
this.graph.setMasterGain(
isMuted ? this.engineConfig.muteGain : this.config.masterVolume,
isMuted ? this.engineConfig.muteRampSeconds : this.config.fadeInSeconds
isMuted ? muteGain : this.masterVolume,
isMuted ? muteRampSeconds : this.config.fadeInSeconds
);
}
public setMasterVolume(masterVolume: number): void {
this.masterVolume = clamp01(masterVolume);
if (!this.isMuted) {
this.graph.setMasterGain(this.masterVolume, this.config.updateRampSeconds);
}
}
public beginGesture(): void {
const context = this.graph.context;
if (!context) {
@ -134,86 +210,60 @@ export class GardenAudio {
}
this.isGestureActive = true;
this.gestureState.beginGesture();
this.isReleasingPiano = false;
this.fadePianoAt = null;
this.stopPianoAt = null;
this.graph.setPianoBusGainScale(1, this.config.fadeInSeconds);
this.gestureState.reset();
this.energy.beginGesture(context.currentTime);
this.pianoEngine.beginGesture();
}
public endGesture(): void {
this.gestureState.endGesture();
this.gestureState.reset();
this.isGestureActive = false;
this.isReleasingPiano = true;
this.fadePianoAt = null;
this.stopPianoAt = null;
this.energy.endGesture();
this.pianoEngine.endGesture();
}
public touchDown(touch: GardenAudioTouchDown): void {
if (this.isDestroyed || this.isMuted) {
return;
}
const context = this.graph.context;
if (!context || !this.isGestureActive) {
return;
}
this.selectedColorIndex = normalizeColorIndex(touch.colorIndex);
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({
vibe: touch.vibe,
now: context.currentTime,
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,
});
}
public update(snapshot: GardenAudioSnapshot): void {
const context = this.graph.context;
if (!this.hasStarted || !context || this.isMuted) {
if (this.lifecycle !== 'started' || !context || this.isMuted) {
return;
}
this.applyVibe(snapshot.vibe);
this.selectedColorIndex = normalizeColorIndex(snapshot.selectedColorIndex);
this.energy.update(context.currentTime);
const profile = getVibeProfile(snapshot.vibe);
this.energy.update(context.currentTime, profile);
if (snapshot.isErasing) {
this.energy.silence();
}
if (!this.isGestureActive && this.isReleasingPiano) {
this.updatePianoRelease(snapshot.vibe, context.currentTime);
this.updateDelay(snapshot);
return;
}
this.pianoEngine.renderLookahead({
vibe: snapshot.vibe,
now: context.currentTime,
activity: snapshot.isErasing ? 0 : this.energy.getLevel(),
selectedColorIndex: this.selectedColorIndex,
activity: snapshot.isErasing
? this.config.eraser.pianoActivity
: this.energy.getLevel(),
});
this.updateDelay(snapshot);
}
public stroke(stroke: GardenAudioStroke): void {
if (this.isDestroyed || this.isMuted) {
if (this.lifecycle !== 'started' || this.isMuted) {
return;
}
this.start(stroke.vibe);
const context = this.graph.context;
if (!context) {
return;
@ -222,44 +272,29 @@ export class GardenAudio {
return;
}
const metrics = getStrokeMetrics(
stroke,
this.config.rhythm.speedForFullEnergyPixelsPerSecond,
this.config.input.pressureFallback,
this.engineConfig.input
);
const metrics = getStrokeMetrics(stroke);
const now = context.currentTime;
this.selectedColorIndex = normalizeColorIndex(stroke.colorIndex);
const frame = this.gestureState.recordStroke({ metrics });
const strokeEnergy = frame.activity;
if (stroke.isErasing) {
this.energy.recordEraserStroke();
this.playEraser(stroke, metrics.speedAmount, metrics.pressure, now);
this.playEraser(strokeEnergy, now);
return;
}
const mirrorAmount = this.getMirrorAmount(stroke.mirrorSegmentCount ?? 1);
const frame = this.gestureState.recordStroke({ stroke, metrics, mirrorAmount });
const strokeEnergy = frame.activity;
this.energy.recordStroke(strokeEnergy, now);
const profile = getVibeProfile(stroke.vibe);
this.energy.recordStroke(strokeEnergy, profile);
this.pianoEngine.recordStroke({
vibe: stroke.vibe,
now,
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,
});
}
public async destroy(): Promise<void> {
this.isDestroyed = true;
this.lifecycle = 'destroyed';
await this.graph.close();
this.piano.reset();
@ -267,10 +302,10 @@ export class GardenAudio {
this.gestureState.reset();
this.pianoEngine.reset();
this.currentVibeId = null;
this.hasStarted = false;
this.isGestureActive = false;
this.selectedColorIndex = 0;
this.hasQueuedPianoLoad = false;
this.isReleasingPiano = false;
this.fadePianoAt = null;
this.stopPianoAt = null;
this.lastEraserAt = Number.NEGATIVE_INFINITY;
this.lastVibeStingerAt = Number.NEGATIVE_INFINITY;
}
@ -282,10 +317,7 @@ export class GardenAudio {
}
const now = context.currentTime;
if (
now - this.lastVibeStingerAt <
this.engineConfig.vibeChangeStingerMinIntervalSeconds
) {
if (now - this.lastVibeStingerAt < vibeChangeStingerMinIntervalSeconds) {
return;
}
@ -293,46 +325,48 @@ export class GardenAudio {
this.pianoEngine.playVibeChangeStinger(vibe, now);
}
private playEraser(
stroke: GardenAudioStroke,
speedAmount: number,
pressure: number,
now: number
): void {
private updatePianoRelease(vibe: VibePreset, now: number): void {
if (this.fadePianoAt === null && this.stopPianoAt === null) {
this.fadePianoAt = this.pianoEngine.release(vibe, now);
}
if (this.fadePianoAt !== null && now >= this.fadePianoAt) {
this.graph.setPianoBusGainScale(0, brushUpPianoBusFadeSeconds);
this.fadePianoAt = null;
this.stopPianoAt = now + brushUpPianoBusFadeSettleSeconds;
}
if (this.stopPianoAt !== null && now >= this.stopPianoAt) {
this.piano.stopAll();
this.pianoEngine.reset();
this.stopPianoAt = null;
this.isReleasingPiano = false;
}
}
private playEraser(activity: number, now: number): void {
if (!this.graph.context) {
return;
}
const sizeAmount = clamp01(
(stroke.eraserSizePixels ?? this.engineConfig.eraser.defaultSizePixels) /
Math.max(
1,
stroke.canvasSize[0] * this.engineConfig.eraser.canvasWidthRatioForFullSize
)
);
const x = clamp01(stroke.to[0] / Math.max(1, stroke.canvasSize[0]));
const distanceActivity = clamp01(activity);
if (distanceActivity <= 0) {
return;
}
const filterHz =
this.config.eraser.filterMinHz +
(this.config.eraser.filterMaxHz - this.config.eraser.filterMinHz) *
clamp01(
speedAmount * this.engineConfig.eraser.filterSpeedWeight +
pressure * this.engineConfig.eraser.filterPressureWeight +
sizeAmount * this.engineConfig.eraser.filterSizeWeight
);
distanceActivity;
if (now - this.lastEraserAt >= this.config.eraser.minIntervalSeconds) {
this.lastEraserAt = now;
this.noise.play({
startTime: now,
durationSeconds: this.engineConfig.eraser.durationSeconds,
gain:
this.config.eraser.noiseGain *
(this.engineConfig.eraser.gainBase +
speedAmount * this.engineConfig.eraser.gainSpeedWeight +
pressure * this.engineConfig.eraser.gainPressureWeight +
sizeAmount * this.engineConfig.eraser.gainSizeWeight),
durationSeconds: this.config.eraser.durationSeconds,
gain: this.config.eraser.noiseGain * distanceActivity,
filterHz,
pan: clamp(x * 2 - 1, -1, 1),
pan: this.config.eraser.pan,
});
}
}
@ -343,11 +377,10 @@ export class GardenAudio {
return;
}
const profile = getVibeProfile(this.config, snapshot.vibe);
const activity = snapshot.isErasing
? this.engineConfig.delay.erasingActivity
? this.config.delay.erasingActivity
: this.energy.getLevel();
this.graph.updateDelay(profile, activity);
this.graph.updateDelay(activity);
}
private applyVibe(vibe: VibePreset): void {
@ -356,35 +389,8 @@ export class GardenAudio {
}
this.currentVibeId = vibe.id;
this.graph.applyDelayProfile(getVibeProfile(this.config, vibe));
this.pianoEngine.cue(this.graph.context.currentTime);
}
private getMirrorAmount(mirrorSegmentCount: number): number {
const maxMirrorSegmentCount = Math.max(1, this.maxMirrorSegmentCount);
const segmentCount = clamp(
Number.isFinite(mirrorSegmentCount) ? mirrorSegmentCount : 1,
1,
maxMirrorSegmentCount
);
if (maxMirrorSegmentCount <= 1) {
return 0;
}
return clamp01((segmentCount - 1) / (maxMirrorSegmentCount - 1));
}
private getTouchPressure(pressure: number | undefined, pointerType?: string): number {
if (pressure !== undefined && Number.isFinite(pressure) && pressure > 0) {
return clamp01(pressure);
}
return pointerType === 'pen'
? Math.max(
this.engineConfig.input.penMinPressure,
this.config.input.pressureFallback
)
: this.config.input.pressureFallback;
const profile = getVibeProfile(vibe);
this.graph.applyDelayProfile();
this.pianoEngine.cue(this.graph.context.currentTime, profile);
}
}

View file

@ -0,0 +1,431 @@
export interface GardenAudioRegister {
midiMin: number;
midiMax: number;
preferredMidi: number;
pan: number;
}
export interface GardenAudioStylePool extends GardenAudioRegister {
scaleDegrees: Array<number>;
}
interface GardenAudioStyleVoice {
scaleDegreeOffset: number;
velocityMultiplier: number;
panOffset: number;
}
interface GenerativePianoTuning {
stylePools: [GardenAudioStylePool, GardenAudioStylePool, GardenAudioStylePool];
padRegisters: [GardenAudioRegister, GardenAudioRegister, GardenAudioRegister];
vibeChangeStinger: {
velocities: [number, number, number];
pans: [number, number, number];
delaySends: [number, number, number];
lowpassExpression: number;
noteDurationSeconds: number;
spacingSeconds: number;
};
releaseResolution: {
durationSeconds: number;
fadeAfterSeconds: number;
velocities: [number, number, number];
delaySend: number;
lowpassExpression: number;
strumSeconds: number;
};
highActivityExtra: {
barOffset: number;
expressionMultiplier: number;
};
padChord: {
velocities: [number, number, number];
expressionVelocityWeight: number;
delaySend: number;
lowpassExpressionWeight: number;
};
supportNote: {
velocityBase: number;
velocityExpressionWeight: number;
durationBaseSeconds: number;
durationExpressionSeconds: number;
delaySendBase: number;
delaySendExpressionWeight: number;
lowpassExpressionWeight: number;
expressionThreshold: number;
offsetsByStyle: [Array<number>, Array<number>, Array<number>];
};
textureNote: {
velocityBase: number;
velocityExpressionWeight: number;
durationBaseSeconds: number;
durationExpressionSeconds: number;
delaySendBase: number;
delaySendExpressionWeight: number;
idleExpressionThreshold: number;
mediumExpressionThreshold: number;
intenseSpacing: number;
idlePhase: number;
};
gestureAccent: {
rotationStrengthMultiplier: number;
quantizeStepLookahead: number;
velocityBase: number;
velocityStrengthWeight: number;
durationBaseSeconds: number;
durationStrengthSeconds: number;
delaySend: number;
};
touchNote: {
velocityBase: number;
velocityStrengthWeight: number;
durationBaseSeconds: number;
durationStrengthSeconds: number;
delaySend: number;
lowpassBaseExpression: number;
lowpassStrengthWeight: number;
};
brushPhrase: {
initialMotifOffset: number;
energyDecaySeconds: number;
maniaDecaySeconds: number;
layerIntensityBase: number;
layerIntensityManiaWeight: number;
frameActivityWeight: number;
frameManiaWeight: number;
};
brushStream: {
inferredManiaThreshold: number;
inferredManiaRange: number;
registerManiaShift: number;
chordToneEverySteps: number;
durationBaseSeconds: number;
durationIntensitySeconds: number;
durationManiaSeconds: number;
durationMinSeconds: number;
durationMaxSeconds: number;
delaySendBase: number;
delaySendIntensityWeight: number;
delaySendManiaWeight: number;
delaySendMin: number;
delaySendMax: number;
velocityBase: number;
velocityIntensityWeight: number;
lowpassBaseExpression: number;
lowpassIntensityWeight: number;
lowpassManiaWeight: number;
intenseThreshold: number;
activeThreshold: number;
};
brushStreamEcho: {
maniaThreshold: number;
stepModulo: number;
stepRemainder: number;
intensityThreshold: number;
octaveSemitones: number;
maxMidi: number;
velocityBase: number;
velocityIntensityWeight: number;
durationMinSeconds: number;
durationScale: number;
panScale: number;
delaySendMin: number;
delaySendScale: number;
lowpassBaseExpression: number;
lowpassManiaWeight: number;
};
brushMotif: {
highThreshold: number;
mediumThreshold: number;
highOffset: number;
mediumOffset: number;
lowOffset: number;
};
registerBias: {
maniaShiftSemitones: number;
midiMin: number;
midiMaxForMin: number;
minimumSpan: number;
midiMax: number;
};
candidateOctaveSearch: {
min: number;
max: number;
};
stylePanOffsetScale: number;
lowpass: {
midiBase: number;
midiRange: number;
midiLiftHz: number;
expressionBase: number;
expressionWeight: number;
};
styleRotationBars: number;
chordBars: number;
supportBarSpacing: number;
supportBarOffset: number;
idleTextureBarSpacing: number;
mediumTextureBarSpacing: number;
textureBeat: number;
highActivityExtraBeat: number;
highActivityExtraThreshold: number;
noteScorePreferenceWeight: number;
noteScoreRegisterWeight: number;
noteScoreChordToneWeight: number;
noteScoreRepeatPenalty: number;
gestureAccentMinIntervalSeconds: number;
strokeAccentMinSteps: number;
strokeAccentThreshold: number;
maxBrushPhraseLayers: number;
maxBrushStreamNotesPerBar: number;
brushLayerBaseSeconds: number;
brushLayerEnergySeconds: number;
brushLayerMinIntensity: number;
brushStreamIdleIntervalBeats: number;
brushStreamActiveIntervalBeats: number;
brushStreamIntenseIntervalBeats: number;
brushMotifMaxSteps: number;
brushMotifCanonDelaySeconds: number;
padDurationBarScale: number;
}
export const generativePianoTuning: GenerativePianoTuning = {
stylePools: [
{
midiMin: 48,
midiMax: 67,
preferredMidi: 55,
pan: -0.18,
scaleDegrees: [0, 1, 2, 4],
},
{
midiMin: 55,
midiMax: 74,
preferredMidi: 63,
pan: 0,
scaleDegrees: [1, 2, 3, 5],
},
{
midiMin: 62,
midiMax: 78,
preferredMidi: 70,
pan: 0.18,
scaleDegrees: [2, 3, 4, 6],
},
],
padRegisters: [
{
midiMin: 40,
midiMax: 55,
preferredMidi: 48,
pan: -0.12,
},
{
midiMin: 48,
midiMax: 64,
preferredMidi: 55,
pan: 0.08,
},
{
midiMin: 58,
midiMax: 76,
preferredMidi: 67,
pan: 0.2,
},
],
vibeChangeStinger: {
velocities: [0.1, 0.085, 0.07],
pans: [-0.16, 0, 0.16],
delaySends: [0.012, 0.014, 0.016],
lowpassExpression: 0.35,
noteDurationSeconds: 1.1,
spacingSeconds: 0.08,
},
releaseResolution: {
durationSeconds: 3.4,
fadeAfterSeconds: 2.4,
velocities: [0.064, 0.05, 0.038],
delaySend: 0.018,
lowpassExpression: 0.34,
strumSeconds: 0.055,
},
highActivityExtra: {
barOffset: 1,
expressionMultiplier: 0.9,
},
padChord: {
velocities: [0.046, 0.036, 0.029],
expressionVelocityWeight: 0.018,
delaySend: 0.008,
lowpassExpressionWeight: 0.24,
},
supportNote: {
velocityBase: 0.105,
velocityExpressionWeight: 0.07,
durationBaseSeconds: 1.35,
durationExpressionSeconds: 0.4,
delaySendBase: 0.016,
delaySendExpressionWeight: 0.006,
lowpassExpressionWeight: 0.7,
expressionThreshold: 0.55,
offsetsByStyle: [
[0, 2, 12],
[1, 2, 0, 12],
[2, 12, 3, 13],
],
},
textureNote: {
velocityBase: 0.09,
velocityExpressionWeight: 0.08,
durationBaseSeconds: 0.62,
durationExpressionSeconds: 0.24,
delaySendBase: 0.016,
delaySendExpressionWeight: 0.006,
idleExpressionThreshold: 0.35,
mediumExpressionThreshold: 0.7,
intenseSpacing: 1,
idlePhase: 1,
},
gestureAccent: {
rotationStrengthMultiplier: 3,
quantizeStepLookahead: 1,
velocityBase: 0.12,
velocityStrengthWeight: 0.09,
durationBaseSeconds: 0.48,
durationStrengthSeconds: 0.22,
delaySend: 0.012,
},
touchNote: {
velocityBase: 0.14,
velocityStrengthWeight: 0.11,
durationBaseSeconds: 0.55,
durationStrengthSeconds: 0.18,
delaySend: 0.006,
lowpassBaseExpression: 0.55,
lowpassStrengthWeight: 0.35,
},
brushPhrase: {
initialMotifOffset: -1,
energyDecaySeconds: 0.72,
maniaDecaySeconds: 0.54,
layerIntensityBase: 0.8,
layerIntensityManiaWeight: 0.42,
frameActivityWeight: 0.42,
frameManiaWeight: 0.18,
},
brushStream: {
inferredManiaThreshold: 0.82,
inferredManiaRange: 0.18,
registerManiaShift: 0.3,
chordToneEverySteps: 4,
durationBaseSeconds: 0.48,
durationIntensitySeconds: 0.08,
durationManiaSeconds: 0.34,
durationMinSeconds: 0.14,
durationMaxSeconds: 0.62,
delaySendBase: 0.012,
delaySendIntensityWeight: 0.011,
delaySendManiaWeight: 0.006,
delaySendMin: 0.006,
delaySendMax: 0.032,
velocityBase: 0.1,
velocityIntensityWeight: 0.1,
lowpassBaseExpression: 0.39,
lowpassIntensityWeight: 0.48,
lowpassManiaWeight: 0.18,
intenseThreshold: 0.68,
activeThreshold: 0.34,
},
brushStreamEcho: {
maniaThreshold: 0.92,
stepModulo: 3,
stepRemainder: 1,
intensityThreshold: 0.95,
octaveSemitones: 12,
maxMidi: 84,
velocityBase: 0.035,
velocityIntensityWeight: 0.04,
durationMinSeconds: 0.11,
durationScale: 0.68,
panScale: -0.75,
delaySendMin: 0.006,
delaySendScale: 0.72,
lowpassBaseExpression: 0.62,
lowpassManiaWeight: 0.24,
},
brushMotif: {
highThreshold: 0.82,
mediumThreshold: 0.55,
highOffset: 1,
mediumOffset: 0,
lowOffset: -1,
},
registerBias: {
maniaShiftSemitones: 2,
midiMin: 36,
midiMaxForMin: 86,
minimumSpan: 4,
midiMax: 91,
},
candidateOctaveSearch: {
min: -3,
max: 3,
},
stylePanOffsetScale: 0.35,
lowpass: {
midiBase: 48,
midiRange: 33,
midiLiftHz: 500,
expressionBase: 0.58,
expressionWeight: 0.32,
},
styleRotationBars: 2,
chordBars: 4,
supportBarSpacing: 2,
supportBarOffset: 1,
idleTextureBarSpacing: 2,
mediumTextureBarSpacing: 1,
textureBeat: 2,
highActivityExtraBeat: 3,
highActivityExtraThreshold: 0.45,
noteScorePreferenceWeight: 1.8,
noteScoreRegisterWeight: 0.28,
noteScoreChordToneWeight: 0.75,
noteScoreRepeatPenalty: 3.2,
gestureAccentMinIntervalSeconds: 2.5,
strokeAccentMinSteps: 12,
strokeAccentThreshold: 0.58,
maxBrushPhraseLayers: 3,
maxBrushStreamNotesPerBar: 7,
brushLayerBaseSeconds: 5.5,
brushLayerEnergySeconds: 2.5,
brushLayerMinIntensity: 0.12,
brushStreamIdleIntervalBeats: 2,
brushStreamActiveIntervalBeats: 1,
brushStreamIntenseIntervalBeats: 0.75,
brushMotifMaxSteps: 8,
brushMotifCanonDelaySeconds: 0.055,
padDurationBarScale: 0.82,
};
export const styleVoices: [
GardenAudioStyleVoice,
GardenAudioStyleVoice,
GardenAudioStyleVoice,
] = [
{
scaleDegreeOffset: 0,
velocityMultiplier: 0.92,
panOffset: -0.14,
},
{
scaleDegreeOffset: 1,
velocityMultiplier: 1,
panOffset: 0,
},
{
scaleDegreeOffset: 2,
velocityMultiplier: 0.86,
panOffset: 0.14,
},
];

View file

@ -1,238 +0,0 @@
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';
import { GenerativePianoEngine } from './generative-piano';
const makeEngine = () => {
const notes: Array<PianoNote> = [];
const engine = new GenerativePianoEngine(
gardenAudioConfig,
appConfig.audioEngine,
(note) => {
notes.push(note);
}
);
return { engine, notes };
};
const getBeatSeconds = (): number => 60 / gardenAudioConfig.rhythm.bpm;
const getBeatsPerBar = (): number =>
Math.round(
gardenAudioConfig.rhythm.stepsPerBar / gardenAudioConfig.rhythm.stepsPerBeat
);
const renderBars = (
engine: GenerativePianoEngine,
activity: number,
selectedColorIndex = 0,
bars = 8
) => {
engine.renderLookahead({
vibe: VIBE_PRESETS[0],
now: 0,
activity,
selectedColorIndex: selectedColorIndex as 0 | 1 | 2,
lookaheadSeconds: getBeatSeconds() * getBeatsPerBar() * bars,
});
};
const average = (values: Array<number>): number =>
values.reduce((sum, value) => sum + value, 0) / values.length;
const uniqueStartTimes = (notes: Array<PianoNote>): Array<string> =>
Array.from(new Set(notes.map((note) => note.startTime.toFixed(3))));
const countNotesBetween = (
notes: Array<PianoNote>,
startSeconds: number,
endSeconds: number
): number =>
notes.filter((note) => note.startTime >= startSeconds && note.startTime < endSeconds)
.length;
describe('GenerativePianoEngine', () => {
it('plays quiet background music even when the garden is idle', () => {
const { engine, notes } = makeEngine();
renderBars(engine, 0);
expect(notes.length).toBeGreaterThan(0);
expect(notes.some((note) => note.durationSeconds > getBeatSeconds() * 6)).toBe(true);
expect(Math.max(...notes.map((note) => note.velocity))).toBeLessThan(0.12);
});
it('keeps the background sparse instead of filling every beat', () => {
const { engine, notes } = makeEngine();
renderBars(engine, 0, 1, 4);
expect(uniqueStartTimes(notes).length).toBeLessThan(8);
});
it('lets activity add density without changing the beat grid', () => {
const idle = makeEngine();
const active = makeEngine();
const startDelaySeconds = 0.02;
renderBars(idle.engine, 0, 1, 8);
renderBars(active.engine, 1, 1, 8);
expect(active.notes.length).toBeGreaterThan(idle.notes.length);
active.notes.forEach((note) => {
const beatsFromStart = (note.startTime - startDelaySeconds) / getBeatSeconds();
expect(Math.abs(beatsFromStart - Math.round(beatsFromStart))).toBeLessThan(0.001);
});
});
it('uses color pools with multiple notes instead of one key per color', () => {
([0, 1, 2] as const).forEach((selectedColorIndex) => {
const { engine, notes } = makeEngine();
renderBars(engine, 1, selectedColorIndex, 16);
expect(new Set(notes.map((note) => note.midi)).size).toBeGreaterThan(3);
});
});
it('keeps the upper color higher and wider than the lower color', () => {
const lower = makeEngine();
const upper = makeEngine();
renderBars(lower.engine, 1, 0, 16);
renderBars(upper.engine, 1, 2, 16);
expect(average(upper.notes.map((note) => note.midi))).toBeGreaterThan(
average(lower.notes.map((note) => note.midi))
);
expect(average(upper.notes.map((note) => note.pan))).toBeGreaterThan(
average(lower.notes.map((note) => note.pan))
);
});
it('starts a fading brush phrase layer with each new brush gesture', () => {
const baseline = makeEngine();
const layered = makeEngine();
const now = 4;
baseline.engine.renderLookahead({
vibe: VIBE_PRESETS[0],
now,
activity: 0.35,
selectedColorIndex: 1,
lookaheadSeconds: 12,
});
layered.engine.beginGesture();
layered.engine.recordStroke({
vibe: VIBE_PRESETS[0],
now,
activity: 0.85,
selectedColorIndex: 1,
mirrorAmount: 0.45,
});
layered.engine.renderLookahead({
vibe: VIBE_PRESETS[0],
now,
activity: 0.35,
selectedColorIndex: 1,
lookaheadSeconds: 12,
});
const earlyExtra =
countNotesBetween(layered.notes, now + 1, now + 5) -
countNotesBetween(baseline.notes, now + 1, now + 5);
const lateExtra =
countNotesBetween(layered.notes, now + 10.5, now + 12) -
countNotesBetween(baseline.notes, now + 10.5, now + 12);
expect(earlyExtra).toBeGreaterThan(2);
expect(lateExtra).toBe(0);
});
it('makes brush phrase layers denser at higher mirror amounts', () => {
const lowMirror = makeEngine();
const highMirror = makeEngine();
const now = 4;
lowMirror.engine.beginGesture();
lowMirror.engine.recordStroke({
vibe: VIBE_PRESETS[0],
now,
activity: 0.85,
selectedColorIndex: 2,
mirrorAmount: 0,
});
lowMirror.engine.renderLookahead({
vibe: VIBE_PRESETS[0],
now,
activity: 0.35,
selectedColorIndex: 2,
lookaheadSeconds: 9,
});
highMirror.engine.beginGesture();
highMirror.engine.recordStroke({
vibe: VIBE_PRESETS[0],
now,
activity: 0.85,
selectedColorIndex: 2,
mirrorAmount: 1,
});
highMirror.engine.renderLookahead({
vibe: VIBE_PRESETS[0],
now,
activity: 0.35,
selectedColorIndex: 2,
lookaheadSeconds: 9,
});
expect(highMirror.notes.length).toBeGreaterThan(lowMirror.notes.length);
});
it('plays one immediate touch note and throttles later stroke accents', () => {
const { engine, notes } = makeEngine();
const now = 4;
engine.beginGesture();
engine.recordStroke({
vibe: VIBE_PRESETS[0],
now,
activity: 0.9,
selectedColorIndex: 1,
});
engine.recordStroke({
vibe: VIBE_PRESETS[0],
now: now + 1,
activity: 0.95,
selectedColorIndex: 1,
});
expect(notes).toHaveLength(1);
expect(notes[0].startTime).toBe(now);
engine.recordStroke({
vibe: VIBE_PRESETS[0],
now: now + 6,
activity: 0.95,
selectedColorIndex: 1,
});
expect(notes).toHaveLength(2);
expect(new Set(notes.map((note) => note.midi)).size).toBeGreaterThan(1);
});
it('is deterministic for the same musical inputs', () => {
const first = makeEngine();
const second = makeEngine();
renderBars(first.engine, 0.78, 2, 16);
renderBars(second.engine, 0.78, 2, 16);
expect(second.notes).toEqual(first.notes);
});
});

File diff suppressed because it is too large Load diff

View file

@ -1,21 +1,27 @@
import type { GardenAudioEngineConfig } from '../config';
import { GardenAudioGraph } from './garden-audio-graph';
import { NoiseBurst } from './garden-audio-types';
import { clamp } from '../utils/math';
import type { GardenAudioGraph } from './garden-audio-graph';
import type { NoiseBurst } from './garden-audio-types';
const noiseBurstTuning = {
attackSeconds: 0.004,
filterQ: 1.4,
offsetRandomSeconds: 0.4,
scheduleAheadSeconds: 0.002,
silentGain: 0.0001,
filterType: 'bandpass' as BiquadFilterType,
};
export class NoiseBurstPlayer {
public constructor(
private readonly engineConfig: GardenAudioEngineConfig,
private readonly graph: GardenAudioGraph
) {}
public constructor(private readonly graph: GardenAudioGraph) {}
public play({ startTime, durationSeconds, gain, filterHz, pan }: NoiseBurst): void {
const { context, eventBus, noiseBuffer } = this.graph;
if (!context || !eventBus || !noiseBuffer) {
const { context, noiseBus, noiseBuffer } = this.graph;
if (!context || !noiseBus || !noiseBuffer) {
return;
}
const scheduledStart = Math.max(
context.currentTime + this.engineConfig.noiseBurst.scheduleAheadSeconds,
context.currentTime + noiseBurstTuning.scheduleAheadSeconds,
startTime
);
const source = context.createBufferSource();
@ -25,28 +31,21 @@ export class NoiseBurstPlayer {
const stopAt = scheduledStart + durationSeconds;
source.buffer = noiseBuffer;
filter.type = 'bandpass';
filter.type = noiseBurstTuning.filterType;
filter.frequency.setValueAtTime(filterHz, scheduledStart);
filter.Q.value = this.engineConfig.noiseBurst.filterQ;
envelope.gain.setValueAtTime(this.engineConfig.noiseBurst.silentGain, scheduledStart);
filter.Q.value = noiseBurstTuning.filterQ;
envelope.gain.setValueAtTime(noiseBurstTuning.silentGain, scheduledStart);
envelope.gain.exponentialRampToValueAtTime(
Math.max(this.engineConfig.noiseBurst.silentGain, gain),
scheduledStart + this.engineConfig.noiseBurst.attackSeconds
Math.max(noiseBurstTuning.silentGain, gain),
scheduledStart + noiseBurstTuning.attackSeconds
);
envelope.gain.exponentialRampToValueAtTime(
this.engineConfig.noiseBurst.silentGain,
stopAt
);
panner.pan.setValueAtTime(pan, scheduledStart);
envelope.gain.exponentialRampToValueAtTime(noiseBurstTuning.silentGain, stopAt);
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
source.connect(filter);
filter.connect(envelope);
envelope.connect(panner);
panner.connect(eventBus);
source.start(
scheduledStart,
Math.random() * this.engineConfig.noiseBurst.offsetRandomSeconds
);
panner.connect(noiseBus);
source.start(scheduledStart, Math.random() * noiseBurstTuning.offsetRandomSeconds);
source.stop(stopAt);
source.addEventListener(
'ended',

View file

@ -1,45 +1,52 @@
import type { GardenAudioEngineConfig } from '../config';
import { clamp, clamp01 } from '../utils/clamp';
import { GardenAudioConfig } from './garden-audio-config';
import { GardenAudioGraph } from './garden-audio-graph';
import { ActivePianoVoice, LoadedPianoSample, PianoNote } from './garden-audio-types';
import { pianoSampleDefinitions } from './piano-samples';
import { clamp, clamp01 } from '../utils/math';
import type { GardenAudioConfig } from './garden-audio-config';
import type { GardenAudioGraph } from './garden-audio-graph';
import { PITCH_SEMITONES_PER_OCTAVE } from './garden-audio-music';
import type { LoadedPianoSample, PianoNote } from './garden-audio-types';
import { getLoadedPianoSamples, loadPianoSamples } from './piano-samples';
export const PIANO_SCHEDULE_AHEAD_SECONDS = 0.002;
interface ActivePianoVoice {
gain: GainNode;
source: AudioScheduledSourceNode;
stopAt: number;
}
const pianoSamplerTuning = {
filterType: 'lowpass' as BiquadFilterType,
filterQ: 0.7,
minDurationSeconds: 0.08,
minFadeSeconds: 0.08,
minGain: 0.0001,
tailStopExtraSeconds: 0.05,
voiceStealFadeSeconds: 0.025,
voiceStealStopSeconds: 0.05,
};
export class PianoSampler {
private sampleLoadPromise: Promise<void> | null = null;
private samples: Array<LoadedPianoSample> = [];
private activeVoices: Array<ActivePianoVoice> = [];
public constructor(
private readonly config: GardenAudioConfig,
private readonly engineConfig: GardenAudioEngineConfig,
private readonly graph: GardenAudioGraph
) {}
public async load(context: AudioContext): Promise<void> {
if (this.sampleLoadPromise) {
return this.sampleLoadPromise;
public load(context: BaseAudioContext): Promise<void> {
if (this.samples.length > 0) {
return Promise.resolve();
}
this.sampleLoadPromise = Promise.all(
pianoSampleDefinitions.map(async (sample) => {
const response = await fetch(sample.url);
if (!response.ok) {
throw new Error(`Unable to load piano sample ${sample.url}`);
}
const audioData = await response.arrayBuffer();
const buffer = await context.decodeAudioData(audioData);
return { midi: sample.midi, buffer };
})
)
.then((samples) => {
this.samples = samples.sort((a, b) => a.midi - b.midi);
})
.catch(() => {
this.samples = [];
});
const loadedSamples = getLoadedPianoSamples();
if (loadedSamples) {
this.setSamples(loadedSamples);
return Promise.resolve();
}
return this.sampleLoadPromise;
return loadPianoSamples(context).then((samples) => {
this.setSamples(samples);
});
}
public play({
@ -48,10 +55,13 @@ export class PianoSampler {
startTime,
durationSeconds,
pan,
role,
delaySend = 0,
lowpassHz = this.config.piano.lowpassHz,
sustainSeconds: profileSustainSeconds = this.config.piano.sustainSeconds,
}: PianoNote): void {
const { context, eventBus, delayInput } = this.graph;
const { context } = this.graph;
const eventBus = this.graph.getPianoBus(role);
if (!context || !eventBus) {
return;
}
@ -62,25 +72,105 @@ export class PianoSampler {
}
const scheduledStart = Math.max(
context.currentTime + this.engineConfig.piano.scheduleAheadSeconds,
context.currentTime + PIANO_SCHEDULE_AHEAD_SECONDS,
startTime
);
const noteVelocity = clamp01(velocity);
const noteGainValue = Math.max(
this.engineConfig.piano.minGain,
this.config.piano.gain * noteVelocity
);
const noteGainValue = this.computeNoteGain(noteVelocity);
const sustainSeconds =
this.config.piano.sustainSeconds *
(this.engineConfig.piano.sustainBase +
noteVelocity * this.engineConfig.piano.sustainVelocityRange);
profileSustainSeconds *
(this.config.piano.sustainBase +
noteVelocity * this.config.piano.sustainVelocityRange);
const sustainAt =
scheduledStart +
Math.max(this.engineConfig.piano.minDurationSeconds, durationSeconds);
scheduledStart + Math.max(pianoSamplerTuning.minDurationSeconds, durationSeconds);
const releaseAt = sustainAt + sustainSeconds;
const releaseSeconds = this.config.piano.releaseSeconds;
const stopAt = releaseAt + releaseSeconds;
const stopAt = releaseAt + this.config.piano.releaseSeconds;
const source = context.createBufferSource();
source.buffer = sample.buffer;
source.playbackRate.setValueAtTime(
Math.pow(2, (midi - sample.midi) / PITCH_SEMITONES_PER_OCTAVE),
scheduledStart
);
this.scheduleVoice({
source,
scheduledStart,
stopAt,
pan,
lowpassHz,
delaySend,
eventBus,
configureGainEnvelope: (gain) => {
gain.gain.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart);
gain.gain.exponentialRampToValueAtTime(
noteGainValue,
scheduledStart + this.config.piano.gainAttackSeconds
);
gain.gain.setTargetAtTime(
Math.max(
pianoSamplerTuning.minGain,
noteGainValue * this.config.piano.sustainLevel
),
sustainAt,
Math.max(
pianoSamplerTuning.minFadeSeconds,
sustainSeconds * this.config.piano.sustainBase
)
);
gain.gain.setTargetAtTime(
pianoSamplerTuning.minGain,
releaseAt,
this.config.piano.releaseSeconds
);
},
});
}
public stopAll(): void {
const context = this.graph.context;
if (!context) {
this.activeVoices = [];
return;
}
const now = context.currentTime;
this.activeVoices.forEach((voice) => {
this.stopVoice(voice, now);
});
this.activeVoices = [];
}
public reset(): void {
this.samples = [];
this.activeVoices = [];
}
private scheduleVoice({
source,
scheduledStart,
stopAt,
pan,
lowpassHz,
delaySend,
eventBus,
configureGainEnvelope,
}: {
source: AudioScheduledSourceNode;
scheduledStart: number;
stopAt: number;
pan: number;
lowpassHz: number;
delaySend: number;
eventBus: GainNode;
configureGainEnvelope: (gain: GainNode) => void;
}): void {
const { context, delayInput } = this.graph;
if (!context) {
return;
}
const filter = context.createBiquadFilter();
const gain = context.createGain();
const panner = context.createStereoPanner();
@ -88,49 +178,17 @@ export class PianoSampler {
this.trimActiveVoices(scheduledStart);
while (this.activeVoices.length >= this.config.piano.maxVoices) {
const oldest = this.activeVoices.shift();
oldest?.gain.gain.cancelScheduledValues(scheduledStart);
oldest?.gain.gain.setTargetAtTime(
this.engineConfig.piano.minGain,
scheduledStart,
this.engineConfig.piano.voiceStealFadeSeconds
);
oldest?.source.stop(scheduledStart + this.engineConfig.piano.voiceStealStopSeconds);
this.stopVoice(this.activeVoices.shift() as ActivePianoVoice, scheduledStart);
}
source.buffer = sample.buffer;
source.playbackRate.setValueAtTime(
Math.pow(2, (midi - sample.midi) / this.engineConfig.piano.pitchSemitonesPerOctave),
scheduledStart
);
filter.type = 'lowpass';
filter.type = pianoSamplerTuning.filterType;
filter.frequency.setValueAtTime(
clamp(
lowpassHz,
this.engineConfig.piano.lowpassMinHz,
this.engineConfig.piano.lowpassMaxHz
),
clamp(lowpassHz, this.config.piano.lowpassMinHz, this.config.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(
Math.max(
this.engineConfig.piano.minGain,
noteGainValue * this.config.piano.sustainLevel
),
sustainAt,
Math.max(
this.engineConfig.piano.minFadeSeconds,
sustainSeconds * this.engineConfig.piano.sustainBase
)
);
gain.gain.setTargetAtTime(this.engineConfig.piano.minGain, releaseAt, releaseSeconds);
filter.Q.value = pianoSamplerTuning.filterQ;
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
configureGainEnvelope(gain);
source.connect(filter);
filter.connect(gain);
@ -145,8 +203,8 @@ export class PianoSampler {
}
source.start(scheduledStart);
source.stop(stopAt + this.engineConfig.piano.tailStopExtraSeconds);
this.activeVoices.push({ gain, source, startAt: scheduledStart, stopAt });
source.stop(stopAt + pianoSamplerTuning.tailStopExtraSeconds);
this.activeVoices.push({ gain, source, stopAt });
source.addEventListener(
'ended',
@ -162,10 +220,11 @@ export class PianoSampler {
);
}
public reset(): void {
this.sampleLoadPromise = null;
this.samples = [];
this.activeVoices = [];
private computeNoteGain(velocity: number, scale = 1): number {
return Math.max(
pianoSamplerTuning.minGain,
this.config.piano.gain * velocity * scale
);
}
private findNearestSample(midi: number): LoadedPianoSample | null {
@ -181,4 +240,21 @@ export class PianoSampler {
private trimActiveVoices(now: number): void {
this.activeVoices = this.activeVoices.filter((voice) => voice.stopAt > now);
}
private stopVoice(voice: ActivePianoVoice, now: number): void {
const stopAt = now + pianoSamplerTuning.voiceStealStopSeconds;
voice.gain.gain.cancelScheduledValues(now);
voice.gain.gain.setTargetAtTime(
pianoSamplerTuning.minGain,
now,
pianoSamplerTuning.voiceStealFadeSeconds
);
voice.stopAt = stopAt;
voice.source.stop(stopAt);
}
private setSamples(samples: Array<LoadedPianoSample>): void {
this.samples = samples.slice().sort((a, b) => a.midi - b.midi);
}
}

View file

@ -1,46 +1,207 @@
import type { LoadedPianoSample } from './garden-audio-types';
import a0SampleUrl from './samples/A0v12.m4a?url&no-inline';
import a1SampleUrl from './samples/A1v12.m4a?url&no-inline';
import a2SampleUrl from './samples/A2v12.m4a?url&no-inline';
import a3SampleUrl from './samples/A3v12.m4a?url&no-inline';
import a4SampleUrl from './samples/A4v12.m4a?url&no-inline';
import a5SampleUrl from './samples/A5v12.m4a?url&no-inline';
import a6SampleUrl from './samples/A6v12.m4a?url&no-inline';
import a7SampleUrl from './samples/A7v12.m4a?url&no-inline';
import c1SampleUrl from './samples/C1v12.m4a?url&no-inline';
import c2SampleUrl from './samples/C2v12.m4a?url&no-inline';
import c3SampleUrl from './samples/C3v12.m4a?url&no-inline';
import c4SampleUrl from './samples/C4v12.m4a?url&no-inline';
import c5SampleUrl from './samples/C5v12.m4a?url&no-inline';
import c6SampleUrl from './samples/C6v12.m4a?url&no-inline';
import c7SampleUrl from './samples/C7v12.m4a?url&no-inline';
import c8SampleUrl from './samples/C8v12.m4a?url&no-inline';
import dSharp1SampleUrl from './samples/Dsharp1v12.m4a?url&no-inline';
import dSharp2SampleUrl from './samples/Dsharp2v12.m4a?url&no-inline';
import dSharp3SampleUrl from './samples/Dsharp3v12.m4a?url&no-inline';
import dSharp4SampleUrl from './samples/Dsharp4v12.m4a?url&no-inline';
import dSharp5SampleUrl from './samples/Dsharp5v12.m4a?url&no-inline';
import dSharp6SampleUrl from './samples/Dsharp6v12.m4a?url&no-inline';
import dSharp7SampleUrl from './samples/Dsharp7v12.m4a?url&no-inline';
import fSharp1SampleUrl from './samples/Fsharp1v12.m4a?url&no-inline';
import fSharp2SampleUrl from './samples/Fsharp2v12.m4a?url&no-inline';
import fSharp3SampleUrl from './samples/Fsharp3v12.m4a?url&no-inline';
import fSharp4SampleUrl from './samples/Fsharp4v12.m4a?url&no-inline';
import fSharp5SampleUrl from './samples/Fsharp5v12.m4a?url&no-inline';
import fSharp6SampleUrl from './samples/Fsharp6v12.m4a?url&no-inline';
import fSharp7SampleUrl from './samples/Fsharp7v12.m4a?url&no-inline';
interface PianoSampleDefinition {
midi: number;
path: string;
url: string;
}
const sampleBaseUrl = `${import.meta.env.BASE_URL}audio/piano/`;
export interface PianoSampleLoadProgress {
loadedCount: number;
totalCount: number;
}
const sampleFiles: Array<[fileName: string, midi: number]> = [
['A0v12.m4a', 21],
['C1v12.m4a', 24],
['Dsharp1v12.m4a', 27],
['Fsharp1v12.m4a', 30],
['A1v12.m4a', 33],
['C2v12.m4a', 36],
['Dsharp2v12.m4a', 39],
['Fsharp2v12.m4a', 42],
['A2v12.m4a', 45],
['C3v12.m4a', 48],
['Dsharp3v12.m4a', 51],
['Fsharp3v12.m4a', 54],
['A3v12.m4a', 57],
['C4v12.m4a', 60],
['Dsharp4v12.m4a', 63],
['Fsharp4v12.m4a', 66],
['A4v12.m4a', 69],
['C5v12.m4a', 72],
['Dsharp5v12.m4a', 75],
['Fsharp5v12.m4a', 78],
['A5v12.m4a', 81],
['C6v12.m4a', 84],
['Dsharp6v12.m4a', 87],
['Fsharp6v12.m4a', 90],
['A6v12.m4a', 93],
['C7v12.m4a', 96],
['Dsharp7v12.m4a', 99],
['Fsharp7v12.m4a', 102],
['A7v12.m4a', 105],
['C8v12.m4a', 108],
const pianoSampleDefinitions: Array<PianoSampleDefinition> = [
{ url: a0SampleUrl, path: './samples/A0v12.m4a', midi: 21 },
{ url: c1SampleUrl, path: './samples/C1v12.m4a', midi: 24 },
{ url: dSharp1SampleUrl, path: './samples/Dsharp1v12.m4a', midi: 27 },
{ url: fSharp1SampleUrl, path: './samples/Fsharp1v12.m4a', midi: 30 },
{ url: a1SampleUrl, path: './samples/A1v12.m4a', midi: 33 },
{ url: c2SampleUrl, path: './samples/C2v12.m4a', midi: 36 },
{ url: dSharp2SampleUrl, path: './samples/Dsharp2v12.m4a', midi: 39 },
{ url: fSharp2SampleUrl, path: './samples/Fsharp2v12.m4a', midi: 42 },
{ url: a2SampleUrl, path: './samples/A2v12.m4a', midi: 45 },
{ url: c3SampleUrl, path: './samples/C3v12.m4a', midi: 48 },
{ url: dSharp3SampleUrl, path: './samples/Dsharp3v12.m4a', midi: 51 },
{ url: fSharp3SampleUrl, path: './samples/Fsharp3v12.m4a', midi: 54 },
{ url: a3SampleUrl, path: './samples/A3v12.m4a', midi: 57 },
{ url: c4SampleUrl, path: './samples/C4v12.m4a', midi: 60 },
{ url: dSharp4SampleUrl, path: './samples/Dsharp4v12.m4a', midi: 63 },
{ url: fSharp4SampleUrl, path: './samples/Fsharp4v12.m4a', midi: 66 },
{ url: a4SampleUrl, path: './samples/A4v12.m4a', midi: 69 },
{ url: c5SampleUrl, path: './samples/C5v12.m4a', midi: 72 },
{ url: dSharp5SampleUrl, path: './samples/Dsharp5v12.m4a', midi: 75 },
{ url: fSharp5SampleUrl, path: './samples/Fsharp5v12.m4a', midi: 78 },
{ url: a5SampleUrl, path: './samples/A5v12.m4a', midi: 81 },
{ url: c6SampleUrl, path: './samples/C6v12.m4a', midi: 84 },
{ url: dSharp6SampleUrl, path: './samples/Dsharp6v12.m4a', midi: 87 },
{ url: fSharp6SampleUrl, path: './samples/Fsharp6v12.m4a', midi: 90 },
{ url: a6SampleUrl, path: './samples/A6v12.m4a', midi: 93 },
{ url: c7SampleUrl, path: './samples/C7v12.m4a', midi: 96 },
{ url: dSharp7SampleUrl, path: './samples/Dsharp7v12.m4a', midi: 99 },
{ url: fSharp7SampleUrl, path: './samples/Fsharp7v12.m4a', midi: 102 },
{ url: a7SampleUrl, path: './samples/A7v12.m4a', midi: 105 },
{ url: c8SampleUrl, path: './samples/C8v12.m4a', midi: 108 },
];
export const pianoSampleDefinitions: Array<PianoSampleDefinition> = sampleFiles
.map(([fileName, midi]) => ({
midi,
url: `${sampleBaseUrl}${fileName}`,
}))
.sort((a, b) => a.midi - b.midi);
let loadedPianoSamples: Array<LoadedPianoSample> | null = null;
let pianoSampleLoadPromise: Promise<Array<LoadedPianoSample>> | null = null;
const sampleLoadTuning = {
concurrency: 4,
sampleTimeoutMs: 15_000,
};
export const preloadPianoSamples = (
onProgress?: (progress: PianoSampleLoadProgress) => void
): Promise<Array<LoadedPianoSample>> => {
const OfflineAudioContextConstructor = globalThis.OfflineAudioContext;
if (!OfflineAudioContextConstructor) {
return Promise.reject(
new Error('OfflineAudioContext is required to preload piano samples.')
);
}
// Decoding ignores these, but the constructor demands real numbers.
const decodeContext = new OfflineAudioContextConstructor(1, 1, 48_000);
return loadPianoSamples(decodeContext, onProgress);
};
export const loadPianoSamples = (
decodeContext: BaseAudioContext,
onProgress?: (progress: PianoSampleLoadProgress) => void
): Promise<Array<LoadedPianoSample>> => {
if (loadedPianoSamples) {
onProgress?.({
loadedCount: loadedPianoSamples.length,
totalCount: pianoSampleDefinitions.length,
});
return Promise.resolve([...loadedPianoSamples]);
}
if (pianoSampleLoadPromise) {
return pianoSampleLoadPromise;
}
let loadedCount = 0;
const totalCount = pianoSampleDefinitions.length;
onProgress?.({ loadedCount, totalCount });
pianoSampleLoadPromise = loadPianoSampleBatch(
pianoSampleDefinitions,
async (sample) => {
try {
return await withTimeout(
(signal) => loadPianoSample(decodeContext, sample, signal),
sampleLoadTuning.sampleTimeoutMs
);
} finally {
loadedCount += 1;
onProgress?.({ loadedCount, totalCount });
}
}
).then(
(samples) => {
loadedPianoSamples = samples.sort((a, b) => a.midi - b.midi);
if (loadedPianoSamples.length !== pianoSampleDefinitions.length) {
throw new Error(
`Loaded ${loadedPianoSamples.length}/${pianoSampleDefinitions.length} piano samples.`
);
}
return [...loadedPianoSamples];
},
(error: unknown) => {
pianoSampleLoadPromise = null;
throw error;
}
);
return pianoSampleLoadPromise;
};
export const getLoadedPianoSamples = (): Array<LoadedPianoSample> | null =>
loadedPianoSamples ? [...loadedPianoSamples] : null;
const loadPianoSample = async (
decodeContext: BaseAudioContext,
sample: PianoSampleDefinition,
signal: AbortSignal
): Promise<LoadedPianoSample> => {
const response = await fetch(sample.url, { signal });
if (!response.ok) {
throw new Error(`Unable to load piano sample ${sample.path}`);
}
const audioData = await response.arrayBuffer();
const buffer = await decodeContext.decodeAudioData(audioData);
return { midi: sample.midi, buffer };
};
const loadPianoSampleBatch = async (
samples: Array<PianoSampleDefinition>,
loadSample: (sample: PianoSampleDefinition) => Promise<LoadedPianoSample>
): Promise<Array<LoadedPianoSample>> => {
const results: Array<LoadedPianoSample> = [];
for (let index = 0; index < samples.length; index += sampleLoadTuning.concurrency) {
const batch = samples.slice(index, index + sampleLoadTuning.concurrency);
const batchResults = await Promise.all(batch.map((sample) => loadSample(sample)));
results.push(...batchResults);
}
return results;
};
const withTimeout = <T>(
operation: (signal: AbortSignal) => Promise<T>,
timeoutMs: number
): Promise<T> =>
new Promise((resolve, reject) => {
const controller = new AbortController();
const timeout = globalThis.setTimeout(() => {
controller.abort();
reject(new Error('Timed out while loading a piano sample.'));
}, timeoutMs);
operation(controller.signal).then(
(value) => {
globalThis.clearTimeout(timeout);
resolve(value);
},
(error: unknown) => {
globalThis.clearTimeout(timeout);
reject(error);
}
);
});

View file

@ -1,171 +1,34 @@
import { runtimeSettings } from './config/runtime-settings';
import { createGardenAudioConfig } from './audio/garden-audio-config';
import { defaultSettings } from './config/default-settings';
import { runtimeControls } from './config/runtime-controls';
import type { GardenAppConfig } from './config/types';
import { audioVibes, defaultVibeId, vibePresets } from './config/vibe-presets';
import { defaultVibeId, vibePresets } from './config/vibe-presets';
import { APP_STORAGE_KEYS, DEFAULT_AUDIO_VOLUME } from './consts';
export {
normalizeNumberControlValue,
normalizeRuntimeSettings,
} from './config/normalize-runtime-settings';
export type {
AgentColorInteractionSettings,
GardenAppConfig,
GardenAudioEngineConfig,
GardenRuntimeSettings,
GardenSimulationConfig,
GardenStorageConfig,
GardenVibeSettings,
NumberControlConfig,
RuntimeSettingControlConfig,
VibePreset,
} from './config/types';
export const appConfig = {
audio: {
masterVolume: 0.42,
fadeInSeconds: 0.45,
updateRampSeconds: 0.08,
highPassFrequencyHz: 45,
fallbackVibeId: defaultVibeId,
compressor: {
thresholdDb: -18,
kneeDb: 18,
ratio: 2.4,
attackSeconds: 0.006,
releaseSeconds: 0.18,
},
delay: {
timeSeconds: 0.46,
feedback: 0.12,
wetGain: 0.044,
},
piano: {
maxVoices: 24,
gain: 0.48,
sustainSeconds: 0.42,
sustainLevel: 0.32,
releaseSeconds: 0.24,
lowpassHz: 7600,
},
input: {
pressureFallback: 0.48,
},
rhythm: {
bpm: 74,
stepsPerBeat: 4,
stepsPerBar: 16,
lookaheadSeconds: 0.3,
speedForFullEnergyPixelsPerSecond: 1800,
sparseActivity: 0.055,
},
eraser: {
minIntervalSeconds: 0.12,
noiseGain: 0.028,
filterMinHz: 650,
filterMaxHz: 3600,
},
colorVoices: [
{
scaleDegreeOffset: 0,
velocityMultiplier: 0.92,
panOffset: -0.14,
},
{
scaleDegreeOffset: 1,
velocityMultiplier: 1,
panOffset: 0,
},
{
scaleDegreeOffset: 2,
velocityMultiplier: 0.86,
panOffset: 0.14,
},
],
vibes: audioVibes,
},
audioEngine: {
energy: {
attackSeconds: 0.08,
decaySeconds: 0.9,
releaseSeconds: 1.15,
strokeDecaySeconds: 0.32,
},
eraser: {
canvasWidthRatioForFullSize: 0.18,
defaultSizePixels: 96,
durationSeconds: 0.08,
filterPressureWeight: 0.26,
filterSizeWeight: 0.16,
filterSpeedWeight: 0.58,
gainBase: 0.45,
gainPressureWeight: 0.24,
gainSizeWeight: 0.18,
gainSpeedWeight: 0.38,
},
delay: {
erasingActivity: 0.12,
},
graph: {
closeGain: 0.0001,
closeRampSeconds: 0.015,
delayActivityFeedbackWeight: 0.08,
delayFeedbackMax: 0.32,
delayFeedbackMin: 0.04,
delayOutputActivityWeight: 0.5,
delayOutputBase: 0.65,
delayTimeRampSeconds: 0.12,
eventBusGain: 1,
noiseMax: 1,
noiseMin: -1,
unlockBufferLength: 1,
unlockSampleRate: 22050,
},
input: {
distanceEnergyBase: 0.34,
distanceEnergyScale: 0.66,
distanceForFullEnergyPixels: 140,
fallbackFrameSeconds: 1 / 60,
penMinPressure: 0.56,
strokeEnergyBase: 0.18,
strokeEnergyPressureWeight: 0.22,
strokeEnergySpeedWeight: 0.62,
},
muteGain: 0.0001,
muteRampSeconds: 0.02,
noiseBurst: {
attackSeconds: 0.004,
filterQ: 1.4,
offsetRandomSeconds: 0.4,
scheduleAheadSeconds: 0.002,
silentGain: 0.0001,
},
piano: {
filterQ: 0.7,
gainAttackSeconds: 0.006,
lowpassMaxHz: 12000,
lowpassMinHz: 1400,
minDurationSeconds: 0.08,
minFadeSeconds: 0.08,
minGain: 0.0001,
pitchSemitonesPerOctave: 12,
scheduleAheadSeconds: 0.002,
sustainBase: 0.45,
sustainVelocityRange: 0.55,
tailStopExtraSeconds: 0.05,
voiceStealFadeSeconds: 0.025,
voiceStealStopSeconds: 0.05,
},
startDelaySeconds: 0.02,
vibeChangeStingerMinIntervalSeconds: 0.45,
},
audio: createGardenAudioConfig(),
deltaTime: {
fpsExponentialDecayStrength: 0.01,
maxDeltaTimeSeconds: 1 / 30,
minDeltaTimeSeconds: 1 / 240,
},
export4k: {
exportSnapshot: {
bytesPerPixel: 4,
height: 2160,
jsHeapSafetyMultiplier: 1.5,
lowMemoryDeviceGiB: 2,
lowMemoryExportFraction: 0.08,
filenameExtension: 'png',
filenamePrefix: 'fleeting-garden',
filenameSuffix: '-snapshot',
mimeType: 'image/png',
rowAlignmentBytes: 256,
width: 3840,
},
menuHider: {
bottomRevealDistancePx: 96,
@ -173,6 +36,17 @@ export const appConfig = {
hideDelayMs: 3000,
},
pipelines: {
common: {
noiseChannelSeeds: [0, 1, 2, 3],
noiseClearValue: { r: 1, g: 1, b: 1, a: 1 },
noiseDrawInstanceCount: 1,
noiseDrawVertexCount: 3,
noiseHashMultiplier: 43758.5453123,
noiseHashX: 12.9898,
noiseHashY: 78.233,
noiseTextureFormat: 'r8unorm',
noiseTextureSize: 2048,
},
brush: {
maxLineCount: 240,
},
@ -180,33 +54,31 @@ export const appConfig = {
minDiffusionRate: 0.000001,
},
eraser: {
maxSegmentCount: 384,
maxTextureLineCount: 384,
segmentFloatCount: 4,
workgroupSize: 64,
},
},
runtimeSettings,
defaultSettings,
runtimeSettings: {
controls: runtimeControls,
},
simulation: {
budget: {
adaptiveCapDecreaseAgentsPerSecond: 50_000,
adaptiveCapMin: 500_000,
fpsHeadroom: 0.95,
fpsSmoothingNew: 0.06,
fpsSmoothingRetain: 0.94,
},
brushEffectFramesPerSecond: 60,
globalAgentCap: 10_000_000,
clearColor: { r: 0, g: 0, b: 0, a: 0 },
initialAgentCount: 180_000,
// How long the source map continues to be diffused after a brush stroke ends.
// 600 frames at ~60 FPS ≈ 10 seconds.
sourceActiveFramesAfterWrite: 600,
intro: {
angleJitterRadians: Math.PI * 0.08,
angleEaseEnd: 1,
angleEaseStart: 0.6,
circleMaxSideRatio: 0.46,
circleMinSideRatio: 0.32,
drawHintClass: 'draw-hint',
drawHintDelayMs: 3000,
durationSeconds: 4,
entryJitterSideRatio: 0.035,
fontScaleDown: 0.94,
fontFamily: '"Open Sans", sans-serif',
initialFontHeightRatio: 0.28,
initialFontWidthRatio: 0.19,
letterSpacingEm: 0.07,
@ -218,7 +90,12 @@ export const appConfig = {
minEntryJitterPx: 6,
minFontSizePx: 18,
minTargetJitterPx: 1,
pathEasing: 'easeOutQuad' as GardenAppConfig['simulation']['intro']['pathEasing'],
pathProgressEpsilon: 0.001,
radialJitterRatio: 0.35,
radialStartEpsilon: 0.001,
resizeMinimumRemainingSeconds: 1.4,
resizeSettleMs: 120,
targetDelayDistanceMultiplier: 0.12,
targetDelayMax: 0.22,
targetDelayRandomMultiplier: 0.06,
@ -232,21 +109,15 @@ export const appConfig = {
},
introMoveSpeedBaseMultiplier: 1.8,
introMoveSpeedProgressMultiplier: 0.35,
maxMirrorSegmentCount: 12,
stroke: {
angleJitterRadians: Math.PI * 0.7,
densityMultiplier: 110,
maxAgentCount: 2_400,
minAgentCount: 140,
},
},
storage: {
audioMutedKey: 'fleeting-garden:audio-muted',
vibeKey: 'fleeting-garden:vibe',
},
telemetry: {
enabled: false,
intervalMs: 1000,
audioMutedKey: APP_STORAGE_KEYS.audioMuted,
audioVolumeKey: APP_STORAGE_KEYS.audioVolume,
vibeKey: APP_STORAGE_KEYS.vibe,
},
toolbar: {
eraser: {
@ -259,6 +130,7 @@ export const appConfig = {
},
mirror: {
default: 1,
fallbackSegmentName: 'slices',
max: 12,
min: 1,
names: {
@ -274,13 +146,43 @@ export const appConfig = {
11: 'elevenths',
12: 'twelfths',
},
offLabel: 'Mirror off',
step: 1,
},
contrast: {
backgroundOpacityMax: 0.82,
brightLuminanceThreshold: 0.32,
brightWeight: 0.65,
bytesPerSample: 4,
contrastOffset: 0.05,
linearChannelBreakpoint: 0.03928,
linearChannelDivisor: 12.92,
linearChannelGamma: 2.4,
linearChannelOffset: 0.055,
linearChannelScale: 1.055,
lowContrastThreshold: 3,
lowContrastWeight: 1.8,
luminanceBase: 0.11,
luminanceBlueWeight: 0.0722,
luminanceGreenWeight: 0.7152,
luminanceRange: 0.28,
luminanceRedWeight: 0.2126,
sampleColumns: 13,
sampleIntervalMs: 300,
sampleRows: 7,
whiteContrastNumerator: 1.05,
},
volume: {
default: DEFAULT_AUDIO_VOLUME,
max: 1,
min: 0,
step: 0.01,
},
},
tuningPane: {
expandedDepth: 1,
showFpsOverlay: import.meta.env.DEV,
startHidden: true,
title: 'Garden Config',
title: 'Garden Settings',
},
vibes: {
defaultVibeId,

View file

@ -1,12 +1,6 @@
import type { AgentColorInteractionSettings, NumberControlConfig } from './types';
import type { NumberControlConfig } from './types';
const agentInteractionOptions: Record<string, number> = {
Follow: 1,
Avoid: -1,
Ignore: 0,
};
export const defaultColorInteractionSettings: AgentColorInteractionSettings = {
export const colorInteractionSettings = {
color1ToColor1: 1,
color1ToColor2: 0,
color1ToColor3: 0,
@ -18,52 +12,15 @@ export const defaultColorInteractionSettings: AgentColorInteractionSettings = {
color3ToColor3: 1,
};
const hashString = (value: string): number => {
let hash = 0x811c9dc5;
for (let i = 0; i < value.length; i++) {
hash ^= value.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return hash >>> 0;
};
const createSeededRandom = (seed: number): (() => number) => {
let state = seed;
return () => {
let value = (state += 0x6d2b79f5);
value = Math.imul(value ^ (value >>> 15), value | 1);
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
};
};
export const createColorInteractionSettings = (
seedSource: string
): AgentColorInteractionSettings => {
const random = createSeededRandom(hashString(seedSource));
const values = Object.values(agentInteractionOptions);
const randomInteraction = () =>
values[Math.floor(random() * values.length)] ??
defaultColorInteractionSettings.color1ToColor2;
return {
color1ToColor1: 1,
color1ToColor2: randomInteraction(),
color1ToColor3: randomInteraction(),
color2ToColor1: randomInteraction(),
color2ToColor2: 1,
color2ToColor3: randomInteraction(),
color3ToColor1: randomInteraction(),
color3ToColor2: randomInteraction(),
color3ToColor3: 1,
};
};
export const colorInteractionControl = (label: string): NumberControlConfig => ({
folder: 'Color Reactions',
label,
min: -1,
max: 1,
step: 1,
options: agentInteractionOptions,
options: {
'Move Toward': 1,
Ignore: 0,
'Move Away': -1,
},
});

View file

@ -0,0 +1,86 @@
import { colorInteractionSettings } from './color-interactions';
import { runtimeControls } from './runtime-controls';
import type { GardenAppConfig } from './types';
// Mirrors the historical render-scale cap so the default render area stays
// roughly equivalent to native rendering on high-DPR phones without the
// pipeline applying its own clamp. The slider can override freely.
const DEFAULT_DEVICE_PIXEL_RATIO_CAP = 2;
const INTERNAL_RENDER_AREA_BOUNDS = {
min: runtimeControls.internalRenderAreaMegapixels?.min ?? 0.5,
max: runtimeControls.internalRenderAreaMegapixels?.max ?? 16.6,
};
const computeDefaultInternalRenderAreaMegapixels = (): number => {
const rawDpr =
typeof window !== 'undefined' && Number.isFinite(window.devicePixelRatio)
? window.devicePixelRatio
: 1;
const dpr = Math.min(Math.max(rawDpr, 1), DEFAULT_DEVICE_PIXEL_RATIO_CAP);
const cssWidth = typeof window !== 'undefined' ? window.innerWidth : 1920;
const cssHeight = typeof window !== 'undefined' ? window.innerHeight : 1080;
const cssMegapixels = (Math.max(cssWidth, 1) * Math.max(cssHeight, 1)) / 1_000_000;
return Math.min(
INTERNAL_RENDER_AREA_BOUNDS.max,
Math.max(INTERNAL_RENDER_AREA_BOUNDS.min, dpr * dpr * cssMegapixels)
);
};
export const defaultSettings: GardenAppConfig['defaultSettings'] = {
...colorInteractionSettings,
selectedColorIndex: 0,
turnWhenLost: 0.8,
forwardRotationScale: 0.25,
sensorOffsetAngle: 32,
introNearDistanceMin: 28,
introNearDistanceInner: 4,
introNearSensorOffsetMultiplier: 0.75,
introTargetAngleBlend: 0.2,
introProgressCutoff: 0.999,
introTurnRateMultiplier: 3.4,
introRandomTurnMultiplier: 0.18,
introFarMoveMultiplier: 2.65,
introNearMoveMultiplier: 0.01,
introStepStopDistance: 0.5,
randomTimeScale: 0.34816,
diffusionRateTrails: 0.22,
decayRateBrush: 18,
diffusionDecayRateDivisor: 1000,
diffusionNeighborDivisor: 8,
brushDecayAlphaOffset: 1.001,
brushEffectDuration: 8,
brushCurveResolution: 12,
brushCurveMinBrushRadius: 1,
brushCurveMinSegmentSpacing: 4,
brushCurveMirrorResolutionExponent: 0.5,
brushCurveSegmentBrushRadiusRatio: 0.65,
brushSmoothingMinSampleDistance: 0.5,
strokeAngleJitterRadians: Math.PI * 0.7,
brushAlpha: 1,
brushDiscardThreshold: 0.02,
brushGrainNoiseScale: 22,
brushGrainNoiseOffsetX: 0.31,
brushGrainNoiseOffsetY: 0.67,
brushGrainMinStrength: 0.45,
brushGrainMaxStrength: 1,
eraserClearAlpha: 0,
eraserClearBlue: 0,
eraserClearGreen: 0,
eraserClearRed: 0,
eraserLineDistanceEpsilon: 0.0001,
eraserMaskAlphaThreshold: 0.5,
adaptiveCapInitial: 1_000_000,
adaptiveCapMin: 50_000,
internalRenderAreaMegapixels: computeDefaultInternalRenderAreaMegapixels(),
maxAgentCount: 700_000,
renderTraceNormalizationFloor: 1,
renderBrushColorBase: 1.2,
renderBrushColorStrengthMultiplier: 1.6,
};

View file

@ -0,0 +1,46 @@
import type {
GardenAppConfig,
GardenRuntimeSettings,
NumberControlConfig,
} from './types';
type RuntimeSettingControls = GardenAppConfig['runtimeSettings']['controls'];
export const normalizeNumberControlValue = (
value: number,
config: NumberControlConfig
): number => {
if (config.options) {
const optionValues = Object.values(config.options);
if (optionValues.includes(value)) {
return value;
}
return optionValues.includes(0) ? 0 : (optionValues[0] ?? config.min ?? 0);
}
const min = config.min ?? Number.NEGATIVE_INFINITY;
const max = config.max ?? Number.POSITIVE_INFINITY;
const fallbackValue = config.min ?? 0;
const finiteValue = Number.isFinite(value) ? value : fallbackValue;
const clampedValue = Math.min(max, Math.max(min, finiteValue));
return config.integer ? Math.round(clampedValue) : clampedValue;
};
export const normalizeRuntimeSettings = (
settings: GardenRuntimeSettings,
controls: RuntimeSettingControls
): GardenRuntimeSettings => {
const normalized = { ...settings };
(
Object.entries(controls) as Array<
[keyof GardenRuntimeSettings, NumberControlConfig | undefined]
>
).forEach(([key, config]) => {
if (config) {
normalized[key] = normalizeNumberControlValue(normalized[key], config);
}
});
return normalized;
};

View file

@ -0,0 +1,121 @@
import { colorInteractionControl } from './color-interactions';
import type { GardenAppConfig } from './types';
const formatPercent = (value: number): string => `${Math.round(value * 100)}%`;
const formatRadiansAsDegrees = (value: number): string =>
`${Math.round((value * 180) / Math.PI)} deg`;
export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
color1ToColor1: colorInteractionControl('Color 1 Follows Color 1'),
color1ToColor2: colorInteractionControl('Color 1 Follows Color 2'),
color1ToColor3: colorInteractionControl('Color 1 Follows Color 3'),
color2ToColor1: colorInteractionControl('Color 2 Follows Color 1'),
color2ToColor2: colorInteractionControl('Color 2 Follows Color 2'),
color2ToColor3: colorInteractionControl('Color 2 Follows Color 3'),
color3ToColor1: colorInteractionControl('Color 3 Follows Color 1'),
color3ToColor2: colorInteractionControl('Color 3 Follows Color 2'),
color3ToColor3: colorInteractionControl('Color 3 Follows Color 3'),
brushSize: {
folder: 'Brush',
label: 'Brush Size',
min: 1,
max: 60,
step: 0.25,
},
spawnPerPixel: {
folder: 'Brush',
label: 'Density',
min: 0.01,
max: 1,
step: 0.001,
},
strokeAngleJitterRadians: {
folder: 'Brush',
format: formatRadiansAsDegrees,
label: 'Spawn Spread',
min: 0,
max: Math.PI * 2,
step: 0.01,
},
sensorOffsetDistance: {
folder: 'Movement',
label: 'Sensor Reach',
min: 0,
max: 200,
step: 1,
},
moveSpeed: {
folder: 'Movement',
label: 'Travel Speed',
min: 10,
max: 500,
step: 1,
},
turnSpeed: {
folder: 'Movement',
label: 'Turning Speed',
min: 1,
max: 200,
step: 1,
},
forwardRotationScale: {
folder: 'Movement',
format: formatPercent,
label: 'Forward Focus',
min: 0,
max: 1,
step: 0.01,
},
turnWhenLost: {
folder: 'Movement',
label: 'Wander Turn',
min: 0,
max: 6.28,
step: 0.01,
},
individualTrailWeight: {
folder: 'Movement',
label: 'Trail Strength',
min: 0,
max: 1,
step: 0.001,
},
decayRateTrails: {
folder: 'Movement',
label: 'Trail Fade',
min: 800,
max: 1000,
step: 1,
},
clarity: {
folder: 'Look',
label: 'Sharpness',
min: 0.00001,
max: 1,
step: 0.001,
},
backgroundGrainStrength: {
folder: 'Look',
label: 'Background Grain',
min: 0,
max: 0.12,
step: 0.001,
},
maxAgentCount: {
folder: 'Performance',
integer: true,
label: 'Population Limit',
min: 0,
step: 10_000,
},
internalRenderAreaMegapixels: {
folder: 'Performance',
label: 'Render Quality (MP)',
min: 0.5,
max: 16.6,
step: 0.1,
},
};

View file

@ -1,206 +0,0 @@
import {
colorInteractionControl,
defaultColorInteractionSettings,
} from './color-interactions';
import type { GardenAppConfig } from './types';
export const runtimeSettings: GardenAppConfig['runtimeSettings'] = {
defaults: {
agentBudgetMax: 1_000_000,
agentCount: 0,
selectedColorIndex: 0,
spawnPerPixel: 0.22,
moveSpeed: 82,
turnSpeed: 58,
sensorOffsetAngle: 34,
sensorOffsetDistance: 38,
turnWhenLost: 0.8,
individualTrailWeight: 0.07,
...defaultColorInteractionSettings,
diffusionRateTrails: 0.22,
decayRateTrails: 965,
diffusionRateBrush: 0.35,
decayRateBrush: 18,
brushEffectDuration: 8,
clarity: 0.62,
brushSize: 14,
brushCurveResolution: 12,
eraserSize: 96,
mirrorSegmentCount: 1,
brushSizeVariation: 0.5,
startColorHue: 200,
renderSpeed: 1,
simulatedDelayMs: 0,
},
controls: {
agentBudgetMax: {
folder: 'Runtime',
integer: true,
min: 500_000,
max: 10_000_000,
step: 50_000,
},
agentCount: {
folder: 'Runtime',
integer: true,
min: 0,
max: 1_000_000,
step: 1_000,
},
color1ToColor1: colorInteractionControl('1 -> 1'),
color1ToColor2: colorInteractionControl('1 -> 2'),
color1ToColor3: colorInteractionControl('1 -> 3'),
color2ToColor1: colorInteractionControl('2 -> 1'),
color2ToColor2: colorInteractionControl('2 -> 2'),
color2ToColor3: colorInteractionControl('2 -> 3'),
color3ToColor1: colorInteractionControl('3 -> 1'),
color3ToColor2: colorInteractionControl('3 -> 2'),
color3ToColor3: colorInteractionControl('3 -> 3'),
brushEffectDuration: {
folder: 'Diffusion',
min: 0.5,
max: 20,
step: 0.05,
},
brushSize: {
folder: 'Brush',
min: 1,
max: 60,
step: 0.25,
},
brushSizeVariation: {
folder: 'Brush',
min: 0,
max: 1,
step: 0.01,
},
brushCurveResolution: {
folder: 'Brush',
integer: true,
label: 'curve resolution',
min: 1,
max: 32,
step: 1,
},
clarity: {
folder: 'Render',
min: 0.00001,
max: 1,
step: 0.001,
},
decayRateBrush: {
folder: 'Diffusion',
min: 0.1,
max: 100,
step: 0.1,
},
decayRateTrails: {
folder: 'Diffusion',
min: 0.1,
max: 5000,
step: 1,
},
diffusionRateBrush: {
folder: 'Diffusion',
min: 0.001,
max: 1,
step: 0.001,
},
diffusionRateTrails: {
folder: 'Diffusion',
min: 0,
max: 2,
step: 0.001,
},
eraserSize: {
folder: 'Brush',
integer: true,
min: 24,
max: 240,
step: 1,
},
individualTrailWeight: {
folder: 'Agent',
min: 0,
max: 1,
step: 0.001,
},
mirrorSegmentCount: {
folder: 'Brush',
integer: true,
min: 1,
max: 12,
step: 1,
},
moveSpeed: {
folder: 'Agent',
min: 10,
max: 500,
step: 1,
},
renderSpeed: {
folder: 'Runtime',
integer: true,
min: 1,
max: 10,
step: 1,
},
selectedColorIndex: {
folder: 'Brush',
integer: true,
min: 0,
max: 2,
step: 1,
},
sensorOffsetAngle: {
folder: 'Agent',
min: 0,
max: 90,
step: 1,
},
sensorOffsetDistance: {
folder: 'Agent',
min: 0,
max: 200,
step: 1,
},
simulatedDelayMs: {
folder: 'Runtime',
integer: true,
min: 0,
max: 2000,
step: 1,
},
spawnPerPixel: {
folder: 'Agent',
min: 0.01,
max: 1,
step: 0.001,
},
startColorHue: {
folder: 'Render',
min: 0,
max: 360,
step: 1,
},
turnSpeed: {
folder: 'Agent',
min: 1,
max: 200,
step: 1,
},
turnWhenLost: {
folder: 'Agent',
min: 0,
max: 1,
step: 0.001,
},
},
};

View file

@ -1,171 +1,108 @@
import type {
GardenAudioConfig,
GardenAudioVibeProfile,
GardenAudioVibeSettings,
} from '../audio/garden-audio-config';
import type { GameLoopSettings } from '../game-loop/game-loop-settings';
import type { AgentSettings } from '../pipelines/agents/agent-settings';
import type { BrushSettings } from '../pipelines/brush/brush-settings';
import type { DiffusionSettings } from '../pipelines/diffusion/diffusion-settings';
import type { RenderSettings } from '../pipelines/render/render-settings';
export type GardenRuntimeSettings = GameLoopSettings &
AgentSettings &
BrushSettings &
DiffusionSettings &
RenderSettings;
export type AgentColorInteractionSettings = Pick<
AgentSettings,
| 'color1ToColor1'
| 'color1ToColor2'
| 'color1ToColor3'
| 'color2ToColor1'
| 'color2ToColor2'
| 'color2ToColor3'
| 'color3ToColor1'
| 'color3ToColor2'
| 'color3ToColor3'
>;
export type GardenVibeSettings = Partial<
Pick<
GardenRuntimeSettings,
| 'agentBudgetMax'
| 'brushSize'
| 'color1ToColor1'
| 'color1ToColor2'
| 'color1ToColor3'
| 'color2ToColor1'
| 'color2ToColor2'
| 'color2ToColor3'
| 'color3ToColor1'
| 'color3ToColor2'
| 'color3ToColor3'
| 'clarity'
| 'decayRateTrails'
| 'diffusionRateTrails'
| 'individualTrailWeight'
| 'moveSpeed'
| 'sensorOffsetAngle'
| 'sensorOffsetDistance'
| 'spawnPerPixel'
| 'turnSpeed'
>
>;
export interface VibePreset {
id: string;
name: string;
colors: [string, string, string];
backgroundColor: string;
settings: GardenVibeSettings;
audio: GardenAudioVibeProfile;
}
import type { AgentSettings } from '../pipelines/agents/agent-pipeline';
import type { BrushSettings } from '../pipelines/brush/brush-pipeline';
import type { DiffusionSettings } from '../pipelines/diffusion/diffusion-pipeline';
import type { RenderSettings } from '../pipelines/render/render-pipeline';
import type { RgbColor } from '../utils/rgb-color';
export interface NumberControlConfig {
format?: (value: number) => string;
folder: string;
integer?: boolean;
label?: string;
max: number;
min: number;
max?: number;
min?: number;
options?: Record<string, number>;
step?: number;
}
export type RuntimeSettingControlConfig = {
[Key in keyof GardenRuntimeSettings]: NumberControlConfig;
};
export type GardenRuntimeSettings = {
adaptiveCapInitial: number;
adaptiveCapMin: number;
backgroundGrainStrength: number;
brushCurveResolution: number;
brushCurveMinBrushRadius: number;
brushCurveMinSegmentSpacing: number;
brushCurveMirrorResolutionExponent: number;
brushCurveSegmentBrushRadiusRatio: number;
brushEffectDuration: number;
brushSmoothingMinSampleDistance: number;
eraserClearAlpha: number;
eraserClearBlue: number;
eraserClearGreen: number;
eraserClearRed: number;
eraserLineDistanceEpsilon: number;
eraserMaskAlphaThreshold: number;
eraserSize: number;
internalRenderAreaMegapixels: number;
mirrorSegmentCount: number;
maxAgentCount: number;
selectedColorIndex: number;
spawnPerPixel: number;
strokeAngleJitterRadians: number;
} & AgentSettings &
BrushSettings &
DiffusionSettings &
RenderSettings;
type RuntimeSettingControlConfig = Partial<
Record<keyof GardenRuntimeSettings, NumberControlConfig>
>;
type GardenVibeSettings = Pick<
GardenRuntimeSettings,
| 'backgroundGrainStrength'
| 'brushSize'
| 'clarity'
| 'decayRateTrails'
| 'individualTrailWeight'
| 'moveSpeed'
| 'sensorOffsetDistance'
| 'spawnPerPixel'
| 'turnSpeed'
>;
type GardenDefaultSettings = Omit<
GardenRuntimeSettings,
keyof GardenVibeSettings | 'eraserSize' | 'mirrorSegmentCount'
>;
export enum VibeId {
AuroraMycelium = 'aurora-mycelium',
EmberCircuit = 'ember-circuit',
VelvetObservatory = 'velvet-observatory',
LichenSignal = 'lichen-signal',
UltravioletSiren = 'ultraviolet-siren',
TidepoolLantern = 'tidepool-lantern',
PaperLanternFog = 'paper-lantern-fog',
ChromePollen = 'chrome-pollen',
}
export interface VibePreset {
id: VibeId;
name: string;
colors: [RgbColor, RgbColor, RgbColor];
backgroundColor: RgbColor;
settings: GardenVibeSettings;
audio: GardenAudioVibeSettings;
}
export interface GardenAppConfig {
audio: GardenAudioConfig;
audioEngine: {
energy: {
attackSeconds: number;
decaySeconds: number;
releaseSeconds: number;
strokeDecaySeconds: number;
};
eraser: {
canvasWidthRatioForFullSize: number;
defaultSizePixels: number;
durationSeconds: number;
filterPressureWeight: number;
filterSizeWeight: number;
filterSpeedWeight: number;
gainBase: number;
gainPressureWeight: number;
gainSizeWeight: number;
gainSpeedWeight: number;
};
delay: {
erasingActivity: number;
};
graph: {
closeGain: number;
closeRampSeconds: number;
delayActivityFeedbackWeight: number;
delayFeedbackMax: number;
delayFeedbackMin: number;
delayOutputActivityWeight: number;
delayOutputBase: number;
delayTimeRampSeconds: number;
eventBusGain: number;
noiseMax: number;
noiseMin: number;
unlockBufferLength: number;
unlockSampleRate: number;
};
input: {
distanceEnergyBase: number;
distanceEnergyScale: number;
distanceForFullEnergyPixels: number;
fallbackFrameSeconds: number;
penMinPressure: number;
strokeEnergyBase: number;
strokeEnergyPressureWeight: number;
strokeEnergySpeedWeight: number;
};
muteGain: number;
muteRampSeconds: number;
noiseBurst: {
attackSeconds: number;
filterQ: number;
offsetRandomSeconds: number;
scheduleAheadSeconds: number;
silentGain: number;
};
piano: {
filterQ: number;
gainAttackSeconds: number;
lowpassMaxHz: number;
lowpassMinHz: number;
minDurationSeconds: number;
minFadeSeconds: number;
minGain: number;
pitchSemitonesPerOctave: number;
scheduleAheadSeconds: number;
sustainBase: number;
sustainVelocityRange: number;
tailStopExtraSeconds: number;
voiceStealFadeSeconds: number;
voiceStealStopSeconds: number;
};
startDelaySeconds: number;
vibeChangeStingerMinIntervalSeconds: number;
};
deltaTime: {
fpsExponentialDecayStrength: number;
maxDeltaTimeSeconds: number;
minDeltaTimeSeconds: number;
};
export4k: {
exportSnapshot: {
bytesPerPixel: number;
height: number;
jsHeapSafetyMultiplier: number;
lowMemoryDeviceGiB: number;
lowMemoryExportFraction: number;
filenameExtension: string;
filenamePrefix: string;
filenameSuffix: string;
mimeType: string;
rowAlignmentBytes: number;
width: number;
};
menuHider: {
bottomRevealDistancePx: number;
@ -173,6 +110,17 @@ export interface GardenAppConfig {
hideDelayMs: number;
};
pipelines: {
common: {
noiseChannelSeeds: [number, number, number, number];
noiseClearValue: GPUColor;
noiseDrawInstanceCount: number;
noiseDrawVertexCount: number;
noiseHashMultiplier: number;
noiseHashX: number;
noiseHashY: number;
noiseTextureFormat: GPUTextureFormat;
noiseTextureSize: number;
};
brush: {
maxLineCount: number;
};
@ -180,36 +128,29 @@ export interface GardenAppConfig {
minDiffusionRate: number;
};
eraser: {
maxSegmentCount: number;
maxTextureLineCount: number;
segmentFloatCount: number;
workgroupSize: number;
};
};
defaultSettings: GardenDefaultSettings;
runtimeSettings: {
controls: RuntimeSettingControlConfig;
defaults: GardenRuntimeSettings;
};
simulation: {
budget: {
adaptiveCapDecreaseAgentsPerSecond: number;
adaptiveCapMin: number;
fpsHeadroom: number;
fpsSmoothingNew: number;
fpsSmoothingRetain: number;
};
brushEffectFramesPerSecond: number;
globalAgentCap: number;
clearColor: GPUColor;
initialAgentCount: number;
sourceActiveFramesAfterWrite: number;
intro: {
angleJitterRadians: number;
angleEaseEnd: number;
angleEaseStart: number;
circleMaxSideRatio: number;
circleMinSideRatio: number;
drawHintClass: string;
drawHintDelayMs: number;
durationSeconds: number;
entryJitterSideRatio: number;
fontScaleDown: number;
fontFamily: string;
initialFontHeightRatio: number;
initialFontWidthRatio: number;
letterSpacingEm: number;
@ -221,7 +162,12 @@ export interface GardenAppConfig {
minEntryJitterPx: number;
minFontSizePx: number;
minTargetJitterPx: number;
pathEasing: 'easeOutQuad' | 'linear';
pathProgressEpsilon: number;
radialJitterRatio: number;
radialStartEpsilon: number;
resizeMinimumRemainingSeconds: number;
resizeSettleMs: number;
targetDelayDistanceMultiplier: number;
targetDelayMax: number;
targetDelayRandomMultiplier: number;
@ -235,22 +181,16 @@ export interface GardenAppConfig {
};
introMoveSpeedBaseMultiplier: number;
introMoveSpeedProgressMultiplier: number;
maxMirrorSegmentCount: number;
stroke: {
angleJitterRadians: number;
densityMultiplier: number;
maxAgentCount: number;
minAgentCount: number;
};
};
storage: {
audioMutedKey: string;
audioVolumeKey: string;
vibeKey: string;
};
telemetry: {
enabled: boolean;
intervalMs: number;
};
toolbar: {
eraser: {
controlScaleMax: number;
@ -262,23 +202,50 @@ export interface GardenAppConfig {
};
mirror: {
default: number;
fallbackSegmentName: string;
max: number;
min: number;
names: Record<number, string>;
offLabel: string;
step: number;
};
contrast: {
backgroundOpacityMax: number;
brightLuminanceThreshold: number;
brightWeight: number;
bytesPerSample: number;
contrastOffset: number;
linearChannelBreakpoint: number;
linearChannelDivisor: number;
linearChannelGamma: number;
linearChannelOffset: number;
linearChannelScale: number;
lowContrastThreshold: number;
lowContrastWeight: number;
luminanceBase: number;
luminanceBlueWeight: number;
luminanceGreenWeight: number;
luminanceRange: number;
luminanceRedWeight: number;
sampleColumns: number;
sampleIntervalMs: number;
sampleRows: number;
whiteContrastNumerator: number;
};
volume: {
default: number;
max: number;
min: number;
step: number;
};
};
tuningPane: {
expandedDepth: number;
showFpsOverlay: boolean;
startHidden: boolean;
title: string;
};
vibes: {
defaultVibeId: string;
defaultVibeId: VibeId;
presets: Array<VibePreset>;
};
}
export type GardenAudioEngineConfig = GardenAppConfig['audioEngine'];
export type GardenSimulationConfig = GardenAppConfig['simulation'];
export type GardenStorageConfig = GardenAppConfig['storage'];

View file

@ -1,204 +1,255 @@
import type {
GardenAudioChord,
GardenAudioVibeProfile,
} from '../audio/garden-audio-config';
import { createColorInteractionSettings } from './color-interactions';
import type { VibePreset } from './types';
import { defaultGardenAudioVibeSettings } from '../audio/garden-audio-config';
import { VibeId, type VibePreset } from './types';
const majorProgression: Array<GardenAudioChord> = [
{ rootOffset: 0, quality: 'major' },
{ rootOffset: 9, quality: 'minor' },
{ rootOffset: 5, quality: 'major' },
{ rootOffset: 7, quality: 'major' },
];
const minorProgression: Array<GardenAudioChord> = [
{ rootOffset: 0, quality: 'minor' },
{ rootOffset: 8, quality: 'major' },
{ rootOffset: 3, quality: 'major' },
{ rootOffset: 10, quality: 'major' },
];
const majorPentatonic = [0, 2, 4, 7, 9];
const minorPentatonic = [0, 3, 5, 7, 10];
export const defaultVibeId = 'candy-rain';
export const defaultVibeId = VibeId.AuroraMycelium;
export const vibePresets: Array<VibePreset> = [
{
id: 'candy-rain',
name: 'Candy Rain',
colors: ['#ff5da2', '#36d7d0', '#ffd84d'],
backgroundColor: '#10151f',
id: VibeId.AuroraMycelium,
name: 'Aurora Mycelium',
colors: [
[78, 255, 176],
[154, 99, 255],
[169, 238, 255],
],
backgroundColor: [6, 13, 22],
settings: {
agentBudgetMax: 1_000_000,
brushSize: 14,
clarity: 0.62,
decayRateTrails: 965,
diffusionRateTrails: 0.22,
individualTrailWeight: 0.07,
moveSpeed: 82,
sensorOffsetAngle: 34,
sensorOffsetDistance: 38,
spawnPerPixel: 0.22,
turnSpeed: 58,
...createColorInteractionSettings('candy-rain'),
backgroundGrainStrength: 0.016,
brushSize: 20,
clarity: 0.52,
decayRateTrails: 988,
individualTrailWeight: 0.085,
moveSpeed: 54,
sensorOffsetDistance: 72,
spawnPerPixel: 0.13,
turnSpeed: 35,
},
audio: {
rootMidi: 57,
scale: majorPentatonic,
brightness: 1.04,
delayTimeMultiplier: 0.92,
progression: majorProgression,
...defaultGardenAudioVibeSettings,
idleIntensity: 0.12,
bpm: 60,
rampUpIntensity: 0.7,
rampUpTime: 0.14,
noteLength: 0.86,
notePitchOffset: -2,
brightness: 0.84,
},
},
{
id: 'sunlit-moss',
name: 'Sunlit Moss',
colors: ['#83d483', '#f6d76b', '#5ec1a1'],
backgroundColor: '#172016',
id: VibeId.EmberCircuit,
name: 'Ember Circuit',
colors: [
[255, 95, 38],
[255, 43, 132],
[43, 219, 255],
],
backgroundColor: [17, 10, 8],
settings: {
agentBudgetMax: 1_000_000,
brushSize: 16,
clarity: 0.68,
decayRateTrails: 975,
diffusionRateTrails: 0.18,
individualTrailWeight: 0.06,
moveSpeed: 70,
sensorOffsetAngle: 28,
sensorOffsetDistance: 46,
spawnPerPixel: 0.18,
turnSpeed: 44,
...createColorInteractionSettings('sunlit-moss'),
backgroundGrainStrength: 0.03,
brushSize: 8,
clarity: 0.82,
decayRateTrails: 918,
individualTrailWeight: 0.04,
moveSpeed: 150,
sensorOffsetDistance: 24,
spawnPerPixel: 0.31,
turnSpeed: 130,
},
audio: {
rootMidi: 53,
scale: majorPentatonic,
brightness: 0.92,
delayTimeMultiplier: 1.08,
progression: [
{ rootOffset: 0, quality: 'major' },
{ rootOffset: 7, quality: 'major' },
{ rootOffset: 9, quality: 'minor' },
{ rootOffset: 5, quality: 'major' },
],
...defaultGardenAudioVibeSettings,
idleIntensity: 0.03,
bpm: 124,
rampUpIntensity: 1.35,
rampUpTime: 0.04,
noteLength: 0.18,
notePitchOffset: 7,
brightness: 1.34,
},
},
{
id: 'coral-tide',
name: 'Coral Tide',
colors: ['#ff7f6e', '#40b8ff', '#f4f0a6'],
backgroundColor: '#0f1822',
id: VibeId.VelvetObservatory,
name: 'Velvet Observatory',
colors: [
[72, 98, 255],
[255, 89, 176],
[235, 236, 255],
],
backgroundColor: [7, 8, 20],
settings: {
agentBudgetMax: 1_000_000,
brushSize: 13,
clarity: 0.58,
decayRateTrails: 955,
diffusionRateTrails: 0.28,
individualTrailWeight: 0.055,
moveSpeed: 90,
sensorOffsetAngle: 36,
sensorOffsetDistance: 35,
spawnPerPixel: 0.25,
turnSpeed: 62,
...createColorInteractionSettings('coral-tide'),
backgroundGrainStrength: 0.01,
brushSize: 24,
clarity: 0.45,
decayRateTrails: 992,
individualTrailWeight: 0.095,
moveSpeed: 45,
sensorOffsetDistance: 86,
spawnPerPixel: 0.1,
turnSpeed: 24,
},
audio: {
rootMidi: 50,
scale: minorPentatonic,
brightness: 1,
delayTimeMultiplier: 1.12,
progression: minorProgression,
...defaultGardenAudioVibeSettings,
idleIntensity: 0.14,
bpm: 56,
rampUpIntensity: 0.6,
rampUpTime: 0.16,
noteLength: 1.15,
notePitchOffset: -5,
brightness: 0.72,
},
},
{
id: 'moon-orchid',
name: 'Moon Orchid',
colors: ['#c993ff', '#7dd8ff', '#f0f4ff'],
backgroundColor: '#14121d',
id: VibeId.LichenSignal,
name: 'Lichen Signal',
colors: [
[174, 205, 91],
[71, 162, 126],
[229, 117, 71],
],
backgroundColor: [18, 24, 17],
settings: {
agentBudgetMax: 1_000_000,
brushSize: 12,
clarity: 0.64,
decayRateTrails: 968,
diffusionRateTrails: 0.2,
backgroundGrainStrength: 0.028,
brushSize: 17,
clarity: 0.66,
decayRateTrails: 974,
individualTrailWeight: 0.065,
moveSpeed: 76,
sensorOffsetAngle: 32,
sensorOffsetDistance: 42,
spawnPerPixel: 0.2,
turnSpeed: 52,
...createColorInteractionSettings('moon-orchid'),
},
audio: {
rootMidi: 49,
scale: minorPentatonic,
brightness: 0.9,
delayTimeMultiplier: 1.24,
progression: minorProgression,
},
},
{
id: 'peach-neon',
name: 'Peach Neon',
colors: ['#ff9b73', '#5bf0a9', '#6ea8ff'],
backgroundColor: '#191716',
settings: {
agentBudgetMax: 1_000_000,
brushSize: 15,
clarity: 0.55,
decayRateTrails: 948,
diffusionRateTrails: 0.32,
individualTrailWeight: 0.05,
moveSpeed: 96,
sensorOffsetAngle: 40,
sensorOffsetDistance: 32,
spawnPerPixel: 0.24,
turnSpeed: 70,
...createColorInteractionSettings('peach-neon'),
},
audio: {
rootMidi: 56,
scale: majorPentatonic,
brightness: 1.08,
delayTimeMultiplier: 0.86,
progression: majorProgression,
},
},
{
id: 'frost-bloom',
name: 'Frost Bloom',
colors: ['#b4f7ff', '#9ec8ff', '#ffb8d2'],
backgroundColor: '#101820',
settings: {
agentBudgetMax: 1_000_000,
brushSize: 18,
clarity: 0.7,
decayRateTrails: 982,
diffusionRateTrails: 0.14,
individualTrailWeight: 0.075,
moveSpeed: 62,
sensorOffsetAngle: 26,
moveSpeed: 68,
sensorOffsetDistance: 52,
spawnPerPixel: 0.16,
turnSpeed: 40,
...createColorInteractionSettings('frost-bloom'),
spawnPerPixel: 0.19,
turnSpeed: 38,
},
audio: {
rootMidi: 62,
scale: majorPentatonic,
brightness: 0.88,
delayTimeMultiplier: 1.32,
progression: [
{ rootOffset: 0, quality: 'major' },
{ rootOffset: 5, quality: 'major' },
{ rootOffset: 9, quality: 'minor' },
{ rootOffset: 7, quality: 'major' },
],
...defaultGardenAudioVibeSettings,
idleIntensity: 0.1,
bpm: 68,
rampUpIntensity: 0.8,
rampUpTime: 0.1,
noteLength: 0.62,
notePitchOffset: -3,
brightness: 0.82,
},
},
{
id: VibeId.UltravioletSiren,
name: 'Ultraviolet Siren',
colors: [
[184, 75, 255],
[0, 224, 255],
[214, 255, 72],
],
backgroundColor: [13, 9, 31],
settings: {
backgroundGrainStrength: 0.02,
brushSize: 11,
clarity: 0.72,
decayRateTrails: 946,
individualTrailWeight: 0.052,
moveSpeed: 118,
sensorOffsetDistance: 30,
spawnPerPixel: 0.28,
turnSpeed: 96,
},
audio: {
...defaultGardenAudioVibeSettings,
idleIntensity: 0.04,
bpm: 112,
rampUpIntensity: 1.2,
rampUpTime: 0.05,
noteLength: 0.25,
notePitchOffset: 5,
brightness: 1.22,
},
},
{
id: VibeId.TidepoolLantern,
name: 'Tidepool Lantern',
colors: [
[30, 219, 194],
[61, 118, 255],
[255, 191, 91],
],
backgroundColor: [5, 20, 28],
settings: {
backgroundGrainStrength: 0.018,
brushSize: 15,
clarity: 0.6,
decayRateTrails: 963,
individualTrailWeight: 0.058,
moveSpeed: 88,
sensorOffsetDistance: 44,
spawnPerPixel: 0.22,
turnSpeed: 60,
},
audio: {
...defaultGardenAudioVibeSettings,
idleIntensity: 0.08,
bpm: 82,
rampUpIntensity: 0.95,
rampUpTime: 0.08,
noteLength: 0.48,
notePitchOffset: 0,
brightness: 0.98,
},
},
{
id: VibeId.PaperLanternFog,
name: 'Paper Lantern Fog',
colors: [
[255, 174, 104],
[242, 102, 107],
[132, 211, 185],
],
backgroundColor: [31, 23, 20],
settings: {
backgroundGrainStrength: 0.036,
brushSize: 22,
clarity: 0.5,
decayRateTrails: 984,
individualTrailWeight: 0.08,
moveSpeed: 56,
sensorOffsetDistance: 64,
spawnPerPixel: 0.14,
turnSpeed: 32,
},
audio: {
...defaultGardenAudioVibeSettings,
idleIntensity: 0.13,
bpm: 64,
rampUpIntensity: 0.72,
rampUpTime: 0.12,
noteLength: 0.9,
notePitchOffset: -4,
brightness: 0.76,
},
},
{
id: VibeId.ChromePollen,
name: 'Chrome Pollen',
colors: [
[235, 255, 238],
[255, 214, 48],
[77, 240, 157],
],
backgroundColor: [9, 13, 12],
settings: {
backgroundGrainStrength: 0.012,
brushSize: 10,
clarity: 0.9,
decayRateTrails: 935,
individualTrailWeight: 0.045,
moveSpeed: 104,
sensorOffsetDistance: 36,
spawnPerPixel: 0.24,
turnSpeed: 78,
},
audio: {
...defaultGardenAudioVibeSettings,
idleIntensity: 0.05,
bpm: 96,
rampUpIntensity: 1.05,
rampUpTime: 0.07,
noteLength: 0.3,
notePitchOffset: 3,
brightness: 1.18,
},
},
];
export const audioVibes = Object.fromEntries(
vibePresets.map((vibe) => [vibe.id, vibe.audio])
) as Record<string, GardenAudioVibeProfile>;

10
src/consts.ts Normal file
View file

@ -0,0 +1,10 @@
export const ENABLED_FLAG_VALUE = '1';
export const DISABLED_FLAG_VALUE = '0';
export const DEFAULT_AUDIO_VOLUME = 0.5;
export const APP_STORAGE_KEYS = {
audioMuted: 'fleeting-garden:audio-muted',
audioVolume: 'fleeting-garden:audio-volume',
vibe: 'fleeting-garden:vibe',
} as const;

View file

@ -1,81 +1,166 @@
import { vec2 } from 'gl-matrix';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.hoisted(() => {
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: vi.fn(() => null),
setItem: vi.fn(),
},
});
});
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { appConfig } from '../config';
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
import { type AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
import { AGENT_FLOAT_COUNT } from '../pipelines/agents/agent-limits';
import { settings } from '../settings';
import { AgentPopulation } from './agent-population';
import { type FramePerformance } from './frame-performance';
const originalAgentBudgetMax = settings.agentBudgetMax;
const originalBrushSize = settings.brushSize;
const originalSelectedColorIndex = settings.selectedColorIndex;
const originalSpawnPerPixel = settings.spawnPerPixel;
const createPopulation = () => {
const pipeline = {
maxAgentCount: 10_000_000,
writeAgents: vi.fn(),
resizeAgents: vi.fn(),
compactAgents: vi.fn(),
} as unknown as AgentGenerationPipeline;
return new AgentPopulation(pipeline);
const originalSettings = {
brushSize: settings.brushSize,
maxAgentCount: settings.maxAgentCount,
selectedColorIndex: settings.selectedColorIndex,
spawnPerPixel: settings.spawnPerPixel,
strokeAngleJitterRadians: settings.strokeAngleJitterRadians,
};
const setPopulationActiveCount = (population: AgentPopulation, activeCount: number) => {
Object.assign(population as unknown as Record<string, number>, {
activeCount,
});
class RecordingAgentGenerationPipeline {
public readonly writtenAgentCounts: Array<number> = [];
public readonly writtenAgentOffsets: Array<number> = [];
public readonly writtenBatches: Array<Float32Array> = [];
public readonly maxSupportedAgentCount = 1_000_000;
public maxAgentCount = 1_000_000;
private compactResolver: ((compactedAgentCount: number) => void) | null = null;
public ensureMaxAgentCount(requestedMaxAgentCount: number): number {
this.maxAgentCount = Math.max(this.maxAgentCount, requestedMaxAgentCount);
return this.maxAgentCount;
}
public writeAgents(agentOffset: number, data: Float32Array): void {
this.writtenAgentOffsets.push(agentOffset);
this.writtenAgentCounts.push(data.length / AGENT_FLOAT_COUNT);
this.writtenBatches.push(data.slice());
}
public compactAgents(): Promise<number> {
return new Promise((resolve) => {
this.compactResolver = resolve;
});
}
public finishCompaction(compactedAgentCount: number): void {
this.compactResolver?.(compactedAgentCount);
this.compactResolver = null;
}
}
const framePerformance = {
adaptiveCapDecreaseAgents: 0,
adaptiveCapInitial: 1_000_000,
adaptiveCapMin: 0,
hasAdaptiveCapHeadroom: true,
} as FramePerformance;
const createPopulation = (): {
pipeline: RecordingAgentGenerationPipeline;
population: AgentPopulation;
} => {
const pipeline = new RecordingAgentGenerationPipeline();
const population = new AgentPopulation(
pipeline as unknown as AgentGenerationPipeline,
0,
() => 1,
framePerformance
);
population.beginStroke();
return { pipeline, population };
};
describe('AgentPopulation adaptive budget', () => {
const setSpawnRate = (agentsPerPixel: number): void => {
settings.spawnPerPixel = agentsPerPixel / appConfig.simulation.stroke.densityMultiplier;
};
describe('AgentPopulation stroke spawning', () => {
beforeEach(() => {
settings.agentBudgetMax = 1_000_000;
settings.brushSize = 1;
settings.brushSize = 0;
settings.maxAgentCount = 1_000_000;
settings.selectedColorIndex = 0;
settings.spawnPerPixel = 1;
settings.strokeAngleJitterRadians = 0;
setSpawnRate(1);
});
afterEach(() => {
settings.agentBudgetMax = originalAgentBudgetMax;
settings.brushSize = originalBrushSize;
settings.selectedColorIndex = originalSelectedColorIndex;
settings.spawnPerPixel = originalSpawnPerPixel;
Object.assign(settings, originalSettings);
});
it('expands beyond the 1M start cap only when new agents arrive under healthy FPS', () => {
const population = createPopulation();
setPopulationActiveCount(population, 1_000_000);
it('spawns the same count for the same stroke length regardless of segmentation', () => {
const segmented = createPopulation();
for (let x = 0; x < 10; x++) {
segmented.population.spawnStrokeAgents(
vec2.fromValues(x, 0),
vec2.fromValues(x + 1, 0)
);
}
population.growBudget(1 / 60, 60, 60);
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(16, 0));
expect(settings.agentBudgetMax).toBeGreaterThan(1_000_000);
expect(population.activeAgentCount).toBeGreaterThan(1_000_000);
expect(settings.agentBudgetMax).toBeLessThanOrEqual(
appConfig.simulation.globalAgentCap
const singleSegment = createPopulation();
singleSegment.population.spawnStrokeAgents(
vec2.fromValues(0, 0),
vec2.fromValues(10, 0)
);
expect(segmented.population.activeAgentCount).toBe(10);
expect(singleSegment.population.activeAgentCount).toBe(10);
});
it('decreases the cap and active count slowly when FPS falls below the threshold', () => {
const population = createPopulation();
setPopulationActiveCount(population, 1_000_000);
it('carries fractional spawn budget within a stroke', () => {
setSpawnRate(0.5);
const { population } = createPopulation();
population.growBudget(10, 50, 60);
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(1, 0));
expect(population.activeAgentCount).toBe(0);
expect(settings.agentBudgetMax).toBe(appConfig.simulation.budget.adaptiveCapMin);
expect(population.activeAgentCount).toBe(
appConfig.simulation.budget.adaptiveCapMin
population.spawnStrokeAgents(vec2.fromValues(1, 0), vec2.fromValues(2, 0));
expect(population.activeAgentCount).toBe(1);
population.spawnStrokeAgents(vec2.fromValues(2, 0), vec2.fromValues(3, 0));
expect(population.activeAgentCount).toBe(1);
population.spawnStrokeAgents(vec2.fromValues(3, 0), vec2.fromValues(4, 0));
expect(population.activeAgentCount).toBe(2);
});
it('chunks long stroke writes without clipping length-linear spawn counts', () => {
const { pipeline, population } = createPopulation();
const batchCapacity = appConfig.simulation.stroke.maxAgentCount;
const expectedAgentCount = batchCapacity + 10;
population.spawnStrokeAgents(
vec2.fromValues(0, 0),
vec2.fromValues(expectedAgentCount, 0)
);
expect(population.activeAgentCount).toBe(expectedAgentCount);
expect(pipeline.writtenAgentCounts).toEqual([batchCapacity, 10]);
});
it('spawns agents in the movement direction', () => {
const { pipeline, population } = createPopulation();
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(3, 0));
expect(population.activeAgentCount).toBe(3);
expect(pipeline.writtenBatches[0][2]).toBe(0);
});
it('queues stroke writes while async compaction is in flight', async () => {
const { pipeline, population } = createPopulation();
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(10, 0));
population.requestCompactionAfterErase();
population.compactAfterErase(false);
population.spawnStrokeAgents(vec2.fromValues(10, 0), vec2.fromValues(15, 0));
expect(population.activeAgentCount).toBe(10);
expect(pipeline.writtenAgentCounts).toEqual([10]);
pipeline.finishCompaction(6);
await population.waitForCompaction();
expect(population.activeAgentCount).toBe(11);
expect(pipeline.writtenAgentOffsets).toEqual([0, 6]);
expect(pipeline.writtenAgentCounts).toEqual([10, 5]);
});
});

View file

@ -1,64 +1,79 @@
import { vec2 } from 'gl-matrix';
import { appConfig } from '../config';
import { AGENT_FLOAT_COUNT } from '../pipelines/agents/agent-generation/agent';
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
import { AGENT_FLOAT_COUNT, writeAgentValues } from '../pipelines/agents/agent-limits';
import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline';
import { settings } from '../settings';
import type { FramePerformance } from './frame-performance';
import { createIntroTitleAgents } from './intro-title-agents';
export const GLOBAL_AGENT_CAP = appConfig.simulation.globalAgentCap;
const INITIAL_AGENT_COUNT = appConfig.simulation.initialAgentCount;
const MIN_STROKE_AGENT_COUNT = appConfig.simulation.stroke.minAgentCount;
const MAX_STROKE_AGENT_COUNT = appConfig.simulation.stroke.maxAgentCount;
const STROKE_AGENT_DENSITY_MULTIPLIER = appConfig.simulation.stroke.densityMultiplier;
const ADAPTIVE_CAP_MIN = appConfig.simulation.budget.adaptiveCapMin;
const ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND =
appConfig.simulation.budget.adaptiveCapDecreaseAgentsPerSecond;
export class AgentPopulation {
private activeCount = 0;
// Current performance-aware limit; new agents above it replace old agents.
private adaptiveCap: number;
// Next active agent slot to overwrite when new agents exceed the current cap.
private replacementCursor = 0;
private canExpandAdaptiveCap = true;
private shouldCompactAfterErase = false;
private isCompacting = false;
private pendingCompaction: Promise<void> | null = null;
private readonly queuedAgentBatches: Array<Float32Array> = [];
private pendingStrokeAgentCount = 0;
private readonly strokeAgentData = new Float32Array(
MAX_STROKE_AGENT_COUNT * AGENT_FLOAT_COUNT
appConfig.simulation.stroke.maxAgentCount * AGENT_FLOAT_COUNT
);
public constructor(private readonly pipeline: AgentGenerationPipeline) {}
public constructor(
private readonly pipeline: AgentGenerationPipeline,
private readonly introSeed: number,
private readonly getCanvasPixelRatio: () => number,
private readonly framePerformance: FramePerformance
) {
this.adaptiveCap = this.clampAndEnsureAdaptiveCap(
this.framePerformance.adaptiveCapInitial
);
}
public get activeAgentCount(): number {
return this.activeCount;
}
public get maxAgentCount(): number {
return this.pipeline.maxAgentCount;
public initializeIntroAgents(canvasSize: vec2): void {
this.replaceIntroAgents(canvasSize, 0);
}
public initializeIntroAgents(canvasSize: vec2): void {
settings.agentBudgetMax = this.clampAdaptiveCap(settings.agentBudgetMax);
const introAgentCount = Math.min(settings.agentBudgetMax, INITIAL_AGENT_COUNT);
this.writeAgentBatch(
createIntroTitleAgents({
count: introAgentCount,
width: canvasSize[0],
height: canvasSize[1],
})
public replaceIntroAgents(canvasSize: vec2, progress: number): void {
this.adaptiveCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
const introAgentCount = Math.min(
this.adaptiveCap,
appConfig.simulation.initialAgentCount
);
const data = createIntroTitleAgents({
count: introAgentCount,
width: canvasSize[0],
height: canvasSize[1],
progress,
seed: this.introSeed,
});
if (data.length === 0) {
return;
}
this.pipeline.writeAgents(0, data);
this.activeCount = data.length / AGENT_FLOAT_COUNT;
this.replacementCursor = 0;
}
public onVibeChanged(): void {
settings.agentBudgetMax = this.clampAdaptiveCap(settings.agentBudgetMax);
this.pendingStrokeAgentCount = 0;
this.adaptiveCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
this.trimActiveCountToBudget();
}
public growBudget(
deltaTime: number,
smoothedFps: number,
refreshTargetFps: number
): void {
this.updateAdaptiveCap(deltaTime, smoothedFps, refreshTargetFps);
public beginStroke(): void {
this.pendingStrokeAgentCount = 0;
}
public resizeAgents(scale: vec2): void {
@ -69,7 +84,7 @@ export class AgentPopulation {
this.shouldCompactAfterErase = true;
}
public async compactAfterErase(isSwipeActive: boolean): Promise<void> {
public compactAfterErase(isSwipeActive: boolean): void {
if (!this.shouldCompactAfterErase || this.isCompacting || isSwipeActive) {
return;
}
@ -80,47 +95,133 @@ export class AgentPopulation {
}
this.isCompacting = true;
try {
const compactedAgentCount = await this.pipeline.compactAgents(this.activeCount);
this.activeCount = compactedAgentCount;
this.replacementCursor =
compactedAgentCount === 0 ? 0 : this.replacementCursor % compactedAgentCount;
} finally {
this.isCompacting = false;
this.pendingCompaction = this.pipeline
.compactAgents(this.activeCount)
.then((compactedAgentCount) => {
const finiteCompactedAgentCount = Number.isFinite(compactedAgentCount)
? Math.max(0, Math.floor(compactedAgentCount))
: 0;
this.activeCount = Math.min(this.activeCount, finiteCompactedAgentCount);
this.clampReplacementCursor();
this.trimActiveCountToBudget();
})
.catch((error: unknown) => {
console.warn('Could not compact agents after erase.', error);
})
.finally(() => {
this.isCompacting = false;
this.pendingCompaction = null;
this.flushQueuedAgentBatches();
});
}
public async waitForCompaction(): Promise<void> {
await this.pendingCompaction;
}
public updateAdaptiveCap(): void {
const previousCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
this.canExpandAdaptiveCap = this.framePerformance.hasAdaptiveCapHeadroom;
if (this.canExpandAdaptiveCap) {
this.adaptiveCap = previousCap;
this.trimActiveCountToBudget();
return;
}
const decrease = this.framePerformance.adaptiveCapDecreaseAgents;
const responsiveCap = Math.min(
previousCap,
this.clampAndEnsureAdaptiveCap(this.activeCount)
);
const nextCap = this.clampAndEnsureAdaptiveCap(responsiveCap - decrease);
this.adaptiveCap = nextCap;
this.trimActiveCountToBudget(decrease);
}
public spawnStrokeAgents(from: vec2, to: vec2): void {
const length = Math.max(1, vec2.dist(from, to));
const count = Math.max(
MIN_STROKE_AGENT_COUNT,
Math.min(
MAX_STROKE_AGENT_COUNT,
Math.ceil(length * settings.spawnPerPixel * STROKE_AGENT_DENSITY_MULTIPLIER)
)
);
const direction = vec2.sub(vec2.create(), to, from);
const baseAngle = Math.atan2(direction[1], direction[0]);
for (let i = 0; i < count; i++) {
const t = count === 1 ? 1 : i / (count - 1);
const x = from[0] + (to[0] - from[0]) * t;
const y = from[1] + (to[1] - from[1]) * t;
const angle =
(Number.isFinite(baseAngle) ? baseAngle : Math.random() * Math.PI * 2) +
(Math.random() - 0.5) * appConfig.simulation.stroke.angleJitterRadians;
const base = i * AGENT_FLOAT_COUNT;
this.strokeAgentData[base] = x + (Math.random() - 0.5) * settings.brushSize;
this.strokeAgentData[base + 1] = y + (Math.random() - 0.5) * settings.brushSize;
this.strokeAgentData[base + 2] = angle;
this.strokeAgentData[base + 3] = settings.selectedColorIndex;
this.strokeAgentData[base + 4] = -1;
this.strokeAgentData[base + 5] = -1;
this.strokeAgentData[base + 6] = angle;
this.strokeAgentData[base + 7] = 0;
const deltaX = to[0] - from[0];
const deltaY = to[1] - from[1];
const length = Math.hypot(deltaX, deltaY);
const spawnRate = getStrokeSpawnRate();
if (!Number.isFinite(length) || length <= 0 || spawnRate <= 0) {
return;
}
this.writeAgentBatch(this.strokeAgentData.subarray(0, count * AGENT_FLOAT_COUNT));
const expectedAgentCount = length * spawnRate + this.pendingStrokeAgentCount;
if (!Number.isFinite(expectedAgentCount)) {
this.pendingStrokeAgentCount = 0;
return;
}
const count = Math.floor(expectedAgentCount);
this.pendingStrokeAgentCount = expectedAgentCount - count;
if (count <= 0) {
return;
}
const baseAngle = Math.atan2(deltaY, deltaX);
const spread = settings.brushSize * getSafePixelRatio(this.getCanvasPixelRatio());
const batchCapacity = this.strokeAgentData.length / AGENT_FLOAT_COUNT;
if (batchCapacity <= 0) {
return;
}
for (let written = 0; written < count; written += batchCapacity) {
const batchCount = Math.min(batchCapacity, count - written);
this.populateStrokeAgentBatch({
baseAngle,
batchCount,
from,
spread,
to,
totalCount: count,
written,
});
this.writeAgentBatch(
this.strokeAgentData.subarray(0, batchCount * AGENT_FLOAT_COUNT)
);
}
}
private populateStrokeAgentBatch({
baseAngle,
batchCount,
from,
spread,
to,
totalCount,
written,
}: {
baseAngle: number;
batchCount: number;
from: vec2;
spread: number;
to: vec2;
totalCount: number;
written: number;
}): void {
for (let i = 0; i < batchCount; i++) {
const agentIndex = written + i;
const t = totalCount === 1 ? 0.5 : agentIndex / (totalCount - 1);
const x = from[0] + (to[0] - from[0]) * t;
const y = from[1] + (to[1] - from[1]) * t;
const angle = baseAngle + (Math.random() - 0.5) * settings.strokeAngleJitterRadians;
const positionX = x + (Math.random() - 0.5) * spread;
const positionY = y + (Math.random() - 0.5) * spread;
writeAgentValues(this.strokeAgentData, i, {
positionX,
positionY,
angle,
colorIndex: settings.selectedColorIndex,
targetPositionX: -1,
targetPositionY: -1,
targetAngle: angle,
introDelay: 0,
});
}
}
private writeAgentBatch(data: Float32Array): void {
@ -128,10 +229,16 @@ export class AgentPopulation {
return;
}
if (this.isCompacting) {
this.queuedAgentBatches.push(data.slice());
return;
}
const count = data.length / AGENT_FLOAT_COUNT;
this.adaptiveCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
this.expandAdaptiveCapForPendingAgents(count);
const available = Math.max(0, settings.agentBudgetMax - this.activeCount);
const available = Math.max(0, this.adaptiveCap - this.activeCount);
const appendCount = Math.min(count, available);
if (appendCount > 0) {
@ -163,59 +270,63 @@ export class AgentPopulation {
}
}
private updateAdaptiveCap(
deltaTime: number,
smoothedFps: number,
refreshTargetFps: number
): 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;
}
const decrease = Math.max(
1,
Math.ceil(ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND * deltaTime)
);
const nextCap = this.clampAdaptiveCap(previousCap - decrease);
settings.agentBudgetMax = nextCap;
this.trimActiveCountToBudget(decrease);
private flushQueuedAgentBatches(): void {
const batches = this.queuedAgentBatches.splice(0);
batches.forEach((batch) => this.writeAgentBatch(batch));
}
private expandAdaptiveCapForPendingAgents(requestedAgentCount: number): void {
const available = Math.max(0, settings.agentBudgetMax - this.activeCount);
const available = Math.max(0, this.adaptiveCap - this.activeCount);
if (requestedAgentCount <= available || !this.canExpandAdaptiveCap) {
return;
}
const currentCap = this.clampAdaptiveCap(settings.agentBudgetMax);
const currentCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
const pendingAgentCount = requestedAgentCount - available;
settings.agentBudgetMax = this.clampAdaptiveCap(currentCap + pendingAgentCount);
this.adaptiveCap = this.clampAndEnsureAdaptiveCap(currentCap + pendingAgentCount);
}
private trimActiveCountToBudget(maxDecrease = Number.POSITIVE_INFINITY): void {
if (this.activeCount <= settings.agentBudgetMax) {
if (this.activeCount <= this.adaptiveCap) {
return;
}
this.activeCount = Math.max(
settings.agentBudgetMax,
this.adaptiveCap,
this.activeCount - Math.max(1, Math.ceil(maxDecrease))
);
this.clampReplacementCursor();
}
private clampReplacementCursor(): void {
this.replacementCursor =
this.activeCount === 0 ? 0 : this.replacementCursor % this.activeCount;
}
private clampAdaptiveCap(value: number): number {
const pipelineCap = Math.max(0, Math.floor(this.pipeline.maxAgentCount));
const minCap = Math.min(ADAPTIVE_CAP_MIN, pipelineCap);
private clampAndEnsureAdaptiveCap(value: number): number {
const runtimeMaxCap =
settings.maxAgentCount === Number.POSITIVE_INFINITY
? Number.POSITIVE_INFINITY
: Number.isFinite(settings.maxAgentCount)
? Math.max(0, Math.floor(settings.maxAgentCount))
: Math.max(0, Math.floor(this.pipeline.maxAgentCount));
const maxCap = Math.min(this.pipeline.maxSupportedAgentCount, runtimeMaxCap);
const minCap = Math.min(this.framePerformance.adaptiveCapMin, maxCap);
const finiteValue = Number.isFinite(value) ? value : minCap;
return Math.min(pipelineCap, Math.max(minCap, Math.round(finiteValue)));
const nextCap = Math.min(maxCap, Math.max(minCap, Math.round(finiteValue)));
return Math.min(
nextCap,
this.pipeline.ensureMaxAgentCount(nextCap, this.activeCount)
);
}
}
const getStrokeSpawnRate = (): number => {
const spawnPerPixel = Number.isFinite(settings.spawnPerPixel)
? settings.spawnPerPixel
: 0;
const densityMultiplier = Number.isFinite(appConfig.simulation.stroke.densityMultiplier)
? appConfig.simulation.stroke.densityMultiplier
: 0;
return Math.max(0, spawnPerPixel * densityMultiplier);
};

View file

@ -0,0 +1,150 @@
import { vec2 } from 'gl-matrix';
import { appConfig } from '../config';
import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline';
import { settings } from '../settings';
import { type StrokeSegment } from './game-loop-types';
interface BrushStrokeSmootherOptions {
getCanvasPixelRatio: () => number;
getMirrorSegmentCount: () => number;
}
export class BrushStrokeSmoother {
private readonly strokePoints: Array<vec2> = [];
private lastBrushPosition: vec2 | null = null;
public constructor(private readonly options: BrushStrokeSmootherOptions) {}
public addSample(position: vec2): Array<StrokeSegment> {
const previousSample = this.strokePoints[this.strokePoints.length - 1];
if (
previousSample !== undefined &&
vec2.squaredDistance(previousSample, position) <=
getBrushSmoothingDistanceSquared(this.options.getCanvasPixelRatio())
) {
return [];
}
this.strokePoints.push(vec2.clone(position));
if (this.strokePoints.length > 3) {
this.strokePoints.shift();
}
if (this.strokePoints.length === 1) {
this.lastBrushPosition = vec2.clone(position);
return [{ from: position, to: position }];
}
if (this.strokePoints.length === 2) {
const [start, end] = this.strokePoints;
const midpoint = getMidpoint(start, end);
this.lastBrushPosition = midpoint;
return [{ from: start, to: midpoint }];
}
const [start, control, end] = this.strokePoints;
const curveStart = getMidpoint(start, control);
const curveEnd = getMidpoint(control, end);
this.lastBrushPosition = curveEnd;
return this.getQuadraticSegments(curveStart, control, curveEnd);
}
public finish(): Array<StrokeSegment> {
if (this.strokePoints.length === 0) {
return [];
}
const finalSample = this.strokePoints[this.strokePoints.length - 1];
if (
this.lastBrushPosition !== null &&
vec2.squaredDistance(this.lastBrushPosition, finalSample) >
getBrushSmoothingDistanceSquared(this.options.getCanvasPixelRatio())
) {
return [{ from: this.lastBrushPosition, to: finalSample }];
}
return [];
}
public clear(): void {
this.strokePoints.length = 0;
this.lastBrushPosition = null;
}
public scale(scale: vec2): void {
this.strokePoints.forEach((point) => {
vec2.mul(point, point, scale);
});
if (this.lastBrushPosition !== null) {
vec2.mul(this.lastBrushPosition, this.lastBrushPosition, scale);
}
}
private getQuadraticSegments(
start: vec2,
control: vec2,
end: vec2
): Array<StrokeSegment> {
const curveLength = vec2.distance(start, control) + vec2.distance(control, end);
const canvasPixelRatio = getSafePixelRatio(this.options.getCanvasPixelRatio());
const brushRadius = Math.max(
settings.brushCurveMinBrushRadius * canvasPixelRatio,
(settings.brushSize * canvasPixelRatio) / 2
);
const segmentSpacing = Math.max(
settings.brushCurveMinSegmentSpacing * canvasPixelRatio,
brushRadius * settings.brushCurveSegmentBrushRadiusRatio
);
const mirrorSegmentCount = Math.max(1, this.options.getMirrorSegmentCount());
const curveResolution = getBrushCurveResolution();
const maxCurveSegments = Math.max(
1,
Math.floor(
curveResolution /
Math.max(1, mirrorSegmentCount ** settings.brushCurveMirrorResolutionExponent)
)
);
const segmentCount = Math.min(
maxCurveSegments,
Math.max(1, Math.ceil(curveLength / segmentSpacing))
);
let previousPoint = start;
const segments: Array<StrokeSegment> = [];
for (let i = 1; i <= segmentCount; i++) {
const point = getQuadraticPoint(start, control, end, i / segmentCount);
segments.push({ from: previousPoint, to: point });
previousPoint = point;
}
return segments;
}
}
const getMidpoint = (from: vec2, to: vec2): vec2 =>
vec2.fromValues((from[0] + to[0]) / 2, (from[1] + to[1]) / 2);
const getQuadraticPoint = (start: vec2, control: vec2, end: vec2, t: number): vec2 => {
const inverseT = 1 - t;
return vec2.fromValues(
inverseT * inverseT * start[0] + 2 * inverseT * t * control[0] + t * t * end[0],
inverseT * inverseT * start[1] + 2 * inverseT * t * control[1] + t * t * end[1]
);
};
const getBrushCurveResolution = (): number => {
const resolution = Number.isFinite(settings.brushCurveResolution)
? settings.brushCurveResolution
: appConfig.defaultSettings.brushCurveResolution;
return Math.max(1, Math.floor(resolution));
};
const getBrushSmoothingDistanceSquared = (pixelRatio?: number): number => {
const distance = Number.isFinite(settings.brushSmoothingMinSampleDistance)
? settings.brushSmoothingMinSampleDistance
: appConfig.defaultSettings.brushSmoothingMinSampleDistance;
return Math.max(0, distance * getSafePixelRatio(pixelRatio)) ** 2;
};

View file

@ -4,6 +4,7 @@ export class EraserPreview {
private previewClientPosition: { x: number; y: number } | null = null;
private isErasing = false;
private isPointerHoveringCanvas = false;
private isSwipeActive = false;
private previousSize: number | null = null;
private previousLeft = '';
private previousTop = '';
@ -11,19 +12,36 @@ export class EraserPreview {
public constructor(
private readonly canvas: HTMLCanvasElement,
private readonly element: HTMLElement
private readonly element: HTMLElement,
private readonly getIsSwipeActive: () => boolean
) {}
public setEraseMode(isErasing: boolean, isSwipeActive: boolean): void {
public attach(): void {
this.canvas.addEventListener('pointerenter', this.onPointerEnter);
this.canvas.addEventListener('pointerleave', this.onPointerLeave);
this.canvas.addEventListener('pointerdown', this.onPointerDown);
this.canvas.addEventListener('pointermove', this.onPointerMove);
this.canvas.addEventListener('pointerup', this.onPointerUp);
this.canvas.addEventListener('pointercancel', this.onPointerUp);
}
public detach(): void {
this.canvas.removeEventListener('pointerenter', this.onPointerEnter);
this.canvas.removeEventListener('pointerleave', this.onPointerLeave);
this.canvas.removeEventListener('pointerdown', this.onPointerDown);
this.canvas.removeEventListener('pointermove', this.onPointerMove);
this.canvas.removeEventListener('pointerup', this.onPointerUp);
this.canvas.removeEventListener('pointercancel', this.onPointerUp);
}
public setEraseMode(isErasing: boolean): void {
this.isErasing = isErasing;
this.update(undefined, isSwipeActive);
this.update();
}
public setPointerHoveringCanvas(isHovering: boolean): void {
this.isPointerHoveringCanvas = isHovering;
}
public update(event?: PointerEvent): void {
this.isSwipeActive = this.getIsSwipeActive();
public update(event?: PointerEvent, isSwipeActive = false): void {
if (event) {
this.previewClientPosition = {
x: event.clientX,
@ -39,7 +57,7 @@ export class EraserPreview {
if (
!this.isErasing ||
this.previewClientPosition === null ||
(!this.isPointerHoveringCanvas && !isSwipeActive)
(!this.isPointerHoveringCanvas && !this.isSwipeActive)
) {
this.setVisible(false);
return;
@ -59,7 +77,16 @@ export class EraserPreview {
this.setVisible(true);
}
public isPointerInsideCanvas(event: PointerEvent): boolean {
private setVisible(isVisible: boolean): void {
if (this.isVisible === isVisible) {
return;
}
this.isVisible = isVisible;
this.element.classList.toggle('visible', isVisible);
}
private isPointerInsideCanvas(event: PointerEvent): boolean {
const rect = this.canvas.getBoundingClientRect();
return (
event.clientX >= rect.left &&
@ -69,12 +96,27 @@ export class EraserPreview {
);
}
private setVisible(isVisible: boolean): void {
if (this.isVisible === isVisible) {
return;
}
private readonly onPointerDown = (event: PointerEvent) => {
this.isPointerHoveringCanvas = true;
this.update(event);
};
this.isVisible = isVisible;
this.element.classList.toggle('visible', isVisible);
}
private readonly onPointerMove = (event: PointerEvent) => {
this.update(event);
};
private readonly onPointerUp = (event: PointerEvent) => {
this.isPointerHoveringCanvas = this.isPointerInsideCanvas(event);
this.update(event);
};
private readonly onPointerEnter = (event: PointerEvent) => {
this.isPointerHoveringCanvas = true;
this.update(event);
};
private readonly onPointerLeave = () => {
this.isPointerHoveringCanvas = false;
this.update();
};
}

View file

@ -1,87 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
estimateExport4KMemory,
formatByteSize,
getAspectFitExport4KDimensions,
getExport4KPreflightError,
} from './export-4k';
const generousLimits = {
maxBufferSize: Number.MAX_SAFE_INTEGER,
maxTextureDimension2D: Number.MAX_SAFE_INTEGER,
};
describe('4K export preflight', () => {
it('fits export dimensions inside 4K while preserving source aspect ratio', () => {
expect(getAspectFitExport4KDimensions(3840, 2160)).toEqual({
width: 3840,
height: 2160,
});
expect(getAspectFitExport4KDimensions(800, 600)).toEqual({
width: 2880,
height: 2160,
});
expect(getAspectFitExport4KDimensions(600, 800)).toEqual({
width: 1620,
height: 2160,
});
expect(getAspectFitExport4KDimensions(1000, 1000)).toEqual({
width: 2160,
height: 2160,
});
});
it('estimates padded readback and temporary memory for the export', () => {
const estimate = estimateExport4KMemory();
expect(estimate.width).toBe(3840);
expect(estimate.height).toBe(2160);
expect(estimate.bytesPerRow % 256).toBe(0);
expect(estimate.estimatedPeakBytes).toBeGreaterThan(estimate.textureBytes);
expect(formatByteSize(estimate.estimatedPeakBytes)).toMatch(/MiB$/);
});
it('rejects GPUs that cannot allocate the export texture', () => {
const error = getExport4KPreflightError({
limits: {
maxBufferSize: Number.MAX_SAFE_INTEGER,
maxTextureDimension2D: 2048,
},
});
expect(error?.code).toBe('export-4k-texture-too-large');
});
it('rejects GPUs that cannot allocate the readback buffer', () => {
const estimate = estimateExport4KMemory();
const error = getExport4KPreflightError({
limits: {
maxBufferSize: estimate.readbackBufferBytes - 1,
maxTextureDimension2D: Number.MAX_SAFE_INTEGER,
},
estimate,
});
expect(error?.code).toBe('export-4k-readback-too-large');
});
it('rejects browser-reported low-memory devices', () => {
const error = getExport4KPreflightError({
limits: generousLimits,
memoryInfo: {
deviceMemoryBytes: 2 * 1024 ** 3,
},
});
expect(error?.code).toBe('export-4k-low-device-memory');
});
it('allows export when memory hints are unavailable', () => {
expect(
getExport4KPreflightError({
limits: generousLimits,
})
).toBeNull();
});
});

View file

@ -1,222 +0,0 @@
import { appConfig } from '../config';
import { RuntimeError } from '../utils/error-handler';
const EXPORT_4K_WIDTH = appConfig.export4k.width;
const EXPORT_4K_HEIGHT = appConfig.export4k.height;
const BYTES_PER_PIXEL = appConfig.export4k.bytesPerPixel;
const ROW_ALIGNMENT_BYTES = appConfig.export4k.rowAlignmentBytes;
const GIBIBYTE = 1024 ** 3;
const LOW_MEMORY_DEVICE_GIB = appConfig.export4k.lowMemoryDeviceGiB;
const LOW_MEMORY_EXPORT_FRACTION = appConfig.export4k.lowMemoryExportFraction;
const JS_HEAP_SAFETY_MULTIPLIER = appConfig.export4k.jsHeapSafetyMultiplier;
interface Export4KMemoryEstimate {
width: number;
height: number;
bytesPerPixel: number;
unpaddedBytesPerRow: number;
bytesPerRow: number;
textureBytes: number;
readbackBufferBytes: number;
pixelBytes: number;
canvasBytes: number;
encoderSafetyBytes: number;
estimatedJsHeapBytes: number;
estimatedPeakBytes: number;
}
interface Export4KDimensions {
width: number;
height: number;
}
interface BrowserMemoryInfo {
deviceMemoryBytes?: number;
jsHeapSizeLimitBytes?: number;
usedJsHeapSizeBytes?: number;
}
interface Export4KPreflightOptions {
limits: Pick<GPUSupportedLimits, 'maxBufferSize' | 'maxTextureDimension2D'>;
memoryInfo?: BrowserMemoryInfo;
estimate?: Export4KMemoryEstimate;
}
const alignTo = (value: number, alignment: number): number =>
Math.ceil(value / alignment) * alignment;
const getPositiveFiniteNumber = (value: unknown): number | undefined =>
typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined;
export const formatByteSize = (bytes: number): string =>
`${Math.ceil(bytes / 1024 / 1024)} MiB`;
export const getAspectFitExport4KDimensions = (
sourceWidth: number,
sourceHeight: number,
maxWidth = EXPORT_4K_WIDTH,
maxHeight = EXPORT_4K_HEIGHT
): Export4KDimensions => {
if (
!Number.isFinite(sourceWidth) ||
!Number.isFinite(sourceHeight) ||
sourceWidth <= 0 ||
sourceHeight <= 0
) {
return { width: maxWidth, height: maxHeight };
}
const scale = Math.min(maxWidth / sourceWidth, maxHeight / sourceHeight);
return {
width: Math.min(maxWidth, Math.max(1, Math.round(sourceWidth * scale))),
height: Math.min(maxHeight, Math.max(1, Math.round(sourceHeight * scale))),
};
};
export const estimateExport4KMemory = (
width = EXPORT_4K_WIDTH,
height = EXPORT_4K_HEIGHT
): Export4KMemoryEstimate => {
const unpaddedBytesPerRow = width * BYTES_PER_PIXEL;
const bytesPerRow = alignTo(unpaddedBytesPerRow, ROW_ALIGNMENT_BYTES);
const textureBytes = unpaddedBytesPerRow * height;
const readbackBufferBytes = bytesPerRow * height;
const pixelBytes = textureBytes;
const canvasBytes = textureBytes;
const encoderSafetyBytes = textureBytes * 2;
const estimatedJsHeapBytes = pixelBytes + canvasBytes + encoderSafetyBytes;
return {
width,
height,
bytesPerPixel: BYTES_PER_PIXEL,
unpaddedBytesPerRow,
bytesPerRow,
textureBytes,
readbackBufferBytes,
pixelBytes,
canvasBytes,
encoderSafetyBytes,
estimatedJsHeapBytes,
estimatedPeakBytes: textureBytes + readbackBufferBytes + estimatedJsHeapBytes,
};
};
export const getBrowserExportMemoryInfo = (): BrowserMemoryInfo => {
const navigatorWithMemory =
typeof navigator === 'undefined'
? undefined
: (navigator as Navigator & { deviceMemory?: number });
const performanceWithMemory =
typeof performance === 'undefined'
? undefined
: (performance as Performance & {
memory?: {
jsHeapSizeLimit?: number;
usedJSHeapSize?: number;
};
});
const deviceMemoryGib = getPositiveFiniteNumber(navigatorWithMemory?.deviceMemory);
const jsHeapSizeLimitBytes = getPositiveFiniteNumber(
performanceWithMemory?.memory?.jsHeapSizeLimit
);
const usedJsHeapSizeBytes = getPositiveFiniteNumber(
performanceWithMemory?.memory?.usedJSHeapSize
);
return {
...(deviceMemoryGib === undefined
? {}
: { deviceMemoryBytes: deviceMemoryGib * GIBIBYTE }),
...(jsHeapSizeLimitBytes === undefined ? {} : { jsHeapSizeLimitBytes }),
...(usedJsHeapSizeBytes === undefined ? {} : { usedJsHeapSizeBytes }),
};
};
export const getExport4KPreflightError = ({
limits,
memoryInfo = {},
estimate = estimateExport4KMemory(),
}: Export4KPreflightOptions): RuntimeError | null => {
if (
estimate.width > limits.maxTextureDimension2D ||
estimate.height > limits.maxTextureDimension2D
) {
return new RuntimeError(
'export-4k-texture-too-large',
'This GPU cannot create a 3840x2160 export texture.',
{
details: {
exportWidth: estimate.width,
exportHeight: estimate.height,
maxTextureDimension2D: limits.maxTextureDimension2D,
},
}
);
}
if (estimate.readbackBufferBytes > limits.maxBufferSize) {
return new RuntimeError(
'export-4k-readback-too-large',
'This GPU cannot allocate the 4K export readback buffer.',
{
details: {
readbackBufferBytes: estimate.readbackBufferBytes,
maxBufferSize: limits.maxBufferSize,
},
}
);
}
if (
memoryInfo.deviceMemoryBytes !== undefined &&
memoryInfo.deviceMemoryBytes <= LOW_MEMORY_DEVICE_GIB * GIBIBYTE &&
estimate.estimatedPeakBytes >
memoryInfo.deviceMemoryBytes * LOW_MEMORY_EXPORT_FRACTION
) {
return new RuntimeError(
'export-4k-low-device-memory',
`4K upscale export needs about ${formatByteSize(
estimate.estimatedPeakBytes
)} of temporary memory, which is not safe on this low-memory device.`,
{
details: {
deviceMemoryBytes: memoryInfo.deviceMemoryBytes,
estimatedPeakBytes: estimate.estimatedPeakBytes,
},
}
);
}
if (
memoryInfo.jsHeapSizeLimitBytes !== undefined &&
memoryInfo.usedJsHeapSizeBytes !== undefined
) {
const availableJsHeapBytes =
memoryInfo.jsHeapSizeLimitBytes - memoryInfo.usedJsHeapSizeBytes;
if (
availableJsHeapBytes <
estimate.estimatedJsHeapBytes * JS_HEAP_SAFETY_MULTIPLIER
) {
return new RuntimeError(
'export-4k-low-js-heap',
`4K upscale export needs about ${formatByteSize(
estimate.estimatedJsHeapBytes
)} of JavaScript heap, and this browser does not report enough free heap.`,
{
details: {
availableJsHeapBytes,
estimatedJsHeapBytes: estimate.estimatedJsHeapBytes,
jsHeapSizeLimitBytes: memoryInfo.jsHeapSizeLimitBytes,
usedJsHeapSizeBytes: memoryInfo.usedJsHeapSizeBytes,
},
}
);
}
}
return null;
};

View file

@ -1,68 +1,56 @@
import { appConfig } from '../config';
import { RenderPipeline } from '../pipelines/render/render-pipeline';
import {
estimateExport4KMemory,
getAspectFitExport4KDimensions,
getBrowserExportMemoryInfo,
getExport4KPreflightError,
} from './export-4k';
import type { VibeId } from '../vibes';
interface Export4KRendererOptions {
interface ExportSnapshotRendererOptions {
device: GPUDevice;
renderPipeline: RenderPipeline;
canvasFormat: GPUTextureFormat;
statusElement: HTMLElement;
seed: string;
getSourceSize: () => { width: number; height: number };
getColorTextureView: () => GPUTextureView;
getSourceTextureView: () => GPUTextureView;
getVibeId: () => string;
getSourceActive?: () => boolean;
getVibeId: () => VibeId;
}
export class Export4KRenderer {
interface SnapshotLayout {
width: number;
height: number;
unpaddedBytesPerRow: number;
bytesPerRow: number;
readbackBufferBytes: number;
}
export class ExportSnapshotRenderer {
private isExporting = false;
public constructor(private readonly options: Export4KRendererOptions) {}
public constructor(private readonly options: ExportSnapshotRendererOptions) {}
public async export(): Promise<void> {
if (this.isExporting) {
this.statusElement.textContent = '4K upscale already rendering...';
this.statusElement.textContent = 'Snapshot already saving...';
return;
}
this.isExporting = true;
this.statusElement.textContent = 'Rendering 4K upscale...';
this.statusElement.textContent = 'Saving snapshot...';
try {
const sourceSize = this.options.getSourceSize();
const exportDimensions = getAspectFitExport4KDimensions(
sourceSize.width,
sourceSize.height
);
const estimate = estimateExport4KMemory(
exportDimensions.width,
exportDimensions.height
);
const preflightError = getExport4KPreflightError({
limits: this.device.limits,
memoryInfo: getBrowserExportMemoryInfo(),
estimate,
});
if (preflightError) {
this.statusElement.textContent = '4K upscale unavailable';
throw preflightError;
}
await this.renderExport(estimate);
await this.renderSnapshot(getSnapshotLayout(sourceSize.width, sourceSize.height));
this.statusElement.textContent = '';
} catch (error) {
this.statusElement.textContent = 'Snapshot failed';
throw error;
} finally {
this.isExporting = false;
}
}
private async renderExport(
estimate: ReturnType<typeof estimateExport4KMemory>
): Promise<void> {
const { width, height, unpaddedBytesPerRow, bytesPerRow } = estimate;
const format = navigator.gpu.getPreferredCanvasFormat();
private async renderSnapshot(layout: SnapshotLayout): Promise<void> {
const { width, height, unpaddedBytesPerRow, bytesPerRow } = layout;
let texture: GPUTexture | null = null;
let output: GPUBuffer | null = null;
let isOutputMapped = false;
@ -70,14 +58,11 @@ export class Export4KRenderer {
try {
texture = this.device.createTexture({
size: { width, height },
format,
usage:
GPUTextureUsage.RENDER_ATTACHMENT |
GPUTextureUsage.COPY_SRC |
GPUTextureUsage.TEXTURE_BINDING,
format: this.options.canvasFormat,
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
});
output = this.device.createBuffer({
size: estimate.readbackBufferBytes,
size: layout.readbackBufferBytes,
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
});
@ -86,7 +71,8 @@ export class Export4KRenderer {
commandEncoder,
this.options.getColorTextureView(),
this.options.getSourceTextureView(),
texture.createView()
texture.createView(),
this.options.getSourceActive?.() ?? true
);
commandEncoder.copyTextureToBuffer(
{ texture },
@ -97,13 +83,13 @@ export class Export4KRenderer {
await output.mapAsync(GPUMapMode.READ);
isOutputMapped = true;
const pixels = readExportPixels({
const pixels = readSnapshotPixels({
mapped: new Uint8Array(output.getMappedRange()),
width,
height,
unpaddedBytesPerRow,
bytesPerRow,
isBgra: format === 'bgra8unorm',
isBgra: this.options.canvasFormat === 'bgra8unorm',
});
output.unmap();
isOutputMapped = false;
@ -113,9 +99,6 @@ export class Export4KRenderer {
texture = null;
await this.downloadPixels(pixels, width, height);
} catch (error) {
this.statusElement.textContent = '4K upscale failed';
throw error;
} finally {
if (output && isOutputMapped) {
output.unmap();
@ -135,15 +118,18 @@ export class Export4KRenderer {
if (!context) {
throw new Error('Could not create export canvas');
}
context.putImageData(new ImageData(pixels, width, height), 0, 0);
const blob = await canvas.convertToBlob({ type: 'image/png' });
const blob = await canvas.convertToBlob({
type: appConfig.exportSnapshot.mimeType,
});
const link = document.createElement('a');
const objectUrl = URL.createObjectURL(blob);
try {
link.href = objectUrl;
link.download = `fleeting-garden_${this.options.getVibeId()}_${
link.download = `${appConfig.exportSnapshot.filenamePrefix}_${this.options.getVibeId()}_${
this.options.seed
}_${width}x${height}-upscale.png`;
}_${width}x${height}${appConfig.exportSnapshot.filenameSuffix}.${appConfig.exportSnapshot.filenameExtension}`;
link.click();
} finally {
URL.revokeObjectURL(objectUrl);
@ -159,7 +145,31 @@ export class Export4KRenderer {
}
}
const readExportPixels = ({
const alignTo = (value: number, alignment: number): number =>
Math.ceil(value / alignment) * alignment;
const getSnapshotDimension = (value: number): number =>
Number.isFinite(value) && value > 0 ? Math.max(1, Math.floor(value)) : 1;
const getSnapshotLayout = (sourceWidth: number, sourceHeight: number): SnapshotLayout => {
const width = getSnapshotDimension(sourceWidth);
const height = getSnapshotDimension(sourceHeight);
const unpaddedBytesPerRow = width * appConfig.exportSnapshot.bytesPerPixel;
const bytesPerRow = alignTo(
unpaddedBytesPerRow,
appConfig.exportSnapshot.rowAlignmentBytes
);
return {
width,
height,
unpaddedBytesPerRow,
bytesPerRow,
readbackBufferBytes: bytesPerRow * height,
};
};
const readSnapshotPixels = ({
mapped,
width,
height,
@ -181,8 +191,8 @@ const readExportPixels = ({
const sourceOffset = y * bytesPerRow;
const targetOffset = y * unpaddedBytesPerRow;
for (let x = 0; x < width; x++) {
const source = sourceOffset + x * 4;
const target = targetOffset + x * 4;
const source = sourceOffset + x * appConfig.exportSnapshot.bytesPerPixel;
const target = targetOffset + x * appConfig.exportSnapshot.bytesPerPixel;
pixels[target] = isBgra ? mapped[source + 2] : mapped[source];
pixels[target + 1] = mapped[source + 1];
pixels[target + 2] = isBgra ? mapped[source] : mapped[source + 2];

View file

@ -1,47 +0,0 @@
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

@ -1,138 +1,61 @@
import { appConfig } from '../config';
interface TelemetrySnapshot {
frameCpuStartedAt: number;
encodeCpuMs: number;
activeAgentCount: 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;
import { settings } from '../settings';
export class FramePerformance {
public latestFps = 60;
public smoothedFps = 60;
public displayRefreshFps = 60;
public readonly refreshTargetFps = 60;
private readonly adaptiveRefreshTargetFps = 60;
private readonly initialFps = this.adaptiveRefreshTargetFps;
public smoothedFps = this.initialFps;
private lastTelemetryAt = 0;
private hasConfirmedDisplayRefreshFps = false;
private pendingDisplayRefreshFps = 0;
private pendingDisplayRefreshFrameCount = 0;
public measuredFps = 0;
public frameDeltaSeconds = 0;
public measuredFrameTimeMs = 0;
public markCpuStart(): number {
return appConfig.telemetry.enabled ? performance.now() : 0;
private readonly adaptiveCapDecreaseAgentsPerSecond = 200_000;
private readonly frameGapResetSeconds = 1;
private readonly fpsHeadroom = 0.9;
private readonly fpsSmoothingNew = 0.06;
private readonly fpsSmoothingRetain = 1 - this.fpsSmoothingNew;
private previousFrameTime: DOMHighResTimeStamp | null = null;
public get adaptiveCapInitial(): number {
return settings.adaptiveCapInitial;
}
public measureSince(startedAt: number): number {
return appConfig.telemetry.enabled ? performance.now() - startedAt : 0;
public get adaptiveCapMin(): number {
return settings.adaptiveCapMin;
}
public update(deltaTime: number): void {
const fps = 1 / Math.max(deltaTime, appConfig.deltaTime.minDeltaTimeSeconds);
this.latestFps = fps;
this.updateDisplayRefreshEstimate(fps);
public get hasAdaptiveCapHeadroom(): boolean {
return this.smoothedFps >= this.adaptiveRefreshTargetFps * this.fpsHeadroom;
}
public get adaptiveCapDecreaseAgents(): number {
return Math.max(
1,
Math.ceil(this.adaptiveCapDecreaseAgentsPerSecond * this.frameDeltaSeconds)
);
}
public update(time: DOMHighResTimeStamp): void {
const previous = this.previousFrameTime;
this.previousFrameTime = time;
if (previous === null) {
return;
}
const deltaSeconds = (time - previous) / 1000;
if (deltaSeconds <= 0) {
return;
}
const fps = 1 / deltaSeconds;
this.frameDeltaSeconds = deltaSeconds;
this.measuredFrameTimeMs = deltaSeconds * 1000;
this.measuredFps = fps;
if (deltaSeconds > this.frameGapResetSeconds) {
return;
}
this.smoothedFps =
this.smoothedFps * appConfig.simulation.budget.fpsSmoothingRetain +
fps * appConfig.simulation.budget.fpsSmoothingNew;
}
public renderTelemetry({
frameCpuStartedAt,
encodeCpuMs,
activeAgentCount,
agentBudgetMax,
canvas,
devicePixelRatio,
renderSpeed,
}: TelemetrySnapshot): void {
if (!appConfig.telemetry.enabled) {
return;
}
const now = performance.now();
if (now - this.lastTelemetryAt < appConfig.telemetry.intervalMs) {
return;
}
this.lastTelemetryAt = now;
console.debug('Fleeting Garden telemetry', {
fps: Math.round(this.latestFps),
smoothedFps: Math.round(this.smoothedFps),
refreshTargetFps: Math.round(this.refreshTargetFps),
displayRefreshFps: Math.round(this.displayRefreshFps),
activeAgentCount,
agentBudgetMax,
canvasWidth: canvas.width,
canvasHeight: canvas.height,
dpr: devicePixelRatio,
renderSpeed,
frameCpuMs: now - frameCpuStartedAt,
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;
this.smoothedFps * this.fpsSmoothingRetain + fps * this.fpsSmoothingNew;
}
}

View file

@ -1,28 +0,0 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
const gameLoopSource = readFileSync(
join(process.cwd(), 'src/game-loop/game-loop.ts'),
'utf8'
);
const getStartDrawingHandlerSource = () => {
const start = gameLoopSource.indexOf('onStartDrawing:');
const end = gameLoopSource.indexOf('onEraseGestureEnded:', start);
if (start < 0 || end < 0) {
throw new Error('Could not find the pointer drawing intro handler');
}
return gameLoopSource.slice(start, end);
};
describe('GameLoop intro drawing policy', () => {
it('allows drawing to start without completing the intro sequence', () => {
const handlerSource = getStartDrawingHandlerSource();
expect(handlerSource).toContain('this.introPrompt.markStartedDrawing()');
expect(handlerSource).not.toContain('this.introPrompt.complete(');
});
});

View file

@ -1,50 +0,0 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
const simulationFrameSource = readFileSync(
join(process.cwd(), 'src/game-loop/simulation-frame.ts'),
'utf8'
);
const simulationTexturesSource = readFileSync(
join(process.cwd(), 'src/game-loop/simulation-textures.ts'),
'utf8'
);
const getRenderStepSource = () => {
const start = simulationFrameSource.indexOf('for (let i = 0; i < renderSpeed; i++)');
const end = simulationFrameSource.indexOf(' public clearSwipes', start);
if (start < 0 || end < 0) {
throw new Error('Could not find the render-speed simulation loop');
}
return simulationFrameSource.slice(start, end);
};
describe('GameLoop ping-pong texture flow', () => {
it('copies only the trail map and swaps source/influence references after diffusion', () => {
const renderStepSource = getRenderStepSource();
expect(renderStepSource.match(/copyPipeline\.execute/g)).toHaveLength(1);
expect(renderStepSource).toMatch(
/this\.pipelines\.copyPipeline\.execute\([\s\S]*this\.textures\.trailMapA\.getTextureView\(\)[\s\S]*this\.textures\.trailMapB\.getTextureView\(\)[\s\S]*\);/
);
expect(renderStepSource).toMatch(
/this\.pipelines\.diffusionPipeline\.execute\([\s\S]*this\.textures\.sourceMapA\.getTextureView\(\)[\s\S]*this\.textures\.sourceMapB\.getTextureView\(\)[\s\S]*\);[\s\S]*this\.pipelines\.brushEffectDiffusionPipeline\.execute\([\s\S]*this\.textures\.influenceMapA\.getTextureView\(\)[\s\S]*this\.textures\.influenceMapB\.getTextureView\(\)[\s\S]*\);[\s\S]*this\.device\.queue\.submit\(\[commandEncoder\.finish\(\)\]\);[\s\S]*this\.textures\.swapSourceMaps\(\);[\s\S]*this\.textures\.swapInfluenceMaps\(\);/
);
});
it('keeps ping-pong texture references mutable and swaps A/B identities', () => {
expect(simulationTexturesSource).toContain('public sourceMapA: ResizableTexture;');
expect(simulationTexturesSource).toContain('public sourceMapB: ResizableTexture;');
expect(simulationTexturesSource).toContain('public influenceMapA: ResizableTexture;');
expect(simulationTexturesSource).toContain('public influenceMapB: ResizableTexture;');
expect(simulationTexturesSource).toContain(
'[this.sourceMapA, this.sourceMapB] = [this.sourceMapB, this.sourceMapA];'
);
expect(simulationTexturesSource).toContain(
'[this.influenceMapA, this.influenceMapB] = [this.influenceMapB, this.influenceMapA];'
);
});
});

View file

@ -5,15 +5,14 @@ import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/ag
import { AgentPipeline } from '../pipelines/agents/agent-pipeline';
import { BrushPipeline } from '../pipelines/brush/brush-pipeline';
import { CommonState } from '../pipelines/common-state/common-state';
import { CopyPipeline } from '../pipelines/copy/copy-pipeline';
import { DiffusionPipeline } from '../pipelines/diffusion/diffusion-pipeline';
import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline';
import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline';
import { RenderPipeline } from '../pipelines/render/render-pipeline';
import { settings } from '../settings';
import { initializeContext } from '../utils/graphics/initialize-context';
import { GLOBAL_AGENT_CAP } from './agent-population';
import { RenderInputs } from './game-loop-types';
import { CanvasReadbackRequest, RenderInputs } from './game-loop-types';
import { GpuProfiler } from './gpu-profiler';
import { SimulationFrameRenderer } from './simulation-frame';
import { SimulationTextures } from './simulation-textures';
@ -22,109 +21,121 @@ interface FrameParameters extends RenderInputs {
deltaTime: number;
canvasSize: vec2;
activeAgentCount: number;
canvasPixelRatio: number;
introProgress: number;
selectedColorIndex: number;
isErasing: boolean;
cameraCenter: [number, number];
cameraZoom: number;
eraserPixelSize: number;
}
export class GameLoopResources {
public readonly textures: SimulationTextures;
public readonly commonState: CommonState;
public readonly copyPipeline: CopyPipeline;
public readonly agentGenerationPipeline: AgentGenerationPipeline;
public readonly agentPipeline: AgentPipeline;
public readonly brushPipeline: BrushPipeline;
public readonly eraserAgentPipeline: EraserAgentPipeline;
public readonly eraserTexturePipeline: EraserTexturePipeline;
public readonly diffusionPipeline: DiffusionPipeline;
public readonly brushEffectDiffusionPipeline: DiffusionPipeline;
public readonly renderPipeline: RenderPipeline;
public readonly gpuProfiler: GpuProfiler | null;
private readonly frameRenderer: SimulationFrameRenderer;
public constructor(
canvas: HTMLCanvasElement,
private readonly device: GPUDevice,
canvasSize: vec2
private readonly canvasFormat: GPUTextureFormat,
canvasSize: vec2,
initialAgentCapacity: number
) {
const context = initializeContext({ device, canvas });
const context = initializeContext({ device, canvas, format: canvasFormat });
this.textures = new SimulationTextures(this.device, canvasSize);
this.copyPipeline = new CopyPipeline(this.device);
this.commonState = new CommonState(this.device);
this.commonState.setParameters({
canvasSize,
time: 0,
deltaTime: 0,
});
this.agentGenerationPipeline = new AgentGenerationPipeline(
this.device,
this.commonState,
GLOBAL_AGENT_CAP
Math.min(settings.maxAgentCount, initialAgentCapacity)
);
this.agentPipeline = new AgentPipeline(
this.device,
this.commonState,
this.agentGenerationPipeline.agentsBuffer
() => this.agentGenerationPipeline.agentsBuffer
);
this.brushPipeline = new BrushPipeline(this.device, this.commonState);
this.eraserAgentPipeline = new EraserAgentPipeline(
this.device,
this.commonState,
this.agentGenerationPipeline.agentsBuffer
() => this.agentGenerationPipeline.agentsBuffer
);
this.eraserTexturePipeline = new EraserTexturePipeline(this.device, this.commonState);
this.diffusionPipeline = new DiffusionPipeline(this.device, this.commonState);
this.brushEffectDiffusionPipeline = new DiffusionPipeline(
this.diffusionPipeline = new DiffusionPipeline(this.device);
this.renderPipeline = new RenderPipeline(
context,
this.device,
this.commonState
this.commonState,
this.canvasFormat
);
this.gpuProfiler = GpuProfiler.create(
this.device,
() => appConfig.tuningPane.showFpsOverlay
);
this.renderPipeline = new RenderPipeline(context, this.device, this.commonState);
this.frameRenderer = new SimulationFrameRenderer(this.device, this.textures, {
copyPipeline: this.copyPipeline,
agentPipeline: this.agentPipeline,
brushPipeline: this.brushPipeline,
eraserAgentPipeline: this.eraserAgentPipeline,
eraserTexturePipeline: this.eraserTexturePipeline,
diffusionPipeline: this.diffusionPipeline,
brushEffectDiffusionPipeline: this.brushEffectDiffusionPipeline,
renderPipeline: this.renderPipeline,
});
this.frameRenderer = new SimulationFrameRenderer(
this.device,
this.textures,
{
agentPipeline: this.agentPipeline,
brushPipeline: this.brushPipeline,
eraserAgentPipeline: this.eraserAgentPipeline,
eraserTexturePipeline: this.eraserTexturePipeline,
diffusionPipeline: this.diffusionPipeline,
renderPipeline: this.renderPipeline,
},
this.gpuProfiler
);
}
public resizeSimulationTo(nextSize: vec2): vec2 | null {
return this.textures.resizeTo(nextSize);
}
public clearSimulation(): void {
this.textures.clear();
this.frameRenderer.resetSourceMapActivity();
}
public get isSourceMapActive(): boolean {
return this.frameRenderer.isSourceMapActive;
}
public get gpuPassTimeMs(): number | undefined {
return this.gpuProfiler?.latestTotalPassMs;
}
public setFrameParameters({
time,
deltaTime,
canvasSize,
activeAgentCount,
canvasPixelRatio,
introProgress,
selectedColorIndex,
isErasing,
channelColors,
backgroundColor,
cameraCenter,
cameraZoom,
eraserPixelSize,
}: FrameParameters): void {
this.commonState.setParameters({
canvasSize,
time,
deltaTime,
});
this.agentPipeline.setParameters({
...settings,
deltaTime,
time,
agentCount: activeAgentCount,
moveSpeed:
settings.moveSpeed *
@ -136,57 +147,48 @@ export class GameLoopResources {
});
this.brushPipeline.setParameters({
...settings,
pixelRatio: canvasPixelRatio,
selectedColorIndex,
isErasing,
});
this.diffusionPipeline.setParameters(settings);
this.renderPipeline.setParameters({
...settings,
channelColors,
backgroundColor,
cameraCenter,
cameraZoom,
});
this.eraserAgentPipeline.setParameters({
agentCount: activeAgentCount,
eraserSize: eraserPixelSize,
eraserMaskAlphaThreshold: settings.eraserMaskAlphaThreshold,
maskSize: canvasSize,
});
this.eraserTexturePipeline.setParameters({
eraserSize: eraserPixelSize,
eraserLineDistanceEpsilon: settings.eraserLineDistanceEpsilon,
eraserClearRed: settings.eraserClearRed,
eraserClearGreen: settings.eraserClearGreen,
eraserClearBlue: settings.eraserClearBlue,
eraserClearAlpha: settings.eraserClearAlpha,
});
this.setBrushEffectDiffusionParameters();
}
public executeFrame(renderSpeed: number, isErasing: boolean): void {
this.frameRenderer.execute(renderSpeed, isErasing);
}
public clearSwipes(): void {
this.frameRenderer.clearSwipes();
public executeFrame(
isErasing: boolean,
canvasReadbackRequest?: CanvasReadbackRequest | null
): void {
this.frameRenderer.execute(isErasing, canvasReadbackRequest);
}
public destroy(): void {
this.copyPipeline.destroy();
this.agentGenerationPipeline.destroy();
this.agentPipeline.destroy();
this.brushPipeline.destroy();
this.eraserAgentPipeline.destroy();
this.eraserTexturePipeline.destroy();
this.diffusionPipeline.destroy();
this.brushEffectDiffusionPipeline.destroy();
this.renderPipeline.destroy();
this.gpuProfiler?.destroy();
this.commonState.destroy();
this.textures.destroy();
}
private setBrushEffectDiffusionParameters(): void {
const framesToOneE = Math.max(
1,
settings.brushEffectDuration * appConfig.simulation.brushEffectFramesPerSecond
);
this.brushEffectDiffusionPipeline.setParameters({
...settings,
decayRateTrails: Math.exp(-1 / framesToOneE) * 1000,
});
}
}

View file

@ -1,10 +0,0 @@
export interface GameLoopSettings {
agentBudgetMax: number;
agentCount: number;
renderSpeed: number;
simulatedDelayMs: number;
selectedColorIndex: number;
spawnPerPixel: number;
startColorHue: number;
}

View file

@ -1,17 +1,26 @@
import { vec2 } from 'gl-matrix';
import type { RgbColor } from '../utils/rgb-color';
export interface GardenUi {
prompt: HTMLElement;
eraserPreview: HTMLElement;
exportStatus: HTMLElement;
grainOverlay: HTMLElement;
prompt: HTMLElement;
toolbar: HTMLElement;
}
export interface RenderInputs {
channelColors: Array<[number, number, number]>;
backgroundColor: [number, number, number];
channelColors: [RgbColor, RgbColor, RgbColor];
backgroundColor: RgbColor;
}
export interface StrokeSegment {
from: vec2;
to: vec2;
}
export interface CanvasReadbackRequest {
encode(commandEncoder: GPUCommandEncoder, texture: GPUTexture): void;
afterSubmit(): void;
}

View file

@ -1,126 +1,146 @@
import { vec2 } from 'gl-matrix';
import { GardenAudio } from '../audio/garden-audio';
import { gardenAudioConfig } from '../audio/garden-audio-config';
import { appConfig } from '../config';
import { activeVibe, settings } from '../settings';
import { DeltaTimeCalculator } from '../utils/delta-time-calculator';
import { sleep } from '../utils/sleep';
import { rgbColorToCss, type RgbColor } from '../utils/rgb-color';
import { AgentPopulation } from './agent-population';
import { EraserPreview } from './eraser-preview';
import { Export4KRenderer } from './export-4k-renderer';
import { ExportSnapshotRenderer } from './export-snapshot-renderer';
import { FramePerformance } from './frame-performance';
import { GameLoopResources } from './game-loop-resources';
import { GardenUi } from './game-loop-types';
import { getInternalRenderSize } from './internal-render-size';
import { IntroPrompt } from './intro-prompt';
import { PerfStatsOverlay } from './perf-stats-overlay';
import { GardenPointerInput } from './pointer-input';
import { RenderInputCache } from './render-input-cache';
import { PipelineStrokeOutput } from './stroke-output';
import { ToolbarContrastMonitor } from './toolbar-contrast-monitor';
export default class GameLoop {
private static readonly MAX_MIRROR_SEGMENT_COUNT =
appConfig.simulation.maxMirrorSegmentCount;
private static readonly DEV_STATS_INTERVAL_MS = 250;
private readonly resources: GameLoopResources;
private readonly audio = new GardenAudio(
gardenAudioConfig,
appConfig.audioEngine,
appConfig.simulation.maxMirrorSegmentCount
);
private readonly renderInputs = new RenderInputCache();
private readonly audio = new GardenAudio(appConfig.audio);
private readonly introPrompt: IntroPrompt;
private readonly eraserPreview: EraserPreview;
private readonly pointerInput: GardenPointerInput;
private readonly agentPopulation: AgentPopulation;
private readonly export4KRenderer: Export4KRenderer;
private readonly exportSnapshotRenderer: ExportSnapshotRenderer;
private readonly framePerformance = new FramePerformance();
private readonly devStatsElement: HTMLDivElement | null;
private readonly seed = Math.floor(Math.random() * 0xffffffff).toString(16);
private perfStatsOverlay: PerfStatsOverlay | null = null;
private readonly toolbarContrastMonitor: ToolbarContrastMonitor;
private readonly seedValue = Math.floor(Math.random() * 0xffffffff);
private readonly seed = this.seedValue.toString(16);
private readonly resizeListener = this.resize.bind(this);
private readonly keydownListener: (event: KeyboardEvent) => void;
private readonly _canvasSize: vec2 = vec2.create();
private lastDevStatsUpdateAt = 0;
private isStatsOverlayPinned = false;
private pendingIntroResizeAt: DOMHighResTimeStamp | null = null;
private previousAccentColor = '';
private previousGrainStrength = Number.NaN;
private hasFinished = false;
private animationFrameId: number | null = null;
private destroyPromise: Promise<void> | null = null;
private readonly finished = Promise.withResolvers<void>();
public constructor(
private readonly canvas: HTMLCanvasElement,
device: GPUDevice,
private readonly device: GPUDevice,
private readonly canvasFormat: GPUTextureFormat,
private readonly deltaTimeCalculator: DeltaTimeCalculator,
ui: GardenUi
private readonly ui: GardenUi
) {
this.resize();
this.devStatsElement = this.createDevStatsElement();
this.syncDevStatsVisibility();
this.resources = new GameLoopResources(canvas, device, this.canvasSize);
this.resources = new GameLoopResources(
canvas,
device,
this.canvasFormat,
this.canvasSize,
this.framePerformance.adaptiveCapInitial
);
this.introPrompt = new IntroPrompt(ui.prompt);
this.eraserPreview = new EraserPreview(canvas, ui.eraserPreview);
this.agentPopulation = new AgentPopulation(this.resources.agentGenerationPipeline);
this.toolbarContrastMonitor = new ToolbarContrastMonitor(
canvas,
ui.toolbar,
device,
this.canvasFormat
);
this.agentPopulation = new AgentPopulation(
this.resources.agentGenerationPipeline,
this.seedValue,
() => this.canvasPixelRatio,
this.framePerformance
);
this.agentPopulation.initializeIntroAgents(this.canvasSize);
this.pointerInput = new GardenPointerInput({
canvas,
audio: this.audio,
brushPipeline: this.resources.brushPipeline,
eraserAgentPipeline: this.resources.eraserAgentPipeline,
eraserTexturePipeline: this.resources.eraserTexturePipeline,
eraserPreview: this.eraserPreview,
getCanvasSize: () => this.canvasSize,
getDevicePixelRatio: () => this.devicePixelRatio,
strokeOutput: new PipelineStrokeOutput(
this.resources.brushPipeline,
this.resources.eraserAgentPipeline,
this.resources.eraserTexturePipeline
),
getCanvasPixelRatio: () => this.canvasPixelRatio,
getMirrorSegmentCount: () => this.mirrorSegmentCount,
onStartDrawing: () => this.introPrompt.markStartedDrawing(),
onStartDrawing: () => {
this.introPrompt.markStartedDrawing();
this.agentPopulation.beginStroke();
},
onEraseGestureEnded: () => this.agentPopulation.requestCompactionAfterErase(),
spawnStrokeAgents: (from, to) => this.agentPopulation.spawnStrokeAgents(from, to),
});
this.export4KRenderer = new Export4KRenderer({
this.eraserPreview = new EraserPreview(
canvas,
ui.eraserPreview,
() => this.pointerInput.isSwipeActive
);
this.exportSnapshotRenderer = new ExportSnapshotRenderer({
device,
renderPipeline: this.resources.renderPipeline,
canvasFormat: this.canvasFormat,
statusElement: ui.exportStatus,
seed: this.seed,
getSourceSize: () => ({
width: this.canvas.width,
height: this.canvas.height,
}),
getSourceSize: () => {
const size = this.resources.textures.trailMapA.getSize();
return {
width: size[0],
height: size[1],
};
},
getColorTextureView: () => this.resources.textures.trailMapA.getTextureView(),
getSourceTextureView: () => this.resources.textures.sourceMapA.getTextureView(),
getSourceActive: () => this.resources.isSourceMapActive,
getVibeId: () => activeVibe.id,
});
this.keydownListener = (event: KeyboardEvent) => {
this.audio.start(activeVibe, { userGesture: event.isTrusted });
this.introPrompt.complete();
};
window.addEventListener('resize', this.resizeListener);
window.addEventListener('keydown', this.keydownListener, { once: true });
this.eraserPreview.attach();
this.syncPerfStatsOverlay();
}
public attachPointerInput(): void {
this.pointerInput.attach();
}
public setEraseMode(isErasing: boolean): void {
this.pointerInput.setEraseMode(isErasing);
this.eraserPreview.setEraseMode(isErasing);
}
public updateEraserPreview(event?: PointerEvent): void {
this.pointerInput.updateEraserPreview(event);
this.eraserPreview.update(event);
}
public onVibeChanged(): void {
this.agentPopulation.onVibeChanged();
this.renderInputs.invalidate();
this.syncPerfStatsOverlay();
}
public setAudioMuted(isMuted: boolean): void {
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 setAudioVolume(volume: number): void {
this.audio.setMasterVolume(volume);
}
public startAudio(userGesture = false): void {
@ -132,162 +152,144 @@ export default class GameLoop {
}
public async start(): Promise<void> {
requestAnimationFrame(this.render);
if (this.animationFrameId === null && !this.hasFinished) {
this.animationFrameId = requestAnimationFrame(this.render);
}
return this.finished.promise;
}
public get maxAgentCount(): number {
return this.agentPopulation.maxAgentCount;
}
public async export4K(): Promise<void> {
return this.export4KRenderer.export();
public async exportSnapshot(): Promise<void> {
return this.exportSnapshotRenderer.export();
}
public async destroy(): Promise<void> {
this.destroyPromise ??= this.dispose();
return this.destroyPromise;
}
private async dispose(): Promise<void> {
this.hasFinished = true;
await this.finished.promise;
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
this.finished.resolve();
window.removeEventListener('resize', this.resizeListener);
window.removeEventListener('keydown', this.keydownListener);
this.pointerInput.detach();
this.devStatsElement?.remove();
this.eraserPreview.detach();
this.perfStatsOverlay?.destroy();
this.perfStatsOverlay = null;
this.toolbarContrastMonitor.destroy();
this.introPrompt.destroy();
await this.agentPopulation.waitForCompaction();
this.resources.destroy();
await this.audio.destroy();
}
private readonly render = async (time: DOMHighResTimeStamp) => {
private readonly render = (time: DOMHighResTimeStamp) => {
this.animationFrameId = null;
if (this.hasFinished) {
this.finished.resolve();
return;
}
const frameCpuStartedAt = this.framePerformance.markCpuStart();
const deltaTime = this.deltaTimeCalculator.calculateDeltaTimeInSeconds(time);
this.framePerformance.update(deltaTime);
this.agentPopulation.growBudget(
deltaTime,
this.framePerformance.smoothedFps,
this.framePerformance.refreshTargetFps
);
this.introPrompt.update();
this.framePerformance.update(time);
this.agentPopulation.updateAdaptiveCap();
this.introPrompt.update(this.pendingIntroResizeAt === null ? deltaTime : 0);
this.resize();
this.resizeSimulationToCanvas();
this.resizeSimulationToCanvas(time);
this.regenerateIntroAfterSettledResize(time);
const scaledTime = time * settings.renderSpeed;
const { channelColors, backgroundColor } = this.renderInputs.get();
const channelColors = activeVibe.colors;
const backgroundColor = activeVibe.backgroundColor;
const introProgress = this.introPrompt.progress;
const cameraZoom = 1;
const cameraCenter: [number, number] = [
this.canvas.width / 2,
this.canvas.height / 2,
];
const eraserPixelSize = settings.eraserSize * this.devicePixelRatio;
const canvasPixelRatio = this.canvasPixelRatio;
const eraserPixelSize = settings.eraserSize * canvasPixelRatio;
const isErasing = this.pointerInput.isEraseMode;
const accentColor = channelColors[settings.selectedColorIndex] ?? channelColors[0];
this.renderInputs.updateAccentColor(accentColor);
this.updateAccentColor(accentColor);
this.updateGrainOverlay(settings.backgroundGrainStrength);
this.audio.update({
vibe: activeVibe,
selectedColorIndex: settings.selectedColorIndex,
isErasing,
mirrorSegmentCount: this.mirrorSegmentCount,
});
this.resources.setFrameParameters({
time: scaledTime,
time,
deltaTime,
canvasSize: this.canvasSize,
activeAgentCount: this.agentPopulation.activeAgentCount,
canvasPixelRatio,
introProgress,
selectedColorIndex: settings.selectedColorIndex,
isErasing,
channelColors,
backgroundColor,
cameraCenter,
cameraZoom,
eraserPixelSize,
});
const encodeCpuStartedAt = this.framePerformance.markCpuStart();
this.resources.executeFrame(settings.renderSpeed, isErasing);
const encodeCpuMs = this.framePerformance.measureSince(encodeCpuStartedAt);
this.resources.executeFrame(
isErasing,
this.toolbarContrastMonitor.takeReadbackRequest(time)
);
this.pointerInput.clearSwipesIfIdle();
await this.agentPopulation.compactAfterErase(this.pointerInput.isSwipeActive);
this.framePerformance.renderTelemetry({
frameCpuStartedAt,
encodeCpuMs,
activeAgentCount: this.agentPopulation.activeAgentCount,
agentBudgetMax: settings.agentBudgetMax,
canvas: this.canvas,
devicePixelRatio: this.devicePixelRatio,
renderSpeed: settings.renderSpeed,
this.agentPopulation.compactAfterErase(this.pointerInput.isSwipeActive);
this.perfStatsOverlay?.update({
time,
fps: this.framePerformance.measuredFps,
agentCount: this.agentPopulation.activeAgentCount,
frameTimeMs: this.framePerformance.measuredFrameTimeMs,
gpuPassTimeMs: this.resources.gpuPassTimeMs,
renderWidth: this.canvas.width,
renderHeight: this.canvas.height,
});
this.updateDevStats(time);
if (settings.simulatedDelayMs > 0) {
await sleep(settings.simulatedDelayMs);
}
requestAnimationFrame(this.render);
this.animationFrameId = requestAnimationFrame(this.render);
};
private createDevStatsElement(): HTMLDivElement | null {
const container = this.canvas.parentElement;
if (!container) {
return null;
}
const element = document.createElement('div');
element.className = 'dev-stats-overlay';
element.setAttribute('aria-hidden', 'true');
container.appendChild(element);
return element;
}
private updateDevStats(time: DOMHighResTimeStamp): void {
if (
!this.devStatsElement ||
!this.shouldShowDevStats ||
time - this.lastDevStatsUpdateAt < GameLoop.DEV_STATS_INTERVAL_MS
) {
private syncPerfStatsOverlay(): void {
if (appConfig.tuningPane.showFpsOverlay) {
this.perfStatsOverlay ??= new PerfStatsOverlay(
this.canvas.parentElement ?? document.body
);
return;
}
this.lastDevStatsUpdateAt = time;
const displayRefreshFps = Math.round(this.framePerformance.displayRefreshFps);
this.devStatsElement.textContent = [
`FPS ${this.framePerformance.smoothedFps.toFixed(1)} / ${displayRefreshFps}`,
`Agents ${this.formatDevStatNumber(this.agentPopulation.activeAgentCount)}`,
`Cap ${this.formatDevStatNumber(settings.agentBudgetMax)}`,
].join('\n');
this.perfStatsOverlay?.destroy();
this.perfStatsOverlay = null;
}
private syncDevStatsVisibility(): void {
if (!this.devStatsElement) {
private updateAccentColor(color: RgbColor): void {
const accentColor = rgbColorToCss(color);
if (this.previousAccentColor === accentColor) {
return;
}
const isVisible = this.shouldShowDevStats;
this.devStatsElement.hidden = !isVisible;
this.devStatsElement.setAttribute('aria-hidden', String(!isVisible));
this.previousAccentColor = accentColor;
document.documentElement.style.setProperty('--accent-color', accentColor);
}
private formatDevStatNumber(value: number): string {
return Math.max(0, Math.round(value)).toLocaleString('en-US');
private updateGrainOverlay(strength: number): void {
const safeStrength = Number.isFinite(strength) ? Math.max(0, strength) : 0;
if (Object.is(this.previousGrainStrength, safeStrength)) {
return;
}
this.previousGrainStrength = safeStrength;
this.grainOverlay.hidden = safeStrength <= 0;
this.grainOverlay.style.setProperty('--garden-grain-strength', String(safeStrength));
}
private resize(): void {
const width = Math.max(
1,
Math.floor(this.canvas.clientWidth * this.devicePixelRatio)
);
const height = Math.max(
1,
Math.floor(this.canvas.clientHeight * this.devicePixelRatio)
);
const rect = this.canvas.getBoundingClientRect();
const { width, height } = getInternalRenderSize({
clientHeight: rect.height || this.canvas.clientHeight,
clientWidth: rect.width || this.canvas.clientWidth,
maxTextureDimension: this.device.limits.maxTextureDimension2D,
targetAreaMegapixels: settings.internalRenderAreaMegapixels,
});
if (this.canvas.width === width && this.canvas.height === height) {
return;
@ -297,7 +299,7 @@ export default class GameLoop {
this.canvas.height = height;
}
private resizeSimulationToCanvas(): void {
private resizeSimulationToCanvas(time: DOMHighResTimeStamp): void {
const scale = this.resources.resizeSimulationTo(this.canvasSize);
if (!scale) {
return;
@ -305,25 +307,58 @@ export default class GameLoop {
this.agentPopulation.resizeAgents(scale);
this.pointerInput.scaleLastPointerPosition(scale);
if (this.introPrompt.shouldRegenerateTitleOnResize) {
this.pendingIntroResizeAt = time;
}
}
private regenerateIntroAfterSettledResize(time: DOMHighResTimeStamp): void {
if (this.pendingIntroResizeAt === null) {
return;
}
if (!this.introPrompt.shouldRegenerateTitleOnResize) {
this.pendingIntroResizeAt = null;
return;
}
if (time - this.pendingIntroResizeAt < appConfig.simulation.intro.resizeSettleMs) {
return;
}
this.introPrompt.rewindToLeaveRemainingTime(
appConfig.simulation.intro.resizeMinimumRemainingSeconds
);
this.resources.clearSimulation();
this.agentPopulation.replaceIntroAgents(this.canvasSize, this.introPrompt.progress);
this.pendingIntroResizeAt = null;
}
private get canvasSize(): vec2 {
return vec2.fromValues(this.canvas.width, this.canvas.height);
vec2.set(this._canvasSize, this.canvas.width, this.canvas.height);
return this._canvasSize;
}
private get devicePixelRatio(): number {
const ratio = window.devicePixelRatio;
private get canvasPixelRatio(): number {
const rect = this.canvas.getBoundingClientRect();
const xScale = rect.width > 0 ? this.canvas.width / rect.width : 1;
const yScale = rect.height > 0 ? this.canvas.height / rect.height : xScale;
const ratio = (xScale + yScale) / 2;
return Number.isFinite(ratio) && ratio > 0 ? ratio : 1;
}
private get mirrorSegmentCount(): number {
const count = Number.isFinite(settings.mirrorSegmentCount)
? settings.mirrorSegmentCount
: 1;
return Math.min(GameLoop.MAX_MIRROR_SEGMENT_COUNT, Math.max(1, Math.round(count)));
: appConfig.toolbar.mirror.min;
return Math.min(
appConfig.toolbar.mirror.max,
Math.max(appConfig.toolbar.mirror.min, Math.round(count))
);
}
private get shouldShowDevStats(): boolean {
return import.meta.env.DEV || this.isStatsOverlayPinned;
private get grainOverlay(): HTMLElement {
return this.ui.grainOverlay;
}
}

View file

@ -0,0 +1,173 @@
const PASS_NAMES = [
'brush',
'eraserTexture',
'eraserAgent',
'agent',
'trailDiffusion',
'render',
'sourceDiffusion',
] as const;
export type GpuPassName = (typeof PASS_NAMES)[number];
interface GpuProfilerSample {
frame: number;
passes: Partial<Record<GpuPassName, number>>;
totalPassMs: number;
}
interface ActivePass {
endQueryIndex: number;
name: GpuPassName;
startQueryIndex: number;
}
interface ReadbackSlot {
buffer: GPUBuffer;
state: 'idle' | 'encoding' | 'mapping';
}
const MAX_QUERY_COUNT = PASS_NAMES.length * 2;
const QUERY_BYTES = BigUint64Array.BYTES_PER_ELEMENT;
const READBACK_SLOT_COUNT = 4;
export class GpuProfiler {
private readonly querySet: GPUQuerySet;
private readonly resolveBuffer: GPUBuffer;
private readonly readbackSlots: Array<ReadbackSlot>;
private readonly isEnabled: () => boolean;
private activePasses: Array<ActivePass> = [];
private nextQueryIndex = 0;
private frame = 0;
private latestSample: GpuProfilerSample | null = null;
public static create(device: GPUDevice, isEnabled: () => boolean): GpuProfiler | null {
if (!device.features.has('timestamp-query')) {
return null;
}
return new GpuProfiler(device, isEnabled);
}
private constructor(device: GPUDevice, isEnabled: () => boolean) {
this.isEnabled = isEnabled;
this.querySet = device.createQuerySet({
type: 'timestamp',
count: MAX_QUERY_COUNT,
});
this.resolveBuffer = device.createBuffer({
size: MAX_QUERY_COUNT * QUERY_BYTES,
usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC,
});
this.readbackSlots = Array.from({ length: READBACK_SLOT_COUNT }, () => ({
buffer: device.createBuffer({
size: MAX_QUERY_COUNT * QUERY_BYTES,
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
}),
state: 'idle' as const,
}));
}
public beginFrame(): void {
this.frame += 1;
this.activePasses = [];
this.nextQueryIndex = 0;
}
public timestampWrites(
name: GpuPassName
): (GPUComputePassTimestampWrites & GPURenderPassTimestampWrites) | undefined {
if (!this.isEnabled()) {
return undefined;
}
if (this.nextQueryIndex + 1 >= MAX_QUERY_COUNT) {
return undefined;
}
const startQueryIndex = this.nextQueryIndex;
const endQueryIndex = this.nextQueryIndex + 1;
this.nextQueryIndex += 2;
this.activePasses.push({
endQueryIndex,
name,
startQueryIndex,
});
return {
querySet: this.querySet,
beginningOfPassWriteIndex: startQueryIndex,
endOfPassWriteIndex: endQueryIndex,
};
}
public resolve(commandEncoder: GPUCommandEncoder): (() => void) | null {
const queryCount = this.nextQueryIndex;
if (queryCount === 0 || this.activePasses.length === 0) {
return null;
}
const slot = this.readbackSlots.find((candidate) => candidate.state === 'idle');
if (!slot) {
return null;
}
const byteLength = queryCount * QUERY_BYTES;
const passes = this.activePasses.slice();
const frame = this.frame;
slot.state = 'encoding';
commandEncoder.resolveQuerySet(this.querySet, 0, queryCount, this.resolveBuffer, 0);
commandEncoder.copyBufferToBuffer(this.resolveBuffer, 0, slot.buffer, 0, byteLength);
return () => {
slot.state = 'mapping';
void slot.buffer
.mapAsync(GPUMapMode.READ, 0, byteLength)
.then(() => {
this.publishSample(frame, passes, slot.buffer.getMappedRange(0, byteLength));
slot.buffer.unmap();
slot.state = 'idle';
})
.catch(() => {
slot.state = 'idle';
});
};
}
public destroy(): void {
this.querySet.destroy();
this.resolveBuffer.destroy();
this.readbackSlots.forEach((slot) => {
slot.buffer.destroy();
});
}
public get latestTotalPassMs(): number | undefined {
return this.latestSample?.totalPassMs;
}
private publishSample(
frame: number,
passes: Array<ActivePass>,
mappedRange: ArrayBuffer
): void {
const timestamps = new BigUint64Array(mappedRange);
const sample: GpuProfilerSample = {
frame,
passes: {},
totalPassMs: 0,
};
passes.forEach(({ endQueryIndex, name, startQueryIndex }) => {
const start = timestamps[startQueryIndex];
const end = timestamps[endQueryIndex];
if (end < start) {
return;
}
const elapsedMs = Number(end - start) / 1_000_000;
sample.passes[name] = elapsedMs;
sample.totalPassMs += elapsedMs;
});
this.latestSample = sample;
}
}

Some files were not shown because too many files have changed in this diff Show more