Compare commits

...

33 commits

Author SHA1 Message Date
c40c5d97db Final clean up
Some checks failed
Check & deploy / build (pull_request) Failing after 1m16s
2026-05-24 10:52:20 +01:00
05c8a39bd8 Small improvements 2026-05-24 09:34:46 +01:00
a7c04b2bd8 fix zoom in 2026-05-22 08:08:31 +01:00
646564fc73 Fixes 2026-05-22 08:03:13 +01:00
f300dbd394 Getting there 2026-05-22 07:54:38 +01:00
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
10a81ba474 v good
Some checks failed
Deploy to Pages / build (pull_request) Failing after 1m56s
2026-05-16 13:46:19 +01:00
2347ecd201 .
Some checks failed
Deploy to Pages / build (pull_request) Failing after 3m15s
2026-05-13 22:13:15 +01:00
39b0160064 WIP 2026-05-13 21:07:10 +01:00
34ac200437 Add WIP sound generation 2026-05-10 15:26:44 +01:00
cb1df6f29e Update SCSS 2026-05-10 15:16:19 +01:00
4e92913925 LGTM 2026-05-09 22:27:51 +01:00
b1acdff594 Refactoring start 2026-05-09 22:09:04 +01:00
6588930911 Add piano 2026-05-09 21:50:56 +01:00
214 changed files with 16657 additions and 3261 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,20 +25,38 @@ jobs:
cache: 'npm'
- name: Install dependencies
run: npm ci
run: |
npm ci
npx playwright install --with-deps chromium
- name: Lint
run: npm run lint -- --check || true
- name: Test
run: |
npm run lint:check
npm run format:check
npm run typecheck
npm run typecheck:e2e
npm test
- name: Typecheck
run: npm run typecheck
- name: Test E2E
run: |
npm run test:e2e
- name: Build
run: npm run build
run: |
npm run build
- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: |
playwright-report/
test-results/
retention-days: 7
- name: Copy build to host pages mount
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
apt update && apt install -y rsync
mkdir -p /pages
rsync -a --delete dist/ /pages/fleeting-garden
rsync -a --delete dist/ /pages/fleeting

44
.gitignore vendored
View file

@ -1,45 +1,5 @@
# Dependency directory
node_modules
modules/
ts-node--*/
rss.xml
dist
# Logs
logs
test-results
.DS_Store
*.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.*

2
.nvmrc
View file

@ -1 +1 @@
22
22.13.0

View file

@ -6,5 +6,5 @@
"endOfLine": "lf",
"plugins": ["@ianvs/prettier-plugin-sort-imports"],
"importOrder": ["<BUILTIN_MODULES>", "<THIRD_PARTY_MODULES>", "", "^[./]"],
"importOrderTypeScriptVersion": "5.0.0"
"importOrderTypeScriptVersion": "6.0.3"
}

View file

@ -1,15 +1,14 @@
# Just a bunch of blobs
# Fleeting Garden
[![Deploy to GitHub Pages](https://github.com/schmelczer/webgpu/actions/workflows/deploy.yml/badge.svg)](https://github.com/schmelczer/webgpu/actions/workflows/deploy.yml)
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 an internal render buffer snapshot.
## todo
Check out the [agent logic](./src/pipelines/agents/agent.wgsl).
- add info page description
- add share link
- settings page
add reset link
- shareable settings
- graceful error messages when no support
- fix up generation id automatically
## Testing
Check out the [agent's logic](./src/pipelines/agents/agent.wgsl).
- `npm test` runs the Vitest unit suite.
- `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

10
assets/icons/download.svg Normal file
View file

@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M12 3v11m0 0 4-4m-4 4-4-4M5 17v3h14v-3"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
/>
</svg>

After

Width:  |  Height:  |  Size: 248 B

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="currentColor" 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: 349 B

Before After
Before After

View file

@ -1,7 +1,7 @@
<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="currentColor" 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" />
<path d="M16 4h2a2 2 0 0 1 2 2v2" />
<path d="M16 20h2a2 2 0 0 0 2 -2v-2" />
</svg>
</svg>

Before

Width:  |  Height:  |  Size: 376 B

After

Width:  |  Height:  |  Size: 382 B

Before After
Before After

View file

@ -1,7 +1,7 @@
<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="currentColor" 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" />
<path d="M5 15h2a2 2 0 0 1 2 2v2" />
<path d="M5 9h2a2 2 0 0 0 2 -2v-2" />
</svg>
</svg>

Before

Width:  |  Height:  |  Size: 376 B

After

Width:  |  Height:  |  Size: 382 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="currentColor" 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: 331 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="currentColor" 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: 541 B

Before After
Before After

3
assets/icons/sound.svg Normal file
View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<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>

After

Width:  |  Height:  |  Size: 391 B

4
definitions.d.ts vendored
View file

@ -1,4 +0,0 @@
declare module '*.wgsl?raw' {
const content: string;
export default content;
}

265
e2e/app.spec.ts Normal file
View file

@ -0,0 +1,265 @@
import { test as base, expect, type Page } from '@playwright/test';
const canvasName = 'Interactive generative garden canvas';
interface BrowserDiagnostics {
browserFailures: Array<string>;
consoleErrors: Array<string>;
}
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 test = base.extend<{ browserDiagnostics: BrowserDiagnostics }>({
browserDiagnostics: [
async ({ page }, use) => {
const browserFailures = collectLocalBrowserFailures(page);
const consoleErrors: Array<string> = [];
page.on('console', (message) => {
if (message.type() === 'error') {
consoleErrors.push(message.text());
}
});
await use({ browserFailures, consoleErrors });
expect(consoleErrors).toEqual([]);
expect(browserFailures).toEqual([]);
},
{ auto: true },
],
});
const disableWebGpu = async (page: Page) => {
await page.addInitScript(() => {
Object.defineProperty(navigator, 'gpu', {
configurable: true,
value: undefined,
});
});
};
test('starts the WebGPU garden and accepts drawing input', async ({ page }) => {
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', { exact: true, name: 'Start' });
await expect(startButton).toBeVisible();
await expect(startButton).toBeEnabled({ timeout: 30_000 });
await page.keyboard.press('Enter');
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);
await expect
.poll(() =>
page.evaluate(
() =>
(
window as unknown as {
__fleetingGardenBrushPasses?: number;
}
).__fleetingGardenBrushPasses ?? 0
)
)
.toBeGreaterThan(0);
});
test('shows a clear fallback when WebGPU is unavailable', async ({ page }) => {
await disableWebGpu(page);
await page.goto('/');
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 fallback = page.getByRole('alert');
await expect(fallback).toContainText('Fleeting Garden needs WebGPU');
await expect(fallback).toContainText('webgpu-unsupported');
});
test('syncs the selected vibe with the URI', async ({ page }) => {
await disableWebGpu(page);
await page.goto('/?vibe=Aurora%20Mycelium');
await expect(page).toHaveURL(/vibe=aurora-mycelium/);
await page.getByRole('button', { name: 'Next vibe' }).click();
await expect(page).toHaveURL(/vibe=velvet-observatory/);
await page.goBack();
await expect(page).toHaveURL(/vibe=aurora-mycelium/);
});
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.getByRole('button', { name: /audio/i });
const volumeSlider = page.getByRole('slider', { name: 'Master volume' });
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 page.setViewportSize({ width: 390, height: 640 });
await page.goto('/');
const startButton = page.getByRole('button', { exact: true, name: 'Start' });
await expect(startButton).toBeEnabled({ timeout: 30_000 });
await startButton.click();
await expect(page.locator('body')).not.toHaveClass(/is-loading/, {
timeout: 30_000,
});
const settingsButton = page.getByRole('button', { name: 'Show config overlay' });
await settingsButton.click();
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(() => 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

@ -6,76 +6,243 @@
name="viewport"
content="width=device-width,initial-scale=1,viewport-fit=cover"
/>
<meta name="theme-color" content="#b7455e" />
<meta name="theme-color" content="#10151f" />
<meta name="robots" content="index,follow" />
<meta name="author" content="Andras Schmelczer" />
<meta
name="description"
content="A WebGPU agent simulation: a million blobs leave trails, infect each other across generations, and react to your brush."
content="Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser."
/>
<meta property="og:title" content="Just a bunch of blobs" />
<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="A WebGPU agent simulation: a million blobs leave trails, infect each other across generations, and react to your brush."
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." />
<title>Just a bunch of blobs</title>
<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>
<body>
<body class="is-loading">
<main class="canvas-container">
<canvas></canvas>
<canvas
role="img"
aria-label="Interactive generative garden canvas"
aria-describedby="canvas-description"
>
Your browser cannot display the interactive WebGPU garden canvas. Use a browser
with WebGPU support to draw coloured paths and watch the garden grow.
</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, 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">
<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-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>
<section class="errors-container">
<noscript>JavaScript is required for this website.</noscript>
</section>
</main>
<aside>
<nav class="buttons">
<button class="info" aria-label="About"></button>
<button class="maximize-full-screen" aria-label="Enter fullscreen"></button>
<button class="minimize-full-screen" aria-label="Exit fullscreen"></button>
<button class="settings" aria-label="Settings"></button>
<button class="restart" aria-label="Restart simulation"></button>
</nav>
<main class="pages hidden info-page">
<aside class="control-dock">
<section
id="info-panel"
class="hidden info-page"
role="region"
aria-label="About panel"
aria-hidden="true"
tabindex="-1"
inert
>
<section>
<h1>Just a bunch of blobs</h1>
<h1>Fleeting Garden</h1>
<p>
A million autonomous agents wander a 2D field. Each one lays down a faint
trail and follows trails it senses ahead. Two generations are competing for
territory: the older one fades, the newer one spreads.
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>
Drag your finger or mouse anywhere on the canvas to paint a wall. Walls slow
the new generation down and let the old one breathe a little longer. Open
<em>Settings</em> to retune sensors, decay rates and aggression.
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>
Runs entirely on your GPU via WebGPU compute shaders &mdash; no servers, no
tracking, no analytics. Source on
<a href="https://github.com/schmelczer/webgpu" target="_blank" rel="noopener"
>GitHub</a
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, running locally in your browser. More of my work at
<a href="https://schmelczer.dev" target="_blank" rel="noopener"
>schmelczer.dev</a
>.
</p>
</section>
</main>
</section>
<main class="pages hidden settings-page">
<section>
<div class="settings-content"></div>
<button id="apply-defaults" class="large-button">Apply defaults</button>
</section>
</main>
<div class="toolbar-row" role="toolbar" aria-label="Garden toolbar">
<button
class="previous-vibe vibe-button"
aria-label="Previous vibe"
title="Previous vibe"
>
&lsaquo;
</button>
<div class="toolbar-shell">
<section class="garden-controls" aria-label="Garden controls">
<div class="swatches" role="group" aria-label="Drawing colours">
<button
class="color-swatch"
aria-label="Draw colour 1"
title="Draw colour 1"
></button>
<button
class="color-swatch"
aria-label="Draw colour 2"
title="Draw colour 2"
></button>
<button
class="color-swatch"
aria-label="Draw colour 3"
title="Draw colour 3"
></button>
<label class="eraser-size-control" title="Erase and resize">
<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"
aria-label="Mirror segments"
/>
</label>
</div>
</section>
</div>
<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>
<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;
</button>
</div>
</aside>
<script type="module" src="/src/index.ts"></script>
</body>

1174
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,22 +1,28 @@
{
"name": "webgpu-seed",
"name": "fleeting-garden",
"version": "0.2.0",
"private": true,
"type": "module",
"description": "A WebGPU-powered slime-mold-meets-territory-control simulation.",
"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": "eslint --fix \"src/**/*.ts\" && prettier --write \"src/**/*.{ts,scss,json,html}\"",
"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": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:watch": "vitest",
"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"
"node": ">=22.13.0"
},
"repository": {
"type": "git",
@ -33,23 +39,28 @@
"browserslist": [
"supports webgpu and last 2 years"
],
"dependencies": {
"gl-matrix": "^3.4.4"
"knip": {
"ignoreFiles": [
"pwa-assets.config.ts"
]
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@ianvs/prettier-plugin-sort-imports": "^4.7.1",
"@playwright/test": "^1.60.0",
"@tweakpane/core": "~2.0.5",
"@types/node": "^25.6.0",
"@vite-pwa/assets-generator": "^1.0.2",
"@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",
@ -57,5 +68,9 @@
"vite": "^8.0.10",
"vite-plugin-singlefile": "^2.3.3",
"vitest": "^4.1.5"
},
"dependencies": {
"@plausible-analytics/tracker": "^0.4.5",
"tweakpane": "~4.0.5"
}
}

37
playwright.config.ts Normal file
View file

@ -0,0 +1,37 @@
import { defineConfig, devices } from '@playwright/test';
const port = 4173;
const baseURL = `https://127.0.0.1:${port}`;
const isCi = Boolean(process.env.CI);
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: isCi,
retries: isCi ? 2 : 0,
workers: 1,
reporter: isCi ? [['list'], ['html', { open: 'never' }]] : 'list',
use: {
baseURL,
ignoreHTTPSErrors: true,
trace: 'on-first-retry',
},
webServer: {
command: `npm run build && npm run preview -- --host 127.0.0.1 --port ${port}`,
ignoreHTTPSErrors: true,
reuseExistingServer: false,
timeout: 120_000,
url: baseURL,
},
projects: [
{
name: 'chromium',
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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 908 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 587 B

After

Width:  |  Height:  |  Size: 892 B

Before After
Before After

View file

@ -1,6 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#b7455e" />
<circle cx="22" cy="26" r="9" fill="#fff" opacity="0.95" />
<circle cx="42" cy="32" r="11" fill="#fff" opacity="0.85" />
<circle cx="28" cy="44" r="7" fill="#fff" opacity="0.75" />
<defs>
<clipPath id="icon-clip">
<rect width="64" height="64" rx="14" />
</clipPath>
</defs>
<g clip-path="url(#icon-clip)">
<rect width="64" height="64" fill="#10151f" />
<path d="M0 64a32 32 0 0 1 64 0Z" fill="#40d6c8" />
<path
d="M32 34c1.2-7.2 4.8-12.3 10-16"
fill="none"
stroke="#10151f"
stroke-linecap="round"
stroke-width="8"
/>
<path
d="M32 34c1.2-7.2 4.8-12.3 10-16"
fill="none"
stroke="#ff5da2"
stroke-linecap="round"
stroke-width="4"
/>
<ellipse cx="42" cy="11.5" rx="4.2" ry="6.4" fill="#ff5da2" />
<ellipse cx="48.5" cy="18" rx="6.4" ry="4.2" fill="#ff5da2" />
<ellipse cx="42" cy="24.5" rx="4.2" ry="6.4" fill="#ff5da2" />
<ellipse cx="35.5" cy="18" rx="6.4" ry="4.2" fill="#ff5da2" />
<circle cx="42" cy="18" r="3.2" fill="#10151f" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 312 B

After

Width:  |  Height:  |  Size: 950 B

Before After
Before After

View file

@ -1,38 +1,35 @@
{
"name": "Just a bunch of blobs",
"short_name": "Blobs",
"description": "A WebGPU agent simulation: a million blobs leave trails, infect each other across generations, and react to your brush.",
"start_url": "/",
"scope": "/",
"name": "Fleeting Garden",
"short_name": "Garden",
"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": "#b7455e",
"theme_color": "#b7455e",
"background_color": "#10151f",
"theme_color": "#10151f",
"icons": [
{
"src": "/favicon.svg",
"src": "favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
"type": "image/svg+xml"
},
{
"src": "/pwa-64x64.png",
"src": "pwa-64x64.png",
"sizes": "64x64",
"type": "image/png"
},
{
"src": "/pwa-192x192.png",
"src": "pwa-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/pwa-512x512.png",
"src": "pwa-512x512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/maskable-icon-512x512.png",
"src": "maskable-icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Before After
Before After

BIN
public/og-image.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 488 B

After

Width:  |  Height:  |  Size: 690 B

Before After
Before After

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>

68
src/analytics.ts Normal file
View file

@ -0,0 +1,68 @@
import {
init as plausibleInit,
track as plausibleTrack,
type PlausibleEventOptions,
} from '@plausible-analytics/tracker';
import { appConfig } from './config';
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: appConfig.analytics.domain,
endpoint: appConfig.analytics.endpoint,
autoCapturePageviews: appConfig.analytics.autoCapturePageviews,
logging: appConfig.analytics.logging,
});
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

@ -0,0 +1,127 @@
import type { PianoNoteRole } from './garden-audio-types';
export const DEFAULT_AUDIO_VOLUME = 0.5;
export const SILENT_AUDIO_GAIN = 0.0001;
type GardenAudioChordQuality = 'major' | 'minor' | 'sus2' | 'sus4';
export interface GardenAudioChord {
rootOffset: number;
quality: GardenAudioChordQuality;
}
export interface GardenAudioVibeSettings {
idleIntensity: number;
bpm: number;
rampUpIntensity: number;
rampUpTime: number;
noteLength: number;
notePitchOffset: number;
brightness: number;
scale?: Array<number>;
progression?: Array<GardenAudioChord>;
}
export interface GardenAudioVibeProfile extends GardenAudioVibeSettings {
rootMidi: number;
scale: Array<number>;
progression: Array<GardenAudioChord>;
}
export const defaultGardenAudioVibeSettings: GardenAudioVibeSettings = {
idleIntensity: 0.08,
bpm: 74,
rampUpIntensity: 0.85,
rampUpTime: 0.08,
noteLength: 0.42,
notePitchOffset: 0,
brightness: 1,
};
export const createGardenAudioConfig = () => ({
masterVolume: DEFAULT_AUDIO_VOLUME,
fadeInSeconds: 0.45,
updateRampSeconds: 0.08,
delay: {
timeBeats: 0.5,
timeMinSeconds: 0.18,
timeMaxSeconds: 0.72,
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

@ -0,0 +1,66 @@
import { approach, clamp01 } from '../utils/math';
import type { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
export class GardenAudioEnergy {
private isGestureActive = false;
private energy = 0;
private targetEnergy = 0;
private lastEnergyUpdateAt = 0;
public constructor(private readonly config: GardenAudioConfig) {}
public beginGesture(now: number): void {
this.isGestureActive = true;
this.lastEnergyUpdateAt = now;
}
public endGesture(): void {
this.isGestureActive = false;
this.targetEnergy = 0;
}
public recordStroke(strokeEnergy: number, profile: GardenAudioVibeProfile): void {
this.targetEnergy = Math.max(this.targetEnergy, strokeEnergy);
if (this.isGestureActive) {
this.energy = Math.max(this.energy, strokeEnergy * profile.rampUpIntensity);
}
}
public silence(): void {
this.targetEnergy = 0;
this.energy = 0;
}
public update(now: number, profile: GardenAudioVibeProfile): void {
if (this.lastEnergyUpdateAt <= 0) {
this.lastEnergyUpdateAt = now;
return;
}
const elapsedSeconds = now - this.lastEnergyUpdateAt;
this.lastEnergyUpdateAt = now;
this.targetEnergy *= Math.exp(
-elapsedSeconds / this.config.energy.strokeDecaySeconds
);
const target = this.isGestureActive ? this.targetEnergy : 0;
let timeConstant = this.config.energy.decaySeconds;
if (!this.isGestureActive) {
timeConstant = this.config.energy.releaseSeconds;
} else if (target > this.energy) {
timeConstant = profile.rampUpTime;
}
this.energy = approach(this.energy, target, elapsedSeconds, timeConstant);
}
public getLevel(): number {
return clamp01(this.energy);
}
public reset(): void {
this.isGestureActive = false;
this.energy = 0;
this.targetEnergy = 0;
this.lastEnergyUpdateAt = 0;
}
}

View file

@ -0,0 +1,75 @@
import { approach, clamp, clamp01, smoothstep } from '../utils/math';
import type { GardenAudioConfig } from './garden-audio-config';
import type { GardenAudioStrokeMetrics } from './garden-audio-input';
interface GardenAudioGestureFrame {
activity: number;
maniaAmount: number;
}
export class GardenAudioGestureState {
private activity = 0;
private maniaAmount = 0;
private isManic = false;
public constructor(private readonly inputConfig: GardenAudioConfig['input']) {}
public recordStroke({
metrics,
}: {
metrics: GardenAudioStrokeMetrics;
}): GardenAudioGestureFrame {
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
);
if (this.activity >= this.inputConfig.manicActivityThreshold) {
this.isManic = true;
} else if (this.activity <= this.inputConfig.manicReleaseThreshold) {
this.isManic = false;
}
const maniaTarget = this.isManic
? smoothstep(this.inputConfig.manicReleaseThreshold, 1, this.activity)
: 0;
this.maniaAmount = approach(
this.maniaAmount,
maniaTarget,
metrics.elapsedSeconds,
this.inputConfig.maniaSmoothingSeconds
);
return {
activity: this.activity,
maniaAmount: this.maniaAmount,
};
}
public reset(): void {
this.activity = 0;
this.maniaAmount = 0;
this.isManic = false;
}
private getTargetActivity(metrics: GardenAudioStrokeMetrics): number {
const speedRange =
this.inputConfig.fullActivitySpeed - this.inputConfig.activityNoiseFloorSpeed;
const speedAmount = clamp01(
(metrics.normalizedSpeed - this.inputConfig.activityNoiseFloorSpeed) / speedRange
);
const distanceAmount = clamp01(
metrics.normalizedDistance / this.inputConfig.minAudibleDistance
);
const activity = Math.pow(speedAmount, this.inputConfig.activityCurve);
return clamp(activity * distanceAmount, 0, this.inputConfig.activitySoftCeiling);
}
}

View file

@ -0,0 +1,347 @@
import { clamp } from '../utils/math';
import { SILENT_AUDIO_GAIN, type GardenAudioConfig } from './garden-audio-config';
import type { PianoNoteRole } from './garden-audio-types';
type AudioSessionType = NonNullable<NavigatorWithAudioSession['audioSession']>['type'];
type NavigatorWithAudioSession = Navigator & {
audioSession?: {
type:
| 'auto'
| 'playback'
| 'ambient'
| 'transient'
| 'transient-solo'
| 'play-and-record';
};
};
const outputHighPassFrequencyHz = 45;
const noiseBufferDurationSeconds = 1;
const graphTuning = {
closeGain: SILENT_AUDIO_GAIN,
closeRampSeconds: 0.015,
delayMaxSeconds: 2,
eventBusGain: 1,
noiseMax: 1,
noiseMin: -1,
latencyHint: 'interactive',
outputFilterType: 'highpass',
compressor: {
thresholdDb: -18,
kneeDb: 18,
ratio: 2.1,
attackSeconds: 0.018,
releaseSeconds: 0.18,
},
} as const;
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 lastPianoBusActivity = 0;
private pianoBusGainScale = 1;
private pianoBusGainScaleAutomationUntil = 0;
private pianoBusGainScaleTimeConstantSeconds = 0;
private previousAudioSessionType: AudioSessionType | null = null;
private readonly pianoBuses = new Map<PianoNoteRole, GainNode>();
public constructor(private readonly config: GardenAudioConfig) {}
public ensureContext(canCreate: boolean): AudioContext | null {
if (this.context) {
return this.context;
}
if (!canCreate) {
return null;
}
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) {
this.previousAudioSessionType ??= audioSession.type;
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 = graphTuning.outputFilterType;
highPass.frequency.value = outputHighPassFrequencyHz;
compressor.threshold.value = graphTuning.compressor.thresholdDb;
compressor.knee.value = graphTuning.compressor.kneeDb;
compressor.ratio.value = graphTuning.compressor.ratio;
compressor.attack.value = graphTuning.compressor.attackSeconds;
compressor.release.value = graphTuning.compressor.releaseSeconds;
masterGain.connect(highPass);
highPass.connect(compressor);
compressor.connect(context.destination);
this.context = context;
this.masterGain = masterGain;
this.noiseBuffer = this.createNoiseBuffer(context);
this.createDelay(context, masterGain);
this.createBuses(context, masterGain);
return context;
}
public setMasterGain(targetGain: number, timeConstantSeconds: number): void {
if (!this.context || !this.masterGain) {
return;
}
this.masterGain.gain.setTargetAtTime(
targetGain,
this.context.currentTime,
timeConstantSeconds
);
}
public applyDelayProfile(bpm: number): void {
if (!this.context || !this.delayNode) {
return;
}
this.delayNode.delayTime.setTargetAtTime(
this.getDelayTimeSecondsForBpm(bpm),
this.context.currentTime,
this.config.delay.timeRampSeconds
);
}
public updateDelay(activity: number, bpm: 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.getDelayTimeSecondsForBpm(bpm),
now,
this.config.delay.timeRampSeconds
);
this.delayFeedback.gain.setTargetAtTime(
clamp(
this.config.delay.feedback +
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.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> {
const context = this.context;
if (!context) {
return;
}
if (this.masterGain && context.state !== 'closed') {
this.masterGain.gain.setTargetAtTime(
graphTuning.closeGain,
context.currentTime,
graphTuning.closeRampSeconds
);
}
this.clearNodes();
if (context.state !== 'closed') {
await context.close().catch(() => undefined);
}
this.restoreAudioSessionType();
}
private restoreAudioSessionType(): void {
const previousType = this.previousAudioSessionType;
this.previousAudioSessionType = null;
if (previousType === null) {
return;
}
const audioSession = (navigator as NavigatorWithAudioSession).audioSession;
if (audioSession) {
audioSession.type = previousType;
}
}
private createDelay(context: AudioContext, masterGain: GainNode): void {
const delayInput = context.createGain();
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.getDelayTimeSecondsForBpm(this.config.rhythm.bpm);
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(feedbackHighPass);
feedbackHighPass.connect(feedbackLowPass);
feedbackLowPass.connect(delayFeedback);
delayFeedback.connect(delayNode);
delayNode.connect(returnLowPass);
returnLowPass.connect(delayOutput);
delayOutput.connect(masterGain);
this.delayInput = delayInput;
this.delayNode = delayNode;
this.delayFeedback = delayFeedback;
this.delayOutput = delayOutput;
}
private createBuses(context: AudioContext, masterGain: GainNode): void {
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 getDelayTimeSecondsForBpm(bpm: number): number {
const safeBpm = Number.isFinite(bpm) ? Math.max(1, bpm) : this.config.rhythm.bpm;
return clamp(
(60 / safeBpm) * this.config.delay.timeBeats,
this.config.delay.timeMinSeconds,
this.config.delay.timeMaxSeconds
);
}
private createNoiseBuffer(context: AudioContext): AudioBuffer {
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] =
graphTuning.noiseMin +
Math.random() * (graphTuning.noiseMax - graphTuning.noiseMin);
}
return buffer;
}
private clearNodes(): void {
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.lastPianoBusActivity = 0;
this.pianoBusGainScale = 1;
this.pianoBusGainScaleAutomationUntil = 0;
this.pianoBusGainScaleTimeConstantSeconds = 0;
this.pianoBuses.clear();
}
}

View file

@ -0,0 +1,27 @@
import type { GardenAudioStroke } from './garden-audio-types';
const minElapsedSeconds = 0.001;
export interface GardenAudioStrokeMetrics {
elapsedSeconds: number;
normalizedDistance: number;
normalizedSpeed: number;
}
export const getStrokeMetrics = (stroke: GardenAudioStroke): GardenAudioStrokeMetrics => {
const dx = stroke.to[0] - stroke.from[0];
const dy = stroke.to[1] - stroke.from[1];
const distancePixels = Math.hypot(dx, dy);
const elapsedSeconds = Math.max(minElapsedSeconds, stroke.elapsedSeconds ?? 0);
const normalizationPixels = Math.max(
1,
Math.min(stroke.canvasSize[0], stroke.canvasSize[1])
);
const normalizedDistance = distancePixels / normalizationPixels;
return {
elapsedSeconds,
normalizedDistance,
normalizedSpeed: normalizedDistance / elapsedSeconds,
};
};

View file

@ -0,0 +1,33 @@
import type { VibePreset } from '../vibes';
import type { GardenAudioChord, GardenAudioVibeProfile } from './garden-audio-config';
export const PITCH_SEMITONES_PER_OCTAVE = 12;
const DEFAULT_PROGRESSION: ReadonlyArray<GardenAudioChord> = [
{ rootOffset: 0, quality: 'major' },
{ rootOffset: 9, quality: 'minor' },
{ rootOffset: 5, quality: 'major' },
{ rootOffset: 7, quality: 'major' },
];
const DEFAULT_ROOT_MIDI = 57;
const DEFAULT_SCALE: ReadonlyArray<number> = [0, 2, 4, 7, 9];
const getProfileScale = (vibe: VibePreset): Array<number> => {
const scale = vibe.audio.scale?.length ? vibe.audio.scale : DEFAULT_SCALE;
return [...scale];
};
const getProfileProgression = (vibe: VibePreset): Array<GardenAudioChord> =>
(vibe.audio.progression?.length ? vibe.audio.progression : DEFAULT_PROGRESSION).map(
(chord) => ({ ...chord })
);
export const getVibeProfile = (vibe: VibePreset): GardenAudioVibeProfile => {
return {
...vibe.audio,
rootMidi: DEFAULT_ROOT_MIDI + vibe.audio.notePitchOffset,
scale: getProfileScale(vibe),
progression: getProfileProgression(vibe),
};
};

View file

@ -0,0 +1,48 @@
import type { VibePreset } from '../vibes';
export interface GardenAudioSnapshot {
vibe: VibePreset;
isErasing: boolean;
}
export interface GardenAudioStroke {
vibe: VibePreset;
from: ArrayLike<number>;
to: ArrayLike<number>;
canvasSize: ArrayLike<number>;
isErasing: boolean;
elapsedSeconds: number;
}
export interface LoadedPianoSample {
midi: number;
buffer: AudioBuffer;
}
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;
gain: number;
filterHz: number;
pan: number;
}

448
src/audio/garden-audio.ts Normal file
View file

@ -0,0 +1,448 @@
import { ErrorHandler, Severity } from '../utils/error-handler';
import { clamp01 } from '../utils/math';
import type { VibeId, VibePreset } from '../vibes';
import {
SILENT_AUDIO_GAIN,
type GardenAudioConfig,
type GardenAudioVibeProfile,
} 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 } 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';
type AudioLifecycle = 'idle' | 'started' | 'destroyed';
type PianoReleasePhase =
| { kind: 'idle' }
| { kind: 'awaiting-fade' }
| { kind: 'scheduled-fade'; fadeAt: number }
| { kind: 'settling'; stopAt: number };
const muteRampSeconds = 0.02;
const brushUpPianoBusFadeSeconds = 2.4;
const brushUpPianoBusFadeSettleSeconds = 3.2;
const vibeChangeStingerMinIntervalSeconds = 0.45;
export class GardenAudio {
private readonly graph: GardenAudioGraph;
private readonly piano: PianoSampler;
private readonly noise: NoiseBurstPlayer;
private readonly energy: GardenAudioEnergy;
private readonly gestureState: GardenAudioGestureState;
private readonly pianoEngine: GenerativePianoEngine;
private currentVibeId: VibeId | null = null;
private currentVibe: VibePreset | null = null;
private lifecycle: AudioLifecycle = 'idle';
private pianoReleasePhase: PianoReleasePhase = { kind: 'idle' };
private isMuted = false;
private isGestureActive = false;
private masterVolume: number;
private lastEraserAt = Number.NEGATIVE_INFINITY;
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
private startRequestId = 0;
private hasLoadedPiano = false;
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: { userGesture?: boolean } = {}): void {
const isUserGesture = options.userGesture === true;
if (this.lifecycle === 'destroyed') {
return;
}
if (
this.lifecycle === 'started' &&
this.currentVibeId === vibe.id &&
this.graph.context?.state === 'running' &&
this.hasLoadedPiano
) {
return;
}
const context = this.graph.ensureContext(isUserGesture);
if (!context) {
return;
}
const startupRampSeconds = isUserGesture
? muteRampSeconds
: this.config.fadeInSeconds;
const needsResume = context.state !== 'running' && context.state !== 'closed';
const startRequestId = ++this.startRequestId;
if (needsResume) {
if (!isUserGesture) {
return;
}
void context
.resume()
.then(() => {
if (this.graph.context === context && this.lifecycle !== 'destroyed') {
this.completeStart(vibe, { context, startupRampSeconds, startRequestId });
}
})
.catch((error) => {
ErrorHandler.addException(error, {
fallbackMessage: 'Could not resume audio playback.',
severity: Severity.WARNING,
});
});
return;
}
this.completeStart(vibe, { context, startupRampSeconds, startRequestId });
}
private completeStart(
vibe: VibePreset,
{
context,
startRequestId,
startupRampSeconds,
}: {
context: AudioContext;
startRequestId: number;
startupRampSeconds: number;
}
): void {
if (this.graph.context !== context || this.lifecycle === 'destroyed') {
return;
}
if (this.isMuted) {
this.activateMutedStart(vibe, context);
this.graph.setMasterGain(SILENT_AUDIO_GAIN, muteRampSeconds);
return;
}
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.currentVibe = vibe;
const profile = getVibeProfile(vibe);
this.graph.applyDelayProfile(profile.bpm);
this.graph.setMasterGain(this.masterVolume, startupRampSeconds);
if (cuePiano) {
this.hasLoadedPiano = true;
this.pianoEngine.cue(context.currentTime, profile);
}
}
private activateMutedStart(vibe: VibePreset, context: AudioContext): void {
this.lifecycle = 'started';
this.currentVibeId = vibe.id;
this.currentVibe = vibe;
this.hasLoadedPiano = false;
this.graph.applyDelayProfile(getVibeProfile(vibe).bpm);
if (this.graph.context === context) {
this.pianoEngine.reset();
}
}
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();
this.hasLoadedPiano = false;
}
const context = this.graph.context;
if (
context &&
(context.state === 'running' || options.userGesture === true) &&
!this.isMuted &&
this.lifecycle !== 'destroyed' &&
didChangeVibe
) {
this.playVibeChangeStinger(vibe);
}
}
public setMuted(isMuted: boolean): void {
if (this.isMuted === isMuted) {
return;
}
this.isMuted = isMuted;
this.graph.setMasterGain(
isMuted ? SILENT_AUDIO_GAIN : this.masterVolume,
isMuted ? muteRampSeconds : this.config.fadeInSeconds
);
if (!isMuted && this.currentVibe && !this.hasLoadedPiano) {
this.start(this.currentVibe);
}
}
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) {
return;
}
this.isGestureActive = true;
this.pianoReleasePhase = { kind: 'idle' };
this.graph.setPianoBusGainScale(1, this.config.fadeInSeconds);
this.gestureState.reset();
this.energy.beginGesture(context.currentTime);
this.pianoEngine.beginGesture();
}
public endGesture(): void {
this.gestureState.reset();
this.isGestureActive = false;
this.pianoReleasePhase = { kind: 'awaiting-fade' };
this.energy.endGesture();
this.pianoEngine.endGesture();
}
public update(snapshot: GardenAudioSnapshot): void {
const context = this.graph.context;
if (this.lifecycle !== 'started' || !context || this.isMuted) {
return;
}
this.applyVibe(snapshot.vibe);
const profile = getVibeProfile(snapshot.vibe);
this.energy.update(context.currentTime, profile);
if (snapshot.isErasing) {
this.energy.silence();
}
if (!this.isGestureActive && this.pianoReleasePhase.kind !== 'idle') {
this.updatePianoRelease(snapshot.vibe, context.currentTime);
this.updateDelay(snapshot, profile);
return;
}
this.pianoEngine.renderLookahead({
vibe: snapshot.vibe,
now: context.currentTime,
activity: snapshot.isErasing
? this.config.eraser.pianoActivity
: this.energy.getLevel(),
});
this.updateDelay(snapshot, profile);
}
public stroke(stroke: GardenAudioStroke): void {
if (this.lifecycle !== 'started' || this.isMuted) {
return;
}
const context = this.graph.context;
if (!context) {
return;
}
if (!this.isGestureActive) {
return;
}
const metrics = getStrokeMetrics(stroke);
const now = context.currentTime;
const frame = this.gestureState.recordStroke({ metrics });
const strokeEnergy = frame.activity;
if (stroke.isErasing) {
this.playEraser(strokeEnergy, now);
return;
}
const profile = getVibeProfile(stroke.vibe);
this.energy.recordStroke(strokeEnergy, profile);
this.pianoEngine.recordStroke({
vibe: stroke.vibe,
now,
activity: strokeEnergy,
maniaAmount: frame.maniaAmount,
});
}
public async destroy(): Promise<void> {
this.lifecycle = 'destroyed';
await this.graph.close();
this.piano.reset();
this.hasLoadedPiano = false;
this.energy.reset();
this.gestureState.reset();
this.pianoEngine.reset();
this.currentVibeId = null;
this.currentVibe = null;
this.isGestureActive = false;
this.pianoReleasePhase = { kind: 'idle' };
this.lastEraserAt = Number.NEGATIVE_INFINITY;
this.lastVibeStingerAt = Number.NEGATIVE_INFINITY;
}
private playVibeChangeStinger(vibe: VibePreset): void {
const context = this.graph.context;
if (!context) {
return;
}
const now = context.currentTime;
if (now - this.lastVibeStingerAt < vibeChangeStingerMinIntervalSeconds) {
return;
}
this.lastVibeStingerAt = now;
this.pianoEngine.playVibeChangeStinger(vibe, now);
}
private updatePianoRelease(vibe: VibePreset, now: number): void {
if (this.pianoReleasePhase.kind === 'awaiting-fade') {
const fadeAt = this.pianoEngine.release(vibe, now);
if (now < fadeAt) {
this.pianoReleasePhase = { kind: 'scheduled-fade', fadeAt };
return;
}
this.graph.setPianoBusGainScale(0, brushUpPianoBusFadeSeconds);
this.pianoReleasePhase = {
kind: 'settling',
stopAt: now + brushUpPianoBusFadeSettleSeconds,
};
return;
}
if (
this.pianoReleasePhase.kind === 'scheduled-fade' &&
now >= this.pianoReleasePhase.fadeAt
) {
this.graph.setPianoBusGainScale(0, brushUpPianoBusFadeSeconds);
this.pianoReleasePhase = {
kind: 'settling',
stopAt: now + brushUpPianoBusFadeSettleSeconds,
};
return;
}
if (
this.pianoReleasePhase.kind === 'settling' &&
now >= this.pianoReleasePhase.stopAt
) {
this.piano.stopAll();
this.pianoEngine.reset();
this.hasLoadedPiano = false;
this.pianoReleasePhase = { kind: 'idle' };
}
}
private playEraser(activity: number, now: number): void {
if (!this.graph.context) {
return;
}
const distanceActivity = clamp01(activity);
if (distanceActivity <= 0) {
return;
}
const filterHz =
this.config.eraser.filterMinHz +
(this.config.eraser.filterMaxHz - this.config.eraser.filterMinHz) *
distanceActivity;
if (now - this.lastEraserAt >= this.config.eraser.minIntervalSeconds) {
this.lastEraserAt = now;
this.noise.play({
startTime: now,
durationSeconds: this.config.eraser.durationSeconds,
gain: this.config.eraser.noiseGain * distanceActivity,
filterHz,
pan: this.config.eraser.pan,
});
}
}
private updateDelay(
snapshot: GardenAudioSnapshot,
profile: GardenAudioVibeProfile
): void {
const context = this.graph.context;
if (!context) {
return;
}
const activity = snapshot.isErasing
? this.config.delay.erasingActivity
: this.energy.getLevel();
this.graph.updateDelay(activity, profile.bpm);
}
private applyVibe(vibe: VibePreset): void {
if (!this.graph.context || this.currentVibeId === vibe.id) {
return;
}
this.currentVibeId = vibe.id;
this.currentVibe = vibe;
const profile = getVibeProfile(vibe);
this.graph.applyDelayProfile(profile.bpm);
this.pianoEngine.cue(this.graph.context.currentTime, profile);
this.hasLoadedPiano = true;
}
}

View file

@ -0,0 +1,443 @@
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;
};
stereoWidth: {
idle: number;
active: number;
intense: number;
intenseThreshold: 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,
},
stereoWidth: {
idle: 0.46,
active: 0.9,
intense: 1.16,
intenseThreshold: 0.72,
},
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,
},
];

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,64 @@
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 const;
export class NoiseBurstPlayer {
public constructor(private readonly graph: GardenAudioGraph) {}
public play({ startTime, durationSeconds, gain, filterHz, pan }: NoiseBurst): void {
const { context, noiseBus, noiseBuffer } = this.graph;
if (!context || !noiseBus || !noiseBuffer) {
return;
}
const scheduledStart = Math.max(
context.currentTime + noiseBurstTuning.scheduleAheadSeconds,
startTime
);
const source = context.createBufferSource();
const filter = context.createBiquadFilter();
const envelope = context.createGain();
const panner = context.createStereoPanner();
const stopAt = scheduledStart + durationSeconds;
source.buffer = noiseBuffer;
filter.type = noiseBurstTuning.filterType;
filter.frequency.setValueAtTime(filterHz, scheduledStart);
filter.Q.value = noiseBurstTuning.filterQ;
envelope.gain.setValueAtTime(noiseBurstTuning.silentGain, scheduledStart);
envelope.gain.exponentialRampToValueAtTime(
Math.max(noiseBurstTuning.silentGain, gain),
scheduledStart + noiseBurstTuning.attackSeconds
);
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(noiseBus);
const maxOffsetSeconds = Math.max(0, noiseBuffer.duration - durationSeconds);
const offsetSeconds =
Math.random() * Math.min(noiseBurstTuning.offsetRandomSeconds, maxOffsetSeconds);
source.start(scheduledStart, offsetSeconds);
source.stop(stopAt);
source.addEventListener(
'ended',
() => {
source.disconnect();
filter.disconnect();
envelope.disconnect();
panner.disconnect();
},
{ once: true }
);
}
}

264
src/audio/piano-sampler.ts Normal file
View file

@ -0,0 +1,264 @@
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',
filterQ: 0.7,
minDurationSeconds: 0.08,
minFadeSeconds: 0.08,
minGain: 0.0001,
releaseTimeConstantCount: 5,
tailStopExtraSeconds: 0.05,
voiceStealFadeSeconds: 0.025,
voiceStealStopSeconds: 0.05,
} as const;
export class PianoSampler {
private samples: Array<LoadedPianoSample> = [];
private activeVoices: Array<ActivePianoVoice> = [];
public constructor(
private readonly config: GardenAudioConfig,
private readonly graph: GardenAudioGraph
) {}
public load(context: BaseAudioContext): Promise<void> {
if (this.samples.length > 0) {
return Promise.resolve();
}
const loadedSamples = getLoadedPianoSamples();
if (loadedSamples) {
this.setSamples(loadedSamples);
return Promise.resolve();
}
return loadPianoSamples(context).then((samples) => {
this.setSamples(samples);
});
}
public play({
midi,
velocity,
startTime,
durationSeconds,
pan,
role,
delaySend = 0,
lowpassHz = this.config.piano.lowpassHz,
sustainSeconds: profileSustainSeconds = this.config.piano.sustainSeconds,
}: PianoNote): void {
const { context } = this.graph;
const eventBus = this.graph.getPianoBus(role);
if (!context || !eventBus) {
return;
}
const sample = this.findNearestSample(midi);
if (!sample) {
return;
}
const scheduledStart = Math.max(
context.currentTime + PIANO_SCHEDULE_AHEAD_SECONDS,
startTime
);
const noteVelocity = clamp01(velocity);
const noteGainValue = this.computeNoteGain(noteVelocity);
const sustainSeconds =
profileSustainSeconds *
(this.config.piano.sustainBase +
noteVelocity * this.config.piano.sustainVelocityRange);
const sustainAt =
scheduledStart + Math.max(pianoSamplerTuning.minDurationSeconds, durationSeconds);
const releaseAt = sustainAt + sustainSeconds;
const stopAt =
releaseAt +
this.config.piano.releaseSeconds * pianoSamplerTuning.releaseTimeConstantCount;
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();
let sendGain: GainNode | null = null;
this.trimActiveVoices(scheduledStart);
while (this.activeVoices.length >= this.config.piano.maxVoices) {
const oldest = this.activeVoices.shift();
if (!oldest) {
break;
}
this.stopVoice(oldest, scheduledStart);
}
filter.type = pianoSamplerTuning.filterType;
filter.frequency.setValueAtTime(
clamp(lowpassHz, this.config.piano.lowpassMinHz, this.config.piano.lowpassMaxHz),
scheduledStart
);
filter.Q.value = pianoSamplerTuning.filterQ;
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
configureGainEnvelope(gain);
source.connect(filter);
filter.connect(gain);
gain.connect(panner);
panner.connect(eventBus);
if (delayInput && delaySend > 0) {
sendGain = context.createGain();
sendGain.gain.value = delaySend;
panner.connect(sendGain);
sendGain.connect(delayInput);
}
source.start(scheduledStart);
source.stop(stopAt + pianoSamplerTuning.tailStopExtraSeconds);
this.activeVoices.push({ gain, source, stopAt });
source.addEventListener(
'ended',
() => {
source.disconnect();
filter.disconnect();
gain.disconnect();
panner.disconnect();
sendGain?.disconnect();
this.activeVoices = this.activeVoices.filter((voice) => voice.gain !== gain);
},
{ once: true }
);
}
private computeNoteGain(velocity: number): number {
return Math.max(pianoSamplerTuning.minGain, this.config.piano.gain * velocity);
}
private findNearestSample(midi: number): LoadedPianoSample | null {
if (this.samples.length === 0) {
return null;
}
return this.samples.reduce((nearest, sample) =>
Math.abs(sample.midi - midi) < Math.abs(nearest.midi - midi) ? sample : nearest
);
}
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);
}
}

271
src/audio/piano-samples.ts Normal file
View file

@ -0,0 +1,271 @@
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 {
note: string;
url: string;
}
export interface PianoSampleLoadProgress {
failedCount: number;
loadedCount: number;
settledCount: number;
totalCount: number;
}
const pianoSampleDefinitions: Array<PianoSampleDefinition> = [
{ url: a0SampleUrl, note: 'A0' },
{ url: c1SampleUrl, note: 'C1' },
{ url: dSharp1SampleUrl, note: 'Dsharp1' },
{ url: fSharp1SampleUrl, note: 'Fsharp1' },
{ url: a1SampleUrl, note: 'A1' },
{ url: c2SampleUrl, note: 'C2' },
{ url: dSharp2SampleUrl, note: 'Dsharp2' },
{ url: fSharp2SampleUrl, note: 'Fsharp2' },
{ url: a2SampleUrl, note: 'A2' },
{ url: c3SampleUrl, note: 'C3' },
{ url: dSharp3SampleUrl, note: 'Dsharp3' },
{ url: fSharp3SampleUrl, note: 'Fsharp3' },
{ url: a3SampleUrl, note: 'A3' },
{ url: c4SampleUrl, note: 'C4' },
{ url: dSharp4SampleUrl, note: 'Dsharp4' },
{ url: fSharp4SampleUrl, note: 'Fsharp4' },
{ url: a4SampleUrl, note: 'A4' },
{ url: c5SampleUrl, note: 'C5' },
{ url: dSharp5SampleUrl, note: 'Dsharp5' },
{ url: fSharp5SampleUrl, note: 'Fsharp5' },
{ url: a5SampleUrl, note: 'A5' },
{ url: c6SampleUrl, note: 'C6' },
{ url: dSharp6SampleUrl, note: 'Dsharp6' },
{ url: fSharp6SampleUrl, note: 'Fsharp6' },
{ url: a6SampleUrl, note: 'A6' },
{ url: c7SampleUrl, note: 'C7' },
{ url: dSharp7SampleUrl, note: 'Dsharp7' },
{ url: fSharp7SampleUrl, note: 'Fsharp7' },
{ url: a7SampleUrl, note: 'A7' },
{ url: c8SampleUrl, note: 'C8' },
];
let loadedPianoSamples: Array<LoadedPianoSample> | null = null;
let pianoSampleLoadPromise: Promise<Array<LoadedPianoSample>> | null = null;
let lastPianoSampleProgress: PianoSampleLoadProgress | null = null;
const pianoSampleProgressListeners = new Set<
(progress: PianoSampleLoadProgress) => void
>();
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>> => {
const unsubscribeProgress = subscribeToPianoSampleProgress(onProgress);
if (loadedPianoSamples) {
emitPianoSampleProgress({
failedCount: 0,
loadedCount: loadedPianoSamples.length,
settledCount: loadedPianoSamples.length,
totalCount: pianoSampleDefinitions.length,
});
unsubscribeProgress();
return Promise.resolve([...loadedPianoSamples]);
}
if (pianoSampleLoadPromise) {
return pianoSampleLoadPromise.finally(unsubscribeProgress);
}
let loadedCount = 0;
let failedCount = 0;
let settledCount = 0;
const totalCount = pianoSampleDefinitions.length;
emitPianoSampleProgress({ failedCount, loadedCount, settledCount, totalCount });
pianoSampleLoadPromise = loadPianoSampleBatch(
pianoSampleDefinitions,
async (sample) => {
try {
const loadedSample = await withTimeout(
(signal) => loadPianoSample(decodeContext, sample, signal),
sampleLoadTuning.sampleTimeoutMs
);
loadedCount += 1;
return loadedSample;
} catch (error) {
failedCount += 1;
throw error;
} finally {
settledCount += 1;
emitPianoSampleProgress({ failedCount, loadedCount, settledCount, 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;
pianoSampleProgressListeners.clear();
throw error;
}
)
.finally(unsubscribeProgress);
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 ${getPianoSamplePath(sample)}`);
}
const audioData = await response.arrayBuffer();
const buffer = await decodeContext.decodeAudioData(audioData);
return { midi: getMidiForPianoSample(sample), 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);
}
);
});
const subscribeToPianoSampleProgress = (
onProgress: ((progress: PianoSampleLoadProgress) => void) | undefined
): (() => void) => {
if (!onProgress) {
return () => undefined;
}
pianoSampleProgressListeners.add(onProgress);
if (lastPianoSampleProgress) {
onProgress(lastPianoSampleProgress);
}
return () => {
pianoSampleProgressListeners.delete(onProgress);
};
};
const emitPianoSampleProgress = (progress: PianoSampleLoadProgress): void => {
lastPianoSampleProgress = progress;
pianoSampleProgressListeners.forEach((listener) => listener(progress));
};
const getPianoSamplePath = (sample: PianoSampleDefinition): string =>
`./samples/${sample.note}v12.m4a`;
const getMidiForPianoSample = (sample: PianoSampleDefinition): number => {
const match = /^(?<name>[A-G])(?<accidental>sharp)?(?<octave>\d+)$/.exec(sample.note);
if (!match?.groups) {
throw new Error(`Invalid piano sample note ${sample.note}`);
}
const semitoneByName: Record<string, number> = {
C: 0,
D: 2,
E: 4,
F: 5,
G: 7,
A: 9,
B: 11,
};
const octave = Number(match.groups.octave);
const semitone = semitoneByName[match.groups.name] + (match.groups.accidental ? 1 : 0);
return (octave + 1) * 12 + semitone;
};

BIN
src/audio/samples/A0v12.m4a Normal file

Binary file not shown.

BIN
src/audio/samples/A1v12.m4a Normal file

Binary file not shown.

BIN
src/audio/samples/A2v12.m4a Normal file

Binary file not shown.

BIN
src/audio/samples/A3v12.m4a Normal file

Binary file not shown.

BIN
src/audio/samples/A4v12.m4a Normal file

Binary file not shown.

BIN
src/audio/samples/A5v12.m4a Normal file

Binary file not shown.

BIN
src/audio/samples/A6v12.m4a Normal file

Binary file not shown.

BIN
src/audio/samples/A7v12.m4a Normal file

Binary file not shown.

BIN
src/audio/samples/C1v12.m4a Normal file

Binary file not shown.

BIN
src/audio/samples/C2v12.m4a Normal file

Binary file not shown.

BIN
src/audio/samples/C3v12.m4a Normal file

Binary file not shown.

BIN
src/audio/samples/C4v12.m4a Normal file

Binary file not shown.

BIN
src/audio/samples/C5v12.m4a Normal file

Binary file not shown.

BIN
src/audio/samples/C6v12.m4a Normal file

Binary file not shown.

BIN
src/audio/samples/C7v12.m4a Normal file

Binary file not shown.

BIN
src/audio/samples/C8v12.m4a Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,16 @@
Piano samples are Salamander Grand Piano V3 samples by Alexander Holm,
transcoded from OGG Vorbis to AAC M4A for iOS browser playback and distributed
under CC BY 3.0.
Source package: @audio-samples/piano-velocity12
Source recording: https://archive.org/details/SalamanderGrandPianoV3
License: https://creativecommons.org/licenses/by/3.0/
Checked-in subset: velocity layer `v12`, every minor-third anchor from A0
through C8: A, C, Dsharp, and Fsharp for octaves 1-7, plus A0, A7, and C8.
The app derives MIDI values from those note names in `piano-samples.ts`.
Repro notes: start from the matching `v12` OGG files in the source package and
transcode each selected sample to AAC/M4A without renaming the note/velocity
stem. The expected output filenames are `<note>v12.m4a`, for example
`C4v12.m4a`.

199
src/config.ts Normal file
View file

@ -0,0 +1,199 @@
import {
createGardenAudioConfig,
DEFAULT_AUDIO_VOLUME,
} from './audio/garden-audio-config';
import { defaultSettings } from './config/default-settings';
import { runtimeControls } from './config/runtime-controls';
import type { GardenAppConfig } from './config/types';
import { defaultVibeId, vibePresets } from './config/vibe-presets';
export {
normalizeNumberControlValue,
normalizeRuntimeSettings,
} from './config/normalize-runtime-settings';
export type {
GardenAppConfig,
GardenRuntimeSettings,
NumberControlConfig,
} from './config/types';
export const appConfig = {
audio: createGardenAudioConfig(),
analytics: {
autoCapturePageviews: true,
domain: 'fleeting.garden',
endpoint: 'https://stats.schmelczer.dev/status',
logging: import.meta.env.DEV,
},
deltaTime: {
maxDeltaTimeSeconds: 1 / 30,
minDeltaTimeSeconds: 1 / 240,
},
exportSnapshot: {
bytesPerPixel: 4,
filenameExtension: 'png',
filenamePrefix: 'fleeting-garden',
filenameSuffix: '-snapshot',
mimeType: 'image/png',
rowAlignmentBytes: 256,
},
menuHider: {
bottomRevealDistancePx: 96,
desktopMediaQuery: '(min-width: 600px) and (hover: hover) and (pointer: fine)',
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,
},
diffusion: {
minDiffusionRate: 0.000001,
},
eraser: {
maxTextureLineCount: 384,
},
},
defaultSettings,
runtimeSettings: {
controls: runtimeControls,
},
simulation: {
brushEffectFramesPerSecond: 60,
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,
drawHintDelayMs: 3000,
durationSeconds: 4,
entryJitterSideRatio: 0.035,
fontScaleDown: 0.94,
fontFamily: '"Open Sans", sans-serif',
initialFontHeightRatio: 0.28,
initialFontWidthRatio: 0.19,
letterSpacingEm: 0.07,
maskAlphaThreshold: 32,
maskGradientThreshold: 8,
maskMaxPixels: 1_000_000,
maskSampleDensity: 540,
maxHeightRatio: 0.25,
maxWidthRatio: 0.76,
minEntryJitterPx: 6,
minFontSizePx: 18,
minTargetJitterPx: 1,
pathEasing: 'easeOutQuad',
pathProgressEpsilon: 0.001,
radialJitterRatio: 0.35,
radialStartEpsilon: 0.001,
resizeMinimumRemainingSeconds: 1.4,
resizeSettleMs: 120,
targetDelayDistanceMultiplier: 0.12,
targetDelayMax: 0.22,
targetDelayRandomMultiplier: 0.06,
targetJitterSideRatio: 0.0035,
title: 'Fleeting',
titleColorCutLetters: [2, 5],
titleRadiusMultiplier: 1.55,
titleStrokeWidthMinPx: 6,
titleStrokeWidthRatio: 0.11,
verticalAnchor: 0.47,
},
introMoveSpeed: 280,
stroke: {
densityMultiplier: 110,
maxAgentCount: 2_400,
},
},
storage: {
audioMutedKey: 'fleeting-garden:audio-muted',
audioVolumeKey: 'fleeting-garden:audio-volume',
vibeKey: 'fleeting-garden:vibe',
},
toolbar: {
eraser: {
controlScaleMax: 1.34,
controlScaleMin: 0.74,
default: 96,
max: 480,
min: 24,
step: 1,
},
mirror: {
default: 8,
fallbackSegmentName: 'slices',
max: 12,
min: 1,
names: {
2: 'halves',
3: 'thirds',
4: 'quarters',
5: 'fifths',
6: 'sixths',
7: 'sevenths',
8: 'eighths',
9: 'ninths',
10: 'tenths',
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: {
showFpsOverlay: import.meta.env.DEV,
startHidden: true,
title: 'Garden Settings',
},
vibes: {
defaultVibeId,
presets: vibePresets,
},
} satisfies GardenAppConfig;

View file

@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import {
BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS,
getBrushRenderQualityScale,
getRenderQualityBrushSize,
} from './brush-size';
describe('render-quality brush sizing', () => {
it('keeps brush sizes unchanged at the 7.3 MP baseline', () => {
expect(
getRenderQualityBrushSize(21, BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS)
).toBe(21);
});
it('scales linear brush size with the square root of render area', () => {
const doubledLinearQuality = BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS * 4;
expect(getBrushRenderQualityScale(doubledLinearQuality)).toBe(2);
expect(getRenderQualityBrushSize(9.75, doubledLinearQuality)).toBe(19.5);
});
it('falls back to baseline scaling for invalid render areas', () => {
expect(getBrushRenderQualityScale(0)).toBe(1);
expect(getRenderQualityBrushSize(6.5, Number.NaN)).toBe(6.5);
});
});

19
src/config/brush-size.ts Normal file
View file

@ -0,0 +1,19 @@
export const BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS = 7.3;
const getSafeRenderAreaMegapixels = (renderAreaMegapixels: number): number =>
Number.isFinite(renderAreaMegapixels) && renderAreaMegapixels > 0
? renderAreaMegapixels
: BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS;
export const getBrushRenderQualityScale = (renderAreaMegapixels: number): number =>
Math.sqrt(
getSafeRenderAreaMegapixels(renderAreaMegapixels) /
BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS
);
export const getRenderQualityBrushSize = (
brushSize: number,
renderAreaMegapixels: number
): number =>
Math.max(0, Number.isFinite(brushSize) ? brushSize : 0) *
getBrushRenderQualityScale(renderAreaMegapixels);

View file

@ -0,0 +1,14 @@
import type { NumberControlConfig } from './types';
export const colorInteractionControl = (label: string): NumberControlConfig => ({
folder: 'Color Reactions',
label,
min: -1,
max: 1,
step: 1,
options: {
'Move Toward': 1,
Ignore: 0,
'Move Away': -1,
},
});

View file

@ -0,0 +1,74 @@
import { INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS } from './runtime-setting-bounds';
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 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_MEGAPIXEL_LIMITS.max,
Math.max(INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS.min, dpr * dpr * cssMegapixels)
);
};
export const defaultSettings: GardenAppConfig['defaultSettings'] = {
selectedColorIndex: 0,
introNearDistanceMin: 28,
introNearDistanceInner: 4,
introNearSensorOffsetMultiplier: 0.75,
introTargetAngleBlend: 0.2,
introProgressCutoff: 0.999,
introTurnRateMultiplier: 3.4,
introRandomTurnMultiplier: 0.18,
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,
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: 1_500_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,147 @@
import { colorInteractionControl } from './color-interactions';
import { INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS } from './runtime-setting-bounds';
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`;
const formatCompactNumber = (value: number): string => {
if (value >= 1_000_000) {
const millions = value / 1_000_000;
return `${Number.isInteger(millions) ? millions : millions.toFixed(1)}M`;
}
if (value >= 1_000) {
return `${Math.round(value / 1_000)}k`;
}
return `${value}`;
};
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: 36,
step: 0.25,
},
spawnPerPixel: {
folder: 'Brush',
label: 'Density',
min: 0.01,
max: 0.38,
step: 0.001,
},
strokeAngleJitterRadians: {
folder: 'Brush',
format: formatRadiansAsDegrees,
label: 'Spawn Spread',
min: 0,
max: Math.PI,
step: 0.01,
},
sensorOffsetDistance: {
folder: 'Movement',
label: 'Sensor Reach',
min: 0,
max: 200,
step: 1,
},
sensorOffsetAngle: {
folder: 'Movement',
label: 'Sensor Angle',
min: 0,
max: 180,
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: Math.PI * 2,
step: 0.01,
},
individualTrailWeight: {
folder: 'Movement',
label: 'Trail Strength',
min: 0,
max: 1,
step: 0.001,
},
diffusionRateTrails: {
folder: 'Movement',
label: 'Diffusion Rate',
min: 0.01,
max: 1,
step: 0.01,
},
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',
format: formatCompactNumber,
integer: true,
label: 'Population Limit',
min: 0,
step: 10_000,
},
internalRenderAreaMegapixels: {
folder: 'Performance',
label: 'Render Quality (MP)',
min: INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS.min,
max: INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS.max,
step: 0.1,
},
};

268
src/config/types.ts Normal file
View file

@ -0,0 +1,268 @@
import type {
GardenAudioConfig,
GardenAudioVibeSettings,
} from '../audio/garden-audio-config';
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;
options?: Record<string, number>;
step?: number;
}
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>
>;
export type GardenVibeSettings = Pick<
GardenRuntimeSettings,
| 'backgroundGrainStrength'
| 'brushSize'
| 'clarity'
| 'color1ToColor1'
| 'color1ToColor2'
| 'color1ToColor3'
| 'color2ToColor1'
| 'color2ToColor2'
| 'color2ToColor3'
| 'color3ToColor1'
| 'color3ToColor2'
| 'color3ToColor3'
| 'decayRateTrails'
| 'forwardRotationScale'
| 'individualTrailWeight'
| 'moveSpeed'
| 'sensorOffsetAngle'
| 'sensorOffsetDistance'
| 'spawnPerPixel'
| 'strokeAngleJitterRadians'
| 'turnSpeed'
| 'turnWhenLost'
>;
type GardenDefaultSettings = Omit<
GardenRuntimeSettings,
keyof GardenVibeSettings | 'eraserSize' | 'mirrorSegmentCount'
>;
export enum VibeId {
AuroraMycelium = 'aurora-mycelium',
VelvetObservatory = 'velvet-observatory',
LichenSignal = 'lichen-signal',
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;
analytics: {
autoCapturePageviews: boolean;
domain: string;
endpoint: string;
logging: boolean;
};
deltaTime: {
maxDeltaTimeSeconds: number;
minDeltaTimeSeconds: number;
};
exportSnapshot: {
bytesPerPixel: number;
filenameExtension: string;
filenamePrefix: string;
filenameSuffix: string;
mimeType: string;
rowAlignmentBytes: number;
};
menuHider: {
bottomRevealDistancePx: number;
desktopMediaQuery: string;
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;
};
diffusion: {
minDiffusionRate: number;
};
eraser: {
maxTextureLineCount: number;
};
};
defaultSettings: GardenDefaultSettings;
runtimeSettings: {
controls: RuntimeSettingControlConfig;
};
simulation: {
brushEffectFramesPerSecond: number;
clearColor: GPUColor;
initialAgentCount: number;
sourceActiveFramesAfterWrite: number;
intro: {
angleJitterRadians: number;
angleEaseEnd: number;
angleEaseStart: number;
circleMaxSideRatio: number;
circleMinSideRatio: number;
drawHintDelayMs: number;
durationSeconds: number;
entryJitterSideRatio: number;
fontScaleDown: number;
fontFamily: string;
initialFontHeightRatio: number;
initialFontWidthRatio: number;
letterSpacingEm: number;
maskAlphaThreshold: number;
maskGradientThreshold: number;
maskMaxPixels: number;
maskSampleDensity: number;
maxHeightRatio: number;
maxWidthRatio: number;
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;
targetJitterSideRatio: number;
title: string;
titleColorCutLetters: [number, number];
titleRadiusMultiplier: number;
titleStrokeWidthMinPx: number;
titleStrokeWidthRatio: number;
verticalAnchor: number;
};
introMoveSpeed: number;
stroke: {
densityMultiplier: number;
maxAgentCount: number;
};
};
storage: {
audioMutedKey: string;
audioVolumeKey: string;
vibeKey: string;
};
toolbar: {
eraser: {
controlScaleMax: number;
controlScaleMin: number;
default: number;
max: number;
min: number;
step: number;
};
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: {
showFpsOverlay: boolean;
startHidden: boolean;
title: string;
};
vibes: {
defaultVibeId: VibeId;
presets: Array<VibePreset>;
};
}

View file

@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest';
import { runtimeControls } from './runtime-controls';
import { vibePresets } from './vibe-presets';
const FINAL_VIBE_NAMES = [
'Aurora Mycelium Copy',
'Velvet Observatory Copy',
'Lichen Signal',
'Tidepool Lantern',
'Paper Lantern Fog',
'Chrome Pollen',
];
const BLENDED_BRUSH_SIZE_MIN = 17;
const BLENDED_CLARITY_MAX = 0.56;
const SOFT_PARTICLE_BRUSH_SIZE_MAX = 5;
const SOFT_PARTICLE_CLARITY_MAX = 0.2;
// Performance guardrails — bumping any of these is an explicit perf trade-off.
const MAX_SPAWN_PER_PIXEL = runtimeControls.spawnPerPixel?.max ?? 0.38;
const MAX_BRUSH_SIZE = runtimeControls.brushSize?.max ?? 36;
const HIGH_DENSITY_SPAWN_THRESHOLD = 0.28;
const HIGH_DENSITY_DECAY_LIMIT = 940;
const HIGH_DENSITY_BRUSH_SIZE_LIMIT = 14;
const HIGH_DENSITY_TRAIL_WEIGHT_LIMIT = 0.055;
describe('vibePresets', () => {
it('keeps the classic preset set distinct', () => {
expect(vibePresets.map((preset) => preset.name)).toEqual(FINAL_VIBE_NAMES);
const ids = vibePresets.map((preset) => preset.id);
expect(new Set(ids).size).toBe(vibePresets.length);
});
it('includes both blended and visibly particulate styles', () => {
const blendedNames = vibePresets
.filter(
(preset) =>
preset.settings.brushSize >= BLENDED_BRUSH_SIZE_MIN &&
preset.settings.clarity <= BLENDED_CLARITY_MAX
)
.map((preset) => preset.name);
const softParticleNames = vibePresets
.filter(
(preset) =>
preset.settings.brushSize <= SOFT_PARTICLE_BRUSH_SIZE_MAX &&
preset.settings.clarity <= SOFT_PARTICLE_CLARITY_MAX
)
.map((preset) => preset.name);
expect(blendedNames).toEqual(['Tidepool Lantern']);
expect(softParticleNames).toEqual(['Chrome Pollen']);
});
it('stays inside interactive performance guardrails', () => {
const violations = vibePresets.flatMap((preset) => {
const { name, settings } = preset;
const presetViolations: Array<string> = [];
if (settings.spawnPerPixel > MAX_SPAWN_PER_PIXEL) {
presetViolations.push(`${name} density exceeds ${MAX_SPAWN_PER_PIXEL}`);
}
if (settings.brushSize > MAX_BRUSH_SIZE) {
presetViolations.push(`${name} brush size exceeds ${MAX_BRUSH_SIZE}`);
}
if (
settings.spawnPerPixel >= HIGH_DENSITY_SPAWN_THRESHOLD &&
(settings.decayRateTrails > HIGH_DENSITY_DECAY_LIMIT ||
settings.brushSize > HIGH_DENSITY_BRUSH_SIZE_LIMIT ||
settings.individualTrailWeight > HIGH_DENSITY_TRAIL_WEIGHT_LIMIT)
) {
presetViolations.push(`${name} combines high density with too much persistence`);
}
return presetViolations;
});
expect(violations).toEqual([]);
});
});

366
src/config/vibe-presets.ts Normal file
View file

@ -0,0 +1,366 @@
import {
defaultGardenAudioVibeSettings,
type GardenAudioChord,
} from '../audio/garden-audio-config';
import { VibeId, type GardenVibeSettings, type VibePreset } from './types';
type ColorReactionSettings = Pick<
GardenVibeSettings,
| 'color1ToColor1'
| 'color1ToColor2'
| 'color1ToColor3'
| 'color2ToColor1'
| 'color2ToColor2'
| 'color2ToColor3'
| 'color3ToColor1'
| 'color3ToColor2'
| 'color3ToColor3'
>;
const colorReactions = {
auroraMycelium: {
color1ToColor1: 1,
color1ToColor2: 0,
color1ToColor3: 0,
color2ToColor1: -1,
color2ToColor2: 1,
color2ToColor3: 0,
color3ToColor1: -1,
color3ToColor2: -1,
color3ToColor3: 1,
},
velvetObservatory: {
color1ToColor1: 1,
color1ToColor2: -1,
color1ToColor3: -1,
color2ToColor1: -1,
color2ToColor2: 1,
color2ToColor3: -1,
color3ToColor1: -1,
color3ToColor2: -1,
color3ToColor3: 1,
},
lichenSignal: {
color1ToColor1: 0,
color1ToColor2: -1,
color1ToColor3: 1,
color2ToColor1: -1,
color2ToColor2: 0,
color2ToColor3: -1,
color3ToColor1: 1,
color3ToColor2: -1,
color3ToColor3: 1,
},
tidepoolLantern: {
color1ToColor1: 0,
color1ToColor2: 1,
color1ToColor3: 0,
color2ToColor1: 0,
color2ToColor2: 0,
color2ToColor3: 1,
color3ToColor1: 1,
color3ToColor2: 0,
color3ToColor3: 0,
},
paperLanternFog: {
color1ToColor1: 1,
color1ToColor2: 1,
color1ToColor3: 1,
color2ToColor1: 1,
color2ToColor2: 1,
color2ToColor3: 1,
color3ToColor1: 1,
color3ToColor2: 1,
color3ToColor3: 1,
},
chromePollen: {
color1ToColor1: 1,
color1ToColor2: 0,
color1ToColor3: 1,
color2ToColor1: -1,
color2ToColor2: 1,
color2ToColor3: 0,
color3ToColor1: 1,
color3ToColor2: 0,
color3ToColor3: 1,
},
} satisfies Record<string, ColorReactionSettings>;
const musicScales = {
dorian: [0, 2, 3, 5, 7, 9, 10],
lydian: [0, 2, 4, 6, 7, 9, 11],
mixolydian: [0, 2, 4, 5, 7, 9, 10],
naturalMinor: [0, 2, 3, 5, 7, 8, 10],
} satisfies Record<string, Array<number>>;
const musicProgressions = {
aurora: [
{ rootOffset: 0, quality: 'sus2' },
{ rootOffset: 7, quality: 'major' },
{ rootOffset: 9, quality: 'minor' },
{ rootOffset: 5, quality: 'sus4' },
],
chrome: [
{ rootOffset: 0, quality: 'major' },
{ rootOffset: 2, quality: 'major' },
{ rootOffset: 7, quality: 'sus2' },
{ rootOffset: 9, quality: 'minor' },
],
lichen: [
{ rootOffset: 0, quality: 'minor' },
{ rootOffset: 5, quality: 'major' },
{ rootOffset: 10, quality: 'major' },
{ rootOffset: 3, quality: 'major' },
],
paperLantern: [
{ rootOffset: 0, quality: 'minor' },
{ rootOffset: 8, quality: 'major' },
{ rootOffset: 5, quality: 'minor' },
{ rootOffset: 10, quality: 'sus4' },
],
tidepool: [
{ rootOffset: 0, quality: 'major' },
{ rootOffset: 10, quality: 'major' },
{ rootOffset: 5, quality: 'sus2' },
{ rootOffset: 9, quality: 'minor' },
],
velvet: [
{ rootOffset: 0, quality: 'minor' },
{ rootOffset: 8, quality: 'major' },
{ rootOffset: 3, quality: 'major' },
{ rootOffset: 5, quality: 'sus4' },
],
} satisfies Record<string, Array<GardenAudioChord>>;
export const defaultVibeId = VibeId.AuroraMycelium;
export const vibePresets: Array<VibePreset> = [
{
id: VibeId.AuroraMycelium,
name: 'Aurora Mycelium Copy',
colors: [
[251, 210, 94],
[154, 99, 255],
[255, 31, 199],
],
backgroundColor: [6, 13, 22],
settings: {
...colorReactions.auroraMycelium,
backgroundGrainStrength: 0.003,
brushSize: 8.75,
clarity: 1,
decayRateTrails: 973,
forwardRotationScale: 0.37,
individualTrailWeight: 0.053000000000000005,
moveSpeed: 144,
sensorOffsetAngle: 35,
sensorOffsetDistance: 52,
spawnPerPixel: 0.13999999999999999,
strokeAngleJitterRadians: 0.45,
turnSpeed: 13,
turnWhenLost: 0,
},
audio: {
...defaultGardenAudioVibeSettings,
idleIntensity: 0.12000000000000002,
bpm: 60,
rampUpIntensity: 0.7,
rampUpTime: 0.14,
noteLength: 0.8599999999999999,
notePitchOffset: -2,
brightness: 0.84,
scale: musicScales.lydian,
progression: musicProgressions.aurora,
},
},
{
id: VibeId.VelvetObservatory,
name: 'Velvet Observatory Copy',
colors: [
[178, 76, 62],
[2, 174, 255],
[213, 193, 9],
],
backgroundColor: [7, 4, 22],
settings: {
...colorReactions.velvetObservatory,
backgroundGrainStrength: 0.005,
brushSize: 9.75,
clarity: 1,
decayRateTrails: 974,
forwardRotationScale: 0,
individualTrailWeight: 0.232,
moveSpeed: 121,
sensorOffsetAngle: 24,
sensorOffsetDistance: 17,
spawnPerPixel: 0.11499999999999999,
strokeAngleJitterRadians: 0.17,
turnSpeed: 33,
turnWhenLost: 0.42,
},
audio: {
...defaultGardenAudioVibeSettings,
idleIntensity: 0.24000000000000002,
bpm: 72,
rampUpIntensity: 1.42,
rampUpTime: 0.07,
noteLength: 0.7,
notePitchOffset: 0,
brightness: 0.94,
scale: musicScales.naturalMinor,
progression: musicProgressions.velvet,
},
},
{
id: VibeId.LichenSignal,
name: 'Lichen Signal',
colors: [
[183, 216, 92],
[65, 166, 128],
[238, 120, 76],
],
backgroundColor: [0, 0, 0],
settings: {
...colorReactions.lichenSignal,
backgroundGrainStrength: 0.02,
brushSize: 6.5,
clarity: 0.74,
decayRateTrails: 962,
forwardRotationScale: 0.3,
individualTrailWeight: 0.052,
moveSpeed: 72,
sensorOffsetAngle: 42,
sensorOffsetDistance: 54,
spawnPerPixel: 0.16,
strokeAngleJitterRadians: 3.14,
turnSpeed: 44,
turnWhenLost: 0.92,
},
audio: {
...defaultGardenAudioVibeSettings,
idleIntensity: 0.13,
bpm: 68,
rampUpIntensity: 1.46,
rampUpTime: 0.1,
noteLength: 0.6,
notePitchOffset: -3,
brightness: 1.21,
scale: musicScales.dorian,
progression: musicProgressions.lichen,
},
},
{
id: VibeId.TidepoolLantern,
name: 'Tidepool Lantern',
colors: [
[30, 219, 194],
[61, 118, 255],
[255, 191, 91],
],
backgroundColor: [4, 18, 29],
settings: {
...colorReactions.tidepoolLantern,
backgroundGrainStrength: 0.018,
brushSize: 17,
clarity: 0.56,
decayRateTrails: 968,
forwardRotationScale: 0.38,
individualTrailWeight: 0.06,
moveSpeed: 88,
sensorOffsetAngle: 64,
sensorOffsetDistance: 46,
spawnPerPixel: 0.22,
strokeAngleJitterRadians: 1.8,
turnSpeed: 66,
turnWhenLost: 1.05,
},
audio: {
...defaultGardenAudioVibeSettings,
idleIntensity: 0.08,
bpm: 84,
rampUpIntensity: 0.95,
rampUpTime: 0.08,
noteLength: 0.46,
notePitchOffset: 0,
brightness: 0.98,
scale: musicScales.mixolydian,
progression: musicProgressions.tidepool,
},
},
{
id: VibeId.PaperLanternFog,
name: 'Paper Lantern Fog',
colors: [
[255, 176, 108],
[239, 90, 108],
[128, 213, 184],
],
backgroundColor: [30, 23, 20],
settings: {
...colorReactions.paperLanternFog,
backgroundGrainStrength: 0.038,
brushSize: 3.5,
clarity: 1,
decayRateTrails: 999,
forwardRotationScale: 0.24,
individualTrailWeight: 0.937,
moveSpeed: 28,
sensorOffsetAngle: 34,
sensorOffsetDistance: 66,
spawnPerPixel: 0.055,
strokeAngleJitterRadians: 0,
turnSpeed: 30,
turnWhenLost: 1.52,
},
audio: {
...defaultGardenAudioVibeSettings,
idleIntensity: 0.33,
bpm: 127,
rampUpIntensity: 0.66,
rampUpTime: 0.03,
noteLength: 0.92,
notePitchOffset: 10,
brightness: 1.42,
scale: musicScales.naturalMinor,
progression: musicProgressions.paperLantern,
},
},
{
id: VibeId.ChromePollen,
name: 'Chrome Pollen',
colors: [
[178, 34, 34],
[255, 214, 48],
[77, 240, 157],
],
backgroundColor: [7, 12, 11],
settings: {
...colorReactions.chromePollen,
backgroundGrainStrength: 0.012,
brushSize: 4.5,
clarity: 0.1,
decayRateTrails: 922,
forwardRotationScale: 0.5,
individualTrailWeight: 0.026,
moveSpeed: 86,
sensorOffsetAngle: 46,
sensorOffsetDistance: 14,
spawnPerPixel: 0.36,
strokeAngleJitterRadians: 3,
turnSpeed: 34,
turnWhenLost: 1.35,
},
audio: {
...defaultGardenAudioVibeSettings,
idleIntensity: 0.11,
bpm: 150,
rampUpIntensity: 2,
rampUpTime: 0.06,
noteLength: 1.8,
notePitchOffset: -12,
brightness: 0.5,
scale: musicScales.lydian,
progression: musicProgressions.chrome,
},
},
];

View file

@ -1 +0,0 @@
export const isProduction: boolean = import.meta.env.PROD;

View file

@ -0,0 +1,178 @@
import { vec2 } from 'gl-matrix';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { appConfig } from '../config';
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 originalSettings = {
brushSize: settings.brushSize,
maxAgentCount: settings.maxAgentCount,
selectedColorIndex: settings.selectedColorIndex,
spawnPerPixel: settings.spawnPerPixel,
strokeAngleJitterRadians: settings.strokeAngleJitterRadians,
};
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 };
};
const setSpawnRate = (agentsPerPixel: number): void => {
settings.spawnPerPixel = agentsPerPixel / appConfig.simulation.stroke.densityMultiplier;
};
describe('AgentPopulation stroke spawning', () => {
beforeEach(() => {
settings.brushSize = 0;
settings.maxAgentCount = 1_000_000;
settings.selectedColorIndex = 0;
settings.strokeAngleJitterRadians = 0;
setSpawnRate(1);
});
afterEach(() => {
Object.assign(settings, originalSettings);
});
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)
);
}
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('carries fractional spawn budget within a stroke', () => {
setSpawnRate(0.5);
const { population } = createPopulation();
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(1, 0));
expect(population.activeAgentCount).toBe(0);
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('clears active agents when an intro replacement has no generated agents', () => {
const { population } = createPopulation();
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(3, 0));
expect(population.activeAgentCount).toBe(3);
settings.maxAgentCount = 0;
population.replaceIntroAgents(vec2.fromValues(100, 100), 0);
expect(population.activeAgentCount).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

@ -0,0 +1,339 @@
import { vec2 } from 'gl-matrix';
import { appConfig } from '../config';
import { getRenderQualityBrushSize } from '../config/brush-size';
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 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(
appConfig.simulation.stroke.maxAgentCount * AGENT_FLOAT_COUNT
);
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 initializeIntroAgents(canvasSize: vec2): void {
this.replaceIntroAgents(canvasSize, 0);
}
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) {
this.activeCount = 0;
this.replacementCursor = 0;
return;
}
this.pipeline.writeAgents(0, data);
this.activeCount = data.length / AGENT_FLOAT_COUNT;
this.replacementCursor = 0;
}
public onVibeChanged(): void {
this.pendingStrokeAgentCount = 0;
this.adaptiveCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
this.trimActiveCountToBudget();
}
public beginStroke(): void {
this.pendingStrokeAgentCount = 0;
}
public resizeAgents(scale: vec2): void {
this.pipeline.resizeAgents(this.activeCount, scale);
}
public requestCompactionAfterErase(): void {
this.shouldCompactAfterErase = true;
}
public compactAfterErase(isSwipeActive: boolean): void {
if (!this.shouldCompactAfterErase || this.isCompacting || isSwipeActive) {
return;
}
this.shouldCompactAfterErase = false;
if (this.activeCount === 0) {
return;
}
this.isCompacting = true;
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 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;
}
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 =
getRenderQualityBrushSize(
settings.brushSize,
settings.internalRenderAreaMegapixels
) * 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 {
if (data.length === 0) {
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, this.adaptiveCap - this.activeCount);
const appendCount = Math.min(count, available);
if (appendCount > 0) {
this.pipeline.writeAgents(
this.activeCount,
data.subarray(0, appendCount * AGENT_FLOAT_COUNT)
);
this.activeCount += appendCount;
}
let sourceAgentOffset = appendCount;
while (sourceAgentOffset < count && this.activeCount > 0) {
const targetAgentOffset = this.replacementCursor % this.activeCount;
const chunkAgentCount = Math.min(
count - sourceAgentOffset,
this.activeCount - targetAgentOffset
);
this.pipeline.writeAgents(
targetAgentOffset,
data.subarray(
sourceAgentOffset * AGENT_FLOAT_COUNT,
(sourceAgentOffset + chunkAgentCount) * AGENT_FLOAT_COUNT
)
);
sourceAgentOffset += chunkAgentCount;
this.replacementCursor = (targetAgentOffset + chunkAgentCount) % this.activeCount;
}
}
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, this.adaptiveCap - this.activeCount);
if (requestedAgentCount <= available || !this.canExpandAdaptiveCap) {
return;
}
const currentCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
const pendingAgentCount = requestedAgentCount - available;
this.adaptiveCap = this.clampAndEnsureAdaptiveCap(currentCap + pendingAgentCount);
}
private trimActiveCountToBudget(maxDecrease = Number.POSITIVE_INFINITY): void {
if (this.activeCount <= this.adaptiveCap) {
return;
}
this.activeCount = Math.max(
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 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;
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,155 @@
import { vec2 } from 'gl-matrix';
import { appConfig } from '../config';
import { getRenderQualityBrushSize } from '../config/brush-size';
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 brushSize = getRenderQualityBrushSize(
settings.brushSize,
settings.internalRenderAreaMegapixels
);
const brushRadius = Math.max(
settings.brushCurveMinBrushRadius * canvasPixelRatio,
(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

@ -0,0 +1,122 @@
import { settings } from '../settings';
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 = '';
private isVisible = false;
public constructor(
private readonly canvas: HTMLCanvasElement,
private readonly element: HTMLElement,
private readonly getIsSwipeActive: () => boolean
) {}
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();
}
public update(event?: PointerEvent): void {
this.isSwipeActive = this.getIsSwipeActive();
if (event) {
this.previewClientPosition = {
x: event.clientX,
y: event.clientY,
};
}
if (this.previousSize !== settings.eraserSize) {
this.element.style.setProperty('--eraser-preview-size', `${settings.eraserSize}px`);
this.previousSize = settings.eraserSize;
}
if (
!this.isErasing ||
this.previewClientPosition === null ||
(!this.isPointerHoveringCanvas && !this.isSwipeActive)
) {
this.setVisible(false);
return;
}
const rect = this.canvas.getBoundingClientRect();
const left = `${this.previewClientPosition.x - rect.left}px`;
const top = `${this.previewClientPosition.y - rect.top}px`;
if (this.previousLeft !== left) {
this.element.style.left = left;
this.previousLeft = left;
}
if (this.previousTop !== top) {
this.element.style.top = top;
this.previousTop = top;
}
this.setVisible(true);
}
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 &&
event.clientX <= rect.right &&
event.clientY >= rect.top &&
event.clientY <= rect.bottom
);
}
private readonly onPointerDown = (event: PointerEvent) => {
this.isPointerHoveringCanvas = true;
this.update(event);
};
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

@ -0,0 +1,204 @@
import { appConfig } from '../config';
import { RenderPipeline } from '../pipelines/render/render-pipeline';
import type { VibeId } from '../vibes';
interface ExportSnapshotRendererOptions {
device: GPUDevice;
renderPipeline: RenderPipeline;
canvasFormat: GPUTextureFormat;
statusElement: HTMLElement;
seed: string;
getSourceSize: () => { width: number; height: number };
getColorTextureView: () => GPUTextureView;
getSourceTextureView: () => GPUTextureView;
getSourceActive?: () => boolean;
getVibeId: () => VibeId;
}
interface SnapshotLayout {
width: number;
height: number;
unpaddedBytesPerRow: number;
bytesPerRow: number;
readbackBufferBytes: number;
}
export class ExportSnapshotRenderer {
private isExporting = false;
public constructor(private readonly options: ExportSnapshotRendererOptions) {}
public async export(): Promise<void> {
if (this.isExporting) {
this.statusElement.textContent = 'Snapshot already saving...';
return;
}
this.isExporting = true;
this.statusElement.textContent = 'Saving snapshot...';
try {
const sourceSize = this.options.getSourceSize();
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 renderSnapshot(layout: SnapshotLayout): Promise<void> {
const { width, height, unpaddedBytesPerRow, bytesPerRow } = layout;
let texture: GPUTexture | null = null;
let output: GPUBuffer | null = null;
let isOutputMapped = false;
try {
texture = this.device.createTexture({
size: { width, height },
format: this.options.canvasFormat,
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
});
output = this.device.createBuffer({
size: layout.readbackBufferBytes,
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
});
const commandEncoder = this.device.createCommandEncoder();
this.options.renderPipeline.executeToView(
commandEncoder,
this.options.getColorTextureView(),
this.options.getSourceTextureView(),
texture.createView(),
this.options.getSourceActive?.() ?? true
);
commandEncoder.copyTextureToBuffer(
{ texture },
{ buffer: output, bytesPerRow, rowsPerImage: height },
{ width, height }
);
this.device.queue.submit([commandEncoder.finish()]);
await output.mapAsync(GPUMapMode.READ);
isOutputMapped = true;
const pixels = readSnapshotPixels({
mapped: new Uint8Array(output.getMappedRange()),
width,
height,
unpaddedBytesPerRow,
bytesPerRow,
isBgra: this.options.canvasFormat === 'bgra8unorm',
});
output.unmap();
isOutputMapped = false;
output.destroy();
output = null;
texture.destroy();
texture = null;
await this.downloadPixels(pixels, width, height);
} finally {
if (output && isOutputMapped) {
output.unmap();
}
output?.destroy();
texture?.destroy();
}
}
private async downloadPixels(
pixels: Uint8ClampedArray<ArrayBuffer>,
width: number,
height: number
): Promise<void> {
const canvas = new OffscreenCanvas(width, height);
const context = canvas.getContext('2d');
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: appConfig.exportSnapshot.mimeType,
});
const link = document.createElement('a');
const objectUrl = URL.createObjectURL(blob);
try {
link.href = objectUrl;
link.download = `${appConfig.exportSnapshot.filenamePrefix}_${this.options.getVibeId()}_${
this.options.seed
}_${width}x${height}${appConfig.exportSnapshot.filenameSuffix}.${appConfig.exportSnapshot.filenameExtension}`;
link.click();
} finally {
URL.revokeObjectURL(objectUrl);
}
}
private get device(): GPUDevice {
return this.options.device;
}
private get statusElement(): HTMLElement {
return this.options.statusElement;
}
}
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,
unpaddedBytesPerRow,
bytesPerRow,
isBgra,
}: {
mapped: Uint8Array;
width: number;
height: number;
unpaddedBytesPerRow: number;
bytesPerRow: number;
isBgra: boolean;
}): Uint8ClampedArray<ArrayBuffer> => {
const pixels: Uint8ClampedArray<ArrayBuffer> = new Uint8ClampedArray(
unpaddedBytesPerRow * height
);
for (let y = 0; y < height; y++) {
const sourceOffset = y * bytesPerRow;
const targetOffset = y * unpaddedBytesPerRow;
for (let x = 0; x < width; x++) {
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];
pixels[target + 3] = mapped[source + 3];
}
}
return pixels;
};

View file

@ -0,0 +1,61 @@
import { settings } from '../settings';
const ADAPTIVE_REFRESH_TARGET_FPS = 60;
const ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND = 200_000;
const FRAME_GAP_RESET_SECONDS = 1;
const FPS_HEADROOM = 0.9;
const FPS_SMOOTHING_NEW = 0.06;
const FPS_SMOOTHING_RETAIN = 1 - FPS_SMOOTHING_NEW;
export class FramePerformance {
public smoothedFps = ADAPTIVE_REFRESH_TARGET_FPS;
public measuredFps = 0;
public frameDeltaSeconds = 0;
public measuredFrameTimeMs = 0;
private previousFrameTime: DOMHighResTimeStamp | null = null;
public get adaptiveCapInitial(): number {
return settings.adaptiveCapInitial;
}
public get adaptiveCapMin(): number {
return settings.adaptiveCapMin;
}
public get hasAdaptiveCapHeadroom(): boolean {
return this.smoothedFps >= ADAPTIVE_REFRESH_TARGET_FPS * FPS_HEADROOM;
}
public get adaptiveCapDecreaseAgents(): number {
return Math.max(
1,
Math.ceil(ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND * 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;
}
this.measuredFrameTimeMs = deltaSeconds * 1000;
const fps = 1 / deltaSeconds;
this.measuredFps = fps;
if (deltaSeconds > FRAME_GAP_RESET_SECONDS) {
this.frameDeltaSeconds = 0;
this.smoothedFps = ADAPTIVE_REFRESH_TARGET_FPS;
return;
}
this.frameDeltaSeconds = deltaSeconds;
this.smoothedFps = this.smoothedFps * FPS_SMOOTHING_RETAIN + fps * FPS_SMOOTHING_NEW;
}
}

View file

@ -0,0 +1,186 @@
import { vec2 } from 'gl-matrix';
import { appConfig, type GardenRuntimeSettings } from '../config';
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
import { AgentPipeline } from '../pipelines/agents/agent-pipeline';
import { BrushPipeline } from '../pipelines/brush/brush-pipeline';
import { CommonState } from '../pipelines/common-state/common-state';
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 { initializeContext } from '../utils/graphics/initialize-context';
import { CanvasReadbackRequest, RenderInputs } from './game-loop-types';
import { GpuProfiler } from './gpu-profiler';
import { SimulationFrameRenderer } from './simulation-frame';
import { SimulationTextures } from './simulation-textures';
interface FrameParameters extends RenderInputs {
time: number;
deltaTime: number;
canvasSize: vec2;
activeAgentCount: number;
canvasPixelRatio: number;
introProgress: number;
selectedColorIndex: number;
eraserPixelSize: number;
runtimeSettings: GardenRuntimeSettings;
}
export class GameLoopResources {
public readonly textures: SimulationTextures;
public readonly commonState: CommonState;
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 renderPipeline: RenderPipeline;
public readonly gpuProfiler: GpuProfiler | null;
private readonly frameRenderer: SimulationFrameRenderer;
public constructor(
canvas: HTMLCanvasElement,
private readonly device: GPUDevice,
private readonly canvasFormat: GPUTextureFormat,
canvasSize: vec2,
initialAgentCapacity: number,
initialMaxAgentCount: number
) {
const context = initializeContext({ device, canvas, format: canvasFormat });
this.textures = new SimulationTextures(this.device, canvasSize);
this.commonState = new CommonState(this.device);
this.commonState.setParameters({
canvasSize,
});
this.agentGenerationPipeline = new AgentGenerationPipeline(
this.device,
Math.min(initialMaxAgentCount, initialAgentCapacity)
);
this.agentPipeline = new AgentPipeline(
this.device,
this.commonState,
() => this.agentGenerationPipeline.agentsBuffer
);
this.brushPipeline = new BrushPipeline(this.device, this.commonState);
this.eraserAgentPipeline = new EraserAgentPipeline(
this.device,
() => this.agentGenerationPipeline.agentsBuffer
);
this.eraserTexturePipeline = new EraserTexturePipeline(this.device, this.commonState);
this.diffusionPipeline = new DiffusionPipeline(this.device);
this.renderPipeline = new RenderPipeline(context, this.device, this.canvasFormat);
this.gpuProfiler = GpuProfiler.create(
this.device,
() => appConfig.tuningPane.showFpsOverlay
);
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,
channelColors,
backgroundColor,
eraserPixelSize,
runtimeSettings,
}: FrameParameters): void {
this.commonState.setParameters({
canvasSize,
});
this.agentPipeline.setParameters({
...runtimeSettings,
deltaTime,
time,
agentCount: activeAgentCount,
introMoveSpeed: appConfig.simulation.introMoveSpeed,
introProgress,
});
this.brushPipeline.setParameters({
...runtimeSettings,
pixelRatio: canvasPixelRatio,
selectedColorIndex,
});
this.diffusionPipeline.setParameters(runtimeSettings);
this.renderPipeline.setParameters({
...runtimeSettings,
channelColors,
backgroundColor,
});
this.eraserAgentPipeline.setParameters({
agentCount: activeAgentCount,
eraserSize: eraserPixelSize,
eraserMaskAlphaThreshold: runtimeSettings.eraserMaskAlphaThreshold,
maskSize: canvasSize,
});
this.eraserTexturePipeline.setParameters({
eraserSize: eraserPixelSize,
eraserLineDistanceEpsilon: runtimeSettings.eraserLineDistanceEpsilon,
eraserClearRed: runtimeSettings.eraserClearRed,
eraserClearGreen: runtimeSettings.eraserClearGreen,
eraserClearBlue: runtimeSettings.eraserClearBlue,
eraserClearAlpha: runtimeSettings.eraserClearAlpha,
});
}
public executeFrame(
isErasing: boolean,
canvasReadbackRequest?: CanvasReadbackRequest | null
): void {
this.frameRenderer.execute(isErasing, canvasReadbackRequest);
}
public destroy(): void {
this.agentGenerationPipeline.destroy();
this.agentPipeline.destroy();
this.brushPipeline.destroy();
this.eraserAgentPipeline.destroy();
this.eraserTexturePipeline.destroy();
this.diffusionPipeline.destroy();
this.renderPipeline.destroy();
this.gpuProfiler?.destroy();
this.commonState.destroy();
this.textures.destroy();
}
}

View file

@ -1,8 +0,0 @@
export interface GameLoopSettings {
maxAgentCountUpperLimit: number;
agentCount: number;
renderSpeed: number;
simulatedDelayMs: number;
startColorHue: number;
}

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