Compare commits
20 commits
10a81ba474
...
ed5a4379db
| Author | SHA1 | Date | |
|---|---|---|---|
| ed5a4379db | |||
| 6bc125be1c | |||
| 2fe3c69963 | |||
| f03da42b5e | |||
| c94ffcc506 | |||
| 7c70f15e49 | |||
| ea0304356f | |||
| 15e99380b5 | |||
| d6a8f898d1 | |||
| ced0ac56f3 | |||
| 80ed37298b | |||
| 560398fefb | |||
| 2c7d72a699 | |||
| d2da0d1617 | |||
| ce383ce34c | |||
| 1fe5015056 | |||
| 70423851ba | |||
| 20433bd9f0 | |||
| 9256377c13 | |||
| c719b7e5b3 |
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"enabledPlugins": {
|
||||
"frontend-design@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
name: Deploy to Pages
|
||||
name: Check & deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
|
|
@ -25,25 +25,20 @@ jobs:
|
|||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Typecheck browser tests
|
||||
run: npm run typecheck:e2e
|
||||
run: |
|
||||
npm ci
|
||||
npx playwright install --with-deps chromium
|
||||
|
||||
- name: Test
|
||||
run: npm test
|
||||
run: |
|
||||
npm run lint:check
|
||||
npm run typecheck
|
||||
npm run typecheck:e2e
|
||||
npm test
|
||||
|
||||
- name: Browser tests
|
||||
run: npm run test:e2e
|
||||
- name: Test E2E
|
||||
run: |
|
||||
npm run test:e2e
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: failure()
|
||||
|
|
@ -59,4 +54,4 @@ jobs:
|
|||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
apt update && apt install -y rsync
|
||||
rsync -a --delete dist/ /pages/fleeting-garden
|
||||
rsync -a --delete dist/ /pages/fleeting
|
||||
|
|
|
|||
44
.gitignore
vendored
|
|
@ -1,47 +1,3 @@
|
|||
# Dependency directory
|
||||
node_modules
|
||||
modules/
|
||||
ts-node--*/
|
||||
rss.xml
|
||||
|
||||
dist
|
||||
playwright-report
|
||||
test-results
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.ssh
|
||||
*.ppk
|
||||
v8-compile-cache-0/
|
||||
Thumbs.db
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (http://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
bin
|
||||
ts-node
|
||||
|
||||
# Personal Scripts
|
||||
*.bat
|
||||
*.ssh
|
||||
*.sh
|
||||
!system.min.js
|
||||
|
||||
# Editors
|
||||
.vscode
|
||||
.markdownlint.json
|
||||
|
||||
# Build Files
|
||||
temp
|
||||
*.js
|
||||
*.map
|
||||
!webpack.*
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
Fleeting Garden is a single-player WebGPU drawing garden. Pick a vibe palette,
|
||||
draw persistent coloured paths, spawn agents from those strokes, erase locally,
|
||||
and export the scene as a 4K wallpaper.
|
||||
and export the scene as an internal render buffer snapshot.
|
||||
|
||||
Check out the [agent logic](./src/pipelines/agents/agent.wgsl).
|
||||
|
||||
## Testing
|
||||
|
||||
- `npm test` runs the Vitest unit suite.
|
||||
- `npm run test:e2e` builds the production bundle and runs the Playwright Chromium
|
||||
smoke test.
|
||||
- `npm run test:e2e` runs the Playwright Chromium smoke test. The Playwright
|
||||
config builds the production bundle before serving it.
|
||||
- `npx playwright install chromium` installs the local browser binary when needed.
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -2,7 +2,7 @@
|
|||
<path
|
||||
d="M12 3v11m0 0 4-4m-4 4-4-4M5 17v3h14v-3"
|
||||
fill="none"
|
||||
stroke="black"
|
||||
stroke="white"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 239 B After Width: | Height: | Size: 239 B |
|
|
@ -1,4 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="#FFFFFF" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="white" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<line x1="12" y1="8" x2="12.01" y2="8" />
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 344 B After Width: | Height: | Size: 342 B |
|
|
@ -1,4 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="#FFFFFF" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="white" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M4 8v-2a2 2 0 0 1 2 -2h2" />
|
||||
<path d="M4 16v2a2 2 0 0 0 2 2h2" />
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 376 B After Width: | Height: | Size: 374 B |
|
|
@ -1,4 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="#FFFFFF" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="white" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M15 19v-2a2 2 0 0 1 2 -2h2" />
|
||||
<path d="M15 5v2a2 2 0 0 0 2 2h2" />
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 376 B After Width: | Height: | Size: 374 B |
|
|
@ -1,4 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="#FFFFFF" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="white" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M20 11a8.1 8.1 0 0 0 -15.5 -2m-.5 -4v4h4" />
|
||||
<path d="M4 13a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4" />
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 326 B After Width: | Height: | Size: 324 B |
|
|
@ -1,4 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="#FFFFFF" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="white" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M14 6m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
||||
<path d="M4 6l8 0" />
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 536 B After Width: | Height: | Size: 534 B |
|
|
@ -1,3 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M3 9.25v5.5h4.1L13 20V4L7.1 9.25H3Zm13.3-2.8-1.4 1.4A5.1 5.1 0 0 1 16.5 12a5.1 5.1 0 0 1-1.6 4.15l1.4 1.4A7.1 7.1 0 0 0 18.5 12a7.1 7.1 0 0 0-2.2-5.55Zm2.85-2.85-1.42 1.42A9.55 9.55 0 0 1 20.5 12a9.55 9.55 0 0 1-2.77 6.98l1.42 1.42A11.55 11.55 0 0 0 22.5 12a11.55 11.55 0 0 0-3.35-8.4Z" />
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white">
|
||||
<path d="M3 9.25v5.5h4.1L13 20V4L7.1 9.25H3Zm13.3-2.8-1.4 1.4A5.1 5.1 0 0 1 16.5 12a5.1 5.1 0 0 1-1.6 4.15l1.4 1.4A7.1 7.1 0 0 0 18.5 12a7.1 7.1 0 0 0-2.2-5.55Zm2.85-2.85-1.42 1.42A9.55 9.55 0 0 1 20.5 12a9.55 9.55 0 0 1-2.77 6.98l1.42 1.42A11.55 11.55 0 0 0 22.5 12a11.55 11.55 0 0 0-3.35-8.4Z" />
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 369 B After Width: | Height: | Size: 384 B |
470
e2e/app.spec.ts
|
|
@ -1,6 +1,33 @@
|
|||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
type WebGpuFailureMode = 'adapter-null' | 'adapter-rejects' | 'device-rejects';
|
||||
const canvasName = 'Interactive generative garden canvas';
|
||||
|
||||
const isLocalUrl = (url: string) => {
|
||||
const { hostname } = new URL(url);
|
||||
return hostname === '127.0.0.1' || hostname === 'localhost';
|
||||
};
|
||||
|
||||
const collectLocalBrowserFailures = (page: Page) => {
|
||||
const failures: Array<string> = [];
|
||||
|
||||
page.on('requestfailed', (request) => {
|
||||
if (!isLocalUrl(request.url())) {
|
||||
return;
|
||||
}
|
||||
|
||||
const failure = request.failure();
|
||||
failures.push(`${request.method()} ${request.url()} ${failure?.errorText}`);
|
||||
});
|
||||
page.on('response', (response) => {
|
||||
if (response.status() < 400 || !isLocalUrl(response.url())) {
|
||||
return;
|
||||
}
|
||||
|
||||
failures.push(`${response.status()} ${response.url()}`);
|
||||
});
|
||||
|
||||
return failures;
|
||||
};
|
||||
|
||||
const disableWebGpu = async (page: Page) => {
|
||||
await page.addInitScript(() => {
|
||||
|
|
@ -11,299 +38,190 @@ const disableWebGpu = async (page: Page) => {
|
|||
});
|
||||
};
|
||||
|
||||
const emulateWebGpuFailure = async (page: Page, mode: WebGpuFailureMode) => {
|
||||
await page.addInitScript((failureMode) => {
|
||||
const limits = {
|
||||
maxBufferSize: 256 * 1024 * 1024,
|
||||
maxComputeWorkgroupsPerDimension: 65_535,
|
||||
maxStorageBufferBindingSize: 128 * 1024 * 1024,
|
||||
};
|
||||
const adapter = {
|
||||
features: new Set(),
|
||||
info: {
|
||||
architecture: 'test',
|
||||
description: 'Playwright fake adapter',
|
||||
device: 'test-device',
|
||||
isFallbackAdapter: false,
|
||||
subgroupMaxSize: 0,
|
||||
subgroupMinSize: 0,
|
||||
vendor: 'test-vendor',
|
||||
},
|
||||
limits,
|
||||
requestDevice: async () => {
|
||||
if (failureMode === 'device-rejects') {
|
||||
throw new Error('Playwright fake device failure');
|
||||
}
|
||||
|
||||
return {};
|
||||
},
|
||||
};
|
||||
|
||||
Object.defineProperty(navigator, 'gpu', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getPreferredCanvasFormat: () => 'rgba8unorm',
|
||||
requestAdapter: async () => {
|
||||
if (failureMode === 'adapter-null') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (failureMode === 'adapter-rejects') {
|
||||
throw new Error('Playwright fake adapter failure');
|
||||
}
|
||||
|
||||
return adapter;
|
||||
},
|
||||
},
|
||||
});
|
||||
}, mode);
|
||||
};
|
||||
|
||||
const getFirstSwatchColor = (page: Page) =>
|
||||
page
|
||||
.locator('.color-swatch')
|
||||
.first()
|
||||
.evaluate((element) => getComputedStyle(element).backgroundColor);
|
||||
|
||||
const getGardenBackground = (page: Page) =>
|
||||
page.evaluate(() =>
|
||||
document.documentElement.style.getPropertyValue('--garden-background').trim()
|
||||
);
|
||||
|
||||
test('loads the app shell and WebGPU fallback in Chromium', async ({ page }) => {
|
||||
await disableWebGpu(page);
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page).toHaveTitle('Fleeting Garden');
|
||||
await expect(
|
||||
page.getByRole('img', { name: 'Interactive generative garden canvas' })
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible();
|
||||
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
|
||||
await expect(page.getByRole('alert')).toContainText('Fleeting Garden needs WebGPU');
|
||||
|
||||
await page.getByRole('button', { name: 'About' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Fleeting Garden' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('keeps fallback controls interactive and accessible', async ({ page }) => {
|
||||
await disableWebGpu(page);
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
|
||||
|
||||
const aboutButton = page.getByRole('button', { name: 'About' });
|
||||
const aboutPanel = page.locator('#info-panel');
|
||||
await expect(aboutButton).toHaveAttribute('aria-expanded', 'false');
|
||||
await aboutButton.click();
|
||||
await expect(aboutButton).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(aboutPanel).toHaveAttribute('aria-hidden', 'false');
|
||||
await expect(aboutPanel).not.toHaveAttribute('inert', '');
|
||||
await expect(page.getByRole('heading', { name: 'Fleeting Garden' })).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(aboutButton).toHaveAttribute('aria-expanded', 'false');
|
||||
await expect(aboutPanel).toHaveAttribute('aria-hidden', 'true');
|
||||
await expect(aboutPanel).toHaveAttribute('inert', '');
|
||||
|
||||
const settingsButton = page.getByRole('button', { name: 'Show config overlay' });
|
||||
await settingsButton.click();
|
||||
await expect(page.getByRole('button', { name: 'Hide config overlay' })).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'true'
|
||||
);
|
||||
await expect(page.locator('.config-pane')).toBeVisible();
|
||||
|
||||
const soundButton = page.locator('button.sound');
|
||||
await expect(soundButton).toHaveAttribute('aria-pressed', 'false');
|
||||
await soundButton.click();
|
||||
await expect(soundButton).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(soundButton).toHaveAttribute('aria-label', 'Unmute audio');
|
||||
await page.reload();
|
||||
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
|
||||
await expect(page.locator('button.sound')).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
const initialSwatchColor = await getFirstSwatchColor(page);
|
||||
const initialBackground = await getGardenBackground(page);
|
||||
await page.getByRole('button', { name: 'Next vibe' }).click();
|
||||
await expect.poll(() => getFirstSwatchColor(page)).not.toBe(initialSwatchColor);
|
||||
await expect.poll(() => getGardenBackground(page)).not.toBe(initialBackground);
|
||||
|
||||
await page.getByRole('button', { name: 'Draw colour 2' }).click();
|
||||
await expect(page.locator('.color-swatch').nth(1)).toHaveClass(/active/);
|
||||
await expect(page.locator('.color-swatch').first()).not.toHaveClass(/active/);
|
||||
|
||||
const mirrorSlider = page.locator('.mirror-segment-slider');
|
||||
await mirrorSlider.evaluate((input) => {
|
||||
const slider = input as HTMLInputElement;
|
||||
slider.value = '3';
|
||||
slider.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
await expect(page.locator('.mirror-segment-control')).toHaveAttribute(
|
||||
'title',
|
||||
'3 thirds'
|
||||
);
|
||||
await expect(page.locator('.mirror-segment-control')).toHaveClass(/active/);
|
||||
});
|
||||
|
||||
(
|
||||
[
|
||||
{
|
||||
expectedCode: 'webgpu-adapter-unavailable',
|
||||
expectedMessage:
|
||||
'WebGPU is available, but this browser could not provide a compatible GPU adapter.',
|
||||
mode: 'adapter-null',
|
||||
},
|
||||
{
|
||||
expectedCode: 'webgpu-adapter-unavailable',
|
||||
expectedMessage: 'Could not request a WebGPU adapter.',
|
||||
mode: 'adapter-rejects',
|
||||
},
|
||||
{
|
||||
expectedCode: 'webgpu-device-unavailable',
|
||||
expectedMessage: 'Could not create a WebGPU device for this adapter.',
|
||||
mode: 'device-rejects',
|
||||
},
|
||||
] satisfies Array<{
|
||||
expectedCode: string;
|
||||
expectedMessage: string;
|
||||
mode: WebGpuFailureMode;
|
||||
}>
|
||||
).forEach(({ expectedCode, expectedMessage, mode }) => {
|
||||
test(`reports ${mode} startup failures without leaving the shell loading`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await emulateWebGpuFailure(page, mode);
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
|
||||
await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible();
|
||||
await expect(page.getByRole('alert')).toContainText(expectedMessage);
|
||||
await expect(page.getByRole('alert')).toContainText(expectedCode);
|
||||
});
|
||||
});
|
||||
|
||||
test('serves the production bundle without missing browser assets', async ({ page }) => {
|
||||
const browserFailures: Array<string> = [];
|
||||
|
||||
page.on('requestfailed', (request) => {
|
||||
const failure = request.failure();
|
||||
browserFailures.push(`${request.method()} ${request.url()} ${failure?.errorText}`);
|
||||
});
|
||||
page.on('response', (response) => {
|
||||
if (response.status() >= 400) {
|
||||
browserFailures.push(`${response.status()} ${response.url()}`);
|
||||
test('starts the WebGPU garden and accepts drawing input', async ({ page }) => {
|
||||
const browserFailures = collectLocalBrowserFailures(page);
|
||||
const consoleErrors: Array<string> = [];
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') {
|
||||
consoleErrors.push(message.text());
|
||||
}
|
||||
});
|
||||
|
||||
await disableWebGpu(page);
|
||||
await page.goto('/');
|
||||
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
|
||||
await page.addInitScript((expectedCanvasName) => {
|
||||
const captureState = { count: 0 };
|
||||
Object.defineProperty(window, '__fleetingGardenPointerCaptures', {
|
||||
configurable: true,
|
||||
value: captureState,
|
||||
});
|
||||
|
||||
const originalSetPointerCapture = Element.prototype.setPointerCapture;
|
||||
Element.prototype.setPointerCapture = function setPointerCapture(pointerId) {
|
||||
if (
|
||||
this instanceof HTMLCanvasElement &&
|
||||
this.getAttribute('aria-label') === expectedCanvasName
|
||||
) {
|
||||
captureState.count += 1;
|
||||
}
|
||||
|
||||
return originalSetPointerCapture.call(this, pointerId);
|
||||
};
|
||||
}, canvasName);
|
||||
|
||||
await page.goto('/');
|
||||
const startButton = page.getByRole('button', { name: 'Start' });
|
||||
await expect(startButton).toBeVisible();
|
||||
await expect(startButton).toBeEnabled({ timeout: 30_000 });
|
||||
await startButton.click();
|
||||
await expect(page.locator('body')).not.toHaveClass(/is-loading/, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible();
|
||||
|
||||
const canvas = page.getByRole('img', { name: canvasName });
|
||||
await expect(canvas).toBeVisible();
|
||||
const canvasSize = await canvas.evaluate((element) => {
|
||||
const canvasElement = element as HTMLCanvasElement;
|
||||
return {
|
||||
height: canvasElement.height,
|
||||
width: canvasElement.width,
|
||||
};
|
||||
});
|
||||
expect(canvasSize.width).toBeGreaterThan(0);
|
||||
expect(canvasSize.height).toBeGreaterThan(0);
|
||||
|
||||
const box = await canvas.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
if (!box) {
|
||||
return;
|
||||
}
|
||||
|
||||
await page.mouse.move(box.x + box.width * 0.2, box.y + box.height * 0.5);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width * 0.8, box.y + box.height * 0.5, {
|
||||
steps: 16,
|
||||
});
|
||||
await page.mouse.up();
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(
|
||||
window as unknown as {
|
||||
__fleetingGardenPointerCaptures?: { count: number };
|
||||
}
|
||||
).__fleetingGardenPointerCaptures?.count ?? 0
|
||||
)
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
expect(consoleErrors).toEqual([]);
|
||||
expect(browserFailures).toEqual([]);
|
||||
});
|
||||
|
||||
[
|
||||
{ height: 720, name: 'desktop', width: 1280 },
|
||||
{ height: 844, name: 'mobile', width: 390 },
|
||||
].forEach(({ height, name, width }) => {
|
||||
test(`keeps the fallback shell usable on ${name}`, async ({ page }) => {
|
||||
await page.setViewportSize({ height, width });
|
||||
await disableWebGpu(page);
|
||||
test('shows a clear fallback when WebGPU is unavailable', async ({ page }) => {
|
||||
const browserFailures = collectLocalBrowserFailures(page);
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
|
||||
await disableWebGpu(page);
|
||||
await page.goto('/');
|
||||
|
||||
const canvasBox = await page
|
||||
.getByRole('img', { name: 'Interactive generative garden canvas' })
|
||||
.boundingBox();
|
||||
expect(canvasBox?.width).toBeGreaterThan(0);
|
||||
expect(canvasBox?.height).toBeGreaterThan(0);
|
||||
await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'About' })).toBeVisible();
|
||||
await expect(page.getByRole('alert')).toContainText('Fleeting Garden needs WebGPU');
|
||||
await expect(page).toHaveTitle('Fleeting Garden');
|
||||
await expect(page.getByRole('img', { name: canvasName })).toBeVisible();
|
||||
await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible();
|
||||
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
|
||||
|
||||
const aboutButtonReceivesPointer = await page
|
||||
.getByRole('button', { name: 'About' })
|
||||
.evaluate((button) => {
|
||||
const rect = button.getBoundingClientRect();
|
||||
const target = document.elementFromPoint(
|
||||
rect.left + rect.width / 2,
|
||||
rect.top + rect.height / 2
|
||||
);
|
||||
|
||||
return button === target || button.contains(target);
|
||||
});
|
||||
|
||||
expect(aboutButtonReceivesPointer).toBe(true);
|
||||
});
|
||||
const fallback = page.getByRole('alert');
|
||||
await expect(fallback).toContainText('Fleeting Garden needs WebGPU');
|
||||
await expect(fallback).toContainText('webgpu-unsupported');
|
||||
expect(browserFailures).toEqual([]);
|
||||
});
|
||||
|
||||
test('hides the bottom dock after the cursor leaves fullscreen controls', async ({
|
||||
test('keeps audio focus outlines scoped to the active control', async ({ page }) => {
|
||||
await disableWebGpu(page);
|
||||
await page.goto('/');
|
||||
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
|
||||
|
||||
const audioControl = page.locator('.audio-control');
|
||||
const soundButton = page.locator('button.sound');
|
||||
const volumeSlider = page.locator('.volume-slider');
|
||||
|
||||
await soundButton.click();
|
||||
await expect(audioControl).toHaveCSS('outline-style', 'none');
|
||||
await expect(soundButton).toHaveCSS('outline-style', 'none');
|
||||
|
||||
await page.mouse.click(10, 10);
|
||||
for (let tabIndex = 0; tabIndex < 12; tabIndex += 1) {
|
||||
await page.keyboard.press('Tab');
|
||||
const activeClass = await page.evaluate(() =>
|
||||
String(document.activeElement?.className ?? '')
|
||||
);
|
||||
if (activeClass.includes('sound')) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await expect(soundButton).toBeFocused();
|
||||
await expect(soundButton).toHaveCSS('outline-style', 'solid');
|
||||
await expect(soundButton).toHaveCSS('outline-offset', '-4px');
|
||||
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(volumeSlider).toBeFocused();
|
||||
await expect(volumeSlider).toHaveCSS('outline-style', 'solid');
|
||||
await expect(volumeSlider).toHaveCSS('outline-offset', '-4px');
|
||||
});
|
||||
|
||||
test('keeps the config overlay scrollable and dismissible on mobile', async ({
|
||||
page,
|
||||
}) => {
|
||||
await disableWebGpu(page);
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 640 });
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Enter fullscreen' }).click();
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => Boolean(document.fullscreenElement)))
|
||||
.toBe(true);
|
||||
|
||||
await page.mouse.move(640, 120);
|
||||
await page.evaluate(() => {
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
const startButton = page.getByRole('button', { name: 'Start' });
|
||||
await expect(startButton).toBeEnabled({ timeout: 30_000 });
|
||||
await startButton.click();
|
||||
await expect(page.locator('body')).not.toHaveClass(/is-loading/, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await expect(page.locator('aside.control-dock')).toHaveClass(/menu-hidden/, {
|
||||
timeout: 6000,
|
||||
});
|
||||
await expect(page.locator('.garden-controls')).not.toBeVisible();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page
|
||||
.locator('aside.control-dock')
|
||||
.evaluate((dock) => dock.getBoundingClientRect().top >= window.innerHeight)
|
||||
)
|
||||
.toBe(true);
|
||||
const settingsButton = page.locator('button.settings');
|
||||
await settingsButton.click();
|
||||
|
||||
await page.mouse.move(640, 700);
|
||||
await expect(page.locator('aside.control-dock')).not.toHaveClass(/menu-hidden/);
|
||||
await expect(page.locator('.garden-controls')).toBeVisible();
|
||||
const pane = page.locator('.config-pane');
|
||||
const closeButton = page.locator('.config-pane-close');
|
||||
await expect(pane).toBeVisible();
|
||||
await expect(closeButton).toBeVisible();
|
||||
|
||||
const paneMetrics = await pane.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(element);
|
||||
return {
|
||||
bottom: rect.bottom,
|
||||
clientHeight: element.clientHeight,
|
||||
overflowY: style.overflowY,
|
||||
scrollHeight: element.scrollHeight,
|
||||
top: rect.top,
|
||||
viewportHeight: window.innerHeight,
|
||||
viewportWidth: window.innerWidth,
|
||||
width: rect.width,
|
||||
};
|
||||
});
|
||||
|
||||
expect(paneMetrics.top).toBeGreaterThanOrEqual(0);
|
||||
expect(paneMetrics.bottom).toBeLessThanOrEqual(paneMetrics.viewportHeight);
|
||||
expect(Math.round(paneMetrics.width)).toBe(Math.round(paneMetrics.viewportWidth * 0.8));
|
||||
expect(paneMetrics.scrollHeight).toBeGreaterThan(paneMetrics.clientHeight);
|
||||
expect(['auto', 'scroll']).toContain(paneMetrics.overflowY);
|
||||
|
||||
await pane.evaluate((element) => {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
});
|
||||
await expect
|
||||
.poll(() =>
|
||||
page
|
||||
.locator('aside.control-dock')
|
||||
.evaluate((dock) => dock.getBoundingClientRect().bottom <= window.innerHeight)
|
||||
)
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test('keeps the bottom dock visible in mobile fullscreen', async ({ page }) => {
|
||||
await page.setViewportSize({ height: 844, width: 390 });
|
||||
await disableWebGpu(page);
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Enter fullscreen' }).click();
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => Boolean(document.fullscreenElement)))
|
||||
.toBe(true);
|
||||
|
||||
await page.mouse.move(195, 120);
|
||||
await page.evaluate(() => {
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
});
|
||||
await page.waitForTimeout(5200);
|
||||
|
||||
await expect(page.locator('aside.control-dock')).not.toHaveClass(/menu-hidden/);
|
||||
await expect(page.getByRole('button', { name: 'Show config overlay' })).toBeVisible();
|
||||
.poll(() => pane.evaluate((element) => element.scrollTop))
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
await closeButton.click();
|
||||
await expect(pane).toBeHidden();
|
||||
await expect(settingsButton).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
|
|
|||
30
eslint.config.js
Normal file
|
|
@ -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',
|
||||
},
|
||||
}
|
||||
);
|
||||
223
index.html
|
|
@ -7,25 +7,64 @@
|
|||
content="width=device-width,initial-scale=1,viewport-fit=cover"
|
||||
/>
|
||||
<meta name="theme-color" content="#10151f" />
|
||||
<meta name="robots" content="index,follow" />
|
||||
<meta name="author" content="Andras Schmelczer" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Fleeting Garden is a joyful WebGPU drawing garden where your coloured paths bloom into moving organic trails."
|
||||
content="Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser."
|
||||
/>
|
||||
|
||||
<link rel="canonical" href="https://schmelczer.dev/fleeting/" />
|
||||
|
||||
<meta property="og:title" content="Fleeting Garden" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Fleeting Garden" />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Pick a vibe, draw coloured paths, and watch them grow into a living WebGPU garden."
|
||||
content="Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser."
|
||||
/>
|
||||
<meta property="og:url" content="https://schmelczer.dev" />
|
||||
<meta property="og:image" content="https://schmelczer.dev/og-image.jpg" />
|
||||
<meta property="og:image:width" content="1920" />
|
||||
<meta property="og:image:height" content="1920" />
|
||||
<meta property="og:url" content="https://schmelczer.dev/fleeting/" />
|
||||
<meta property="og:image" content="https://schmelczer.dev/fleeting/og-image.jpg" />
|
||||
<meta property="og:image:type" content="image/jpeg" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:alt" content="Fleeting Garden social preview image." />
|
||||
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Fleeting Garden" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser."
|
||||
/>
|
||||
<meta name="twitter:image" content="https://schmelczer.dev/fleeting/og-image.jpg" />
|
||||
<meta name="twitter:image:alt" content="Fleeting Garden social preview image." />
|
||||
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebApplication",
|
||||
"name": "Fleeting Garden",
|
||||
"url": "https://schmelczer.dev/fleeting/",
|
||||
"description": "Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser.",
|
||||
"image": "https://schmelczer.dev/fleeting/og-image.jpg",
|
||||
"applicationCategory": "DesignApplication",
|
||||
"operatingSystem": "Any",
|
||||
"browserRequirements": "Requires a browser with WebGPU support.",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "Andras Schmelczer"
|
||||
},
|
||||
"sameAs": "https://github.com/schmelczer/webgpu"
|
||||
}
|
||||
</script>
|
||||
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon-180x180.png" />
|
||||
<link rel="manifest" href="manifest.webmanifest" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-title" content="Fleeting Garden" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
|
||||
<title>Fleeting Garden</title>
|
||||
</head>
|
||||
|
|
@ -41,28 +80,31 @@
|
|||
</canvas>
|
||||
<p id="canvas-description" class="visually-hidden">
|
||||
Fleeting Garden is a pointer-driven WebGPU drawing canvas. Drag or touch the scene
|
||||
to paint coloured paths, then use the toolbar to change colours, erase, adjust the
|
||||
config overlay, export, restart, or open more information.
|
||||
to paint coloured paths, then use the toolbar to change colours, erase, export,
|
||||
adjust the config overlay, restart, or open more information.
|
||||
</p>
|
||||
<div class="garden-grain" aria-hidden="true"></div>
|
||||
<div class="eraser-preview" aria-hidden="true"></div>
|
||||
<div class="garden-prompt" aria-live="polite"></div>
|
||||
|
||||
<div class="loading-indicator" role="status" aria-live="polite">
|
||||
<div class="loading-dots" aria-hidden="true">
|
||||
<span class="loading-dot"></span>
|
||||
<span class="loading-dot"></span>
|
||||
<span class="loading-dot"></span>
|
||||
<div class="loading-indicator" role="status">
|
||||
<div class="splash" data-visible="true">
|
||||
<h1 class="splash-title">Fleeting Garden</h1>
|
||||
<p class="splash-description">
|
||||
Tend it while you can. The garden returns to weather either way.
|
||||
</p>
|
||||
<button class="start-button" type="button" disabled>Start</button>
|
||||
</div>
|
||||
<div class="loading-status">Starting up…</div>
|
||||
<div
|
||||
class="loading-progress"
|
||||
role="progressbar"
|
||||
aria-label="Loading Fleeting Garden"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-valuenow="0"
|
||||
>
|
||||
<div class="loading-progress-fill"></div>
|
||||
<div class="loading-bar" data-visible="false" aria-hidden="true" inert>
|
||||
<div class="loading-status">Starting up…</div>
|
||||
<div
|
||||
class="loading-progress"
|
||||
role="progressbar"
|
||||
aria-label="Loading Fleeting Garden"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-valuenow="0"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -72,26 +114,36 @@
|
|||
</main>
|
||||
|
||||
<aside class="control-dock">
|
||||
<section id="info-panel" class="pages hidden info-page" aria-hidden="true" inert>
|
||||
<section
|
||||
id="info-panel"
|
||||
class="hidden info-page"
|
||||
role="region"
|
||||
aria-label="About panel"
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
inert
|
||||
>
|
||||
<section>
|
||||
<h1>Fleeting Garden</h1>
|
||||
<p>
|
||||
A living sketchpad where each stroke becomes a trail that agents follow,
|
||||
branch from, and weave into the scene.
|
||||
A garden is what we tend; the wild is what we get the moment we look away.
|
||||
Both happen here at once. Your strokes plant colour, small agents follow them,
|
||||
branch off, and slowly rewrite the patch you laid down into something you
|
||||
didn't quite plan.
|
||||
</p>
|
||||
<p>
|
||||
Paint with the three colour swatches, carve space with the eraser, and raise
|
||||
the mirror control when you want radial patterns instead of a single line.
|
||||
Three swatches plant the line. The eraser carves a clearing. The mirror folds
|
||||
one gesture into many, like footpaths around a hidden well.
|
||||
</p>
|
||||
<p>
|
||||
Switch vibes to recolour the whole garden without clearing your drawing. Add
|
||||
or mute the generated piano, restart for a blank canvas, or export the current
|
||||
frame as a 4K image.
|
||||
Switch vibes to change the season; your shapes stay, the light moves. Add or
|
||||
quiet the piano. Restart when you want a fresh field. Take a snapshot if you
|
||||
want to keep one particular instant of weather.
|
||||
</p>
|
||||
<p>
|
||||
Built with WebGPU and running locally in your browser. Source on
|
||||
<a href="https://github.com/schmelczer/webgpu" target="_blank" rel="noopener"
|
||||
>GitHub</a
|
||||
Built with WebGPU, running locally in your browser. More of my work at
|
||||
<a href="https://schmelczer.dev" target="_blank" rel="noopener"
|
||||
>schmelczer.dev</a
|
||||
>.
|
||||
</p>
|
||||
</section>
|
||||
|
|
@ -108,7 +160,7 @@
|
|||
|
||||
<div class="toolbar-shell">
|
||||
<section class="garden-controls" aria-label="Garden controls">
|
||||
<div class="swatches" aria-label="Drawing colours">
|
||||
<div class="swatches" role="group" aria-label="Drawing colours">
|
||||
<button
|
||||
class="color-swatch"
|
||||
aria-label="Draw colour 1"
|
||||
|
|
@ -125,72 +177,67 @@
|
|||
title="Draw colour 3"
|
||||
></button>
|
||||
<label class="eraser-size-control" title="Erase and resize">
|
||||
<input
|
||||
class="eraser-size-slider"
|
||||
type="range"
|
||||
min="24"
|
||||
max="240"
|
||||
step="1"
|
||||
aria-label="Eraser size"
|
||||
/>
|
||||
<input class="eraser-size-slider" type="range" aria-label="Eraser size" />
|
||||
</label>
|
||||
<label class="mirror-segment-control" title="Mirror off">
|
||||
<input
|
||||
class="mirror-segment-slider"
|
||||
type="range"
|
||||
min="1"
|
||||
max="12"
|
||||
step="1"
|
||||
aria-label="Mirror segments"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<nav class="buttons" aria-label="App controls">
|
||||
<button
|
||||
class="info"
|
||||
aria-label="About"
|
||||
aria-controls="info-panel"
|
||||
aria-expanded="false"
|
||||
title="About"
|
||||
></button>
|
||||
<button
|
||||
class="maximize-full-screen"
|
||||
aria-label="Enter fullscreen"
|
||||
title="Enter fullscreen"
|
||||
></button>
|
||||
<button
|
||||
class="minimize-full-screen"
|
||||
aria-label="Exit fullscreen"
|
||||
hidden
|
||||
title="Exit fullscreen"
|
||||
></button>
|
||||
<button
|
||||
class="settings"
|
||||
aria-label="Show config overlay"
|
||||
aria-expanded="false"
|
||||
title="Show config overlay"
|
||||
></button>
|
||||
<nav class="buttons" aria-label="App controls">
|
||||
<button
|
||||
class="info"
|
||||
data-control="info"
|
||||
aria-label="About"
|
||||
aria-controls="info-panel"
|
||||
aria-expanded="false"
|
||||
title="About"
|
||||
></button>
|
||||
<button
|
||||
class="full-screen-toggle"
|
||||
data-control="full-screen"
|
||||
aria-label="Enter fullscreen"
|
||||
title="Enter fullscreen"
|
||||
></button>
|
||||
<button
|
||||
class="settings"
|
||||
data-control="settings"
|
||||
aria-label="Show config overlay"
|
||||
aria-expanded="false"
|
||||
title="Show config overlay"
|
||||
></button>
|
||||
<div class="audio-control">
|
||||
<button
|
||||
class="sound"
|
||||
data-control="sound"
|
||||
aria-label="Mute audio"
|
||||
aria-pressed="false"
|
||||
title="Mute audio"
|
||||
></button>
|
||||
<button
|
||||
class="export-4k"
|
||||
aria-label="Download 4K upscale image"
|
||||
title="Download 4K upscale of the live simulation"
|
||||
></button>
|
||||
<span class="export-status" aria-live="polite"></span>
|
||||
<button
|
||||
class="restart"
|
||||
aria-label="Restart simulation"
|
||||
title="Restart simulation"
|
||||
></button>
|
||||
</nav>
|
||||
</div>
|
||||
<label class="volume-control" title="Master volume">
|
||||
<input class="volume-slider" type="range" aria-label="Master volume" />
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
class="export-4k"
|
||||
data-control="export"
|
||||
aria-label="Download internal buffer snapshot"
|
||||
title="Download internal buffer snapshot"
|
||||
></button>
|
||||
<span class="export-status" aria-live="polite"></span>
|
||||
<button
|
||||
class="restart"
|
||||
data-control="restart"
|
||||
aria-label="Restart simulation"
|
||||
title="Restart simulation"
|
||||
></button>
|
||||
</nav>
|
||||
|
||||
<button class="next-vibe vibe-button" aria-label="Next vibe" title="Next vibe">
|
||||
›
|
||||
|
|
|
|||
1069
package-lock.json
generated
31
package.json
|
|
@ -3,25 +3,23 @@
|
|||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "A WebGPU drawing garden where coloured paths grow into organic agent trails.",
|
||||
"description": "Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser.",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "npm run lint:check",
|
||||
"lint:check": "eslint --rule \"prettier/prettier: off\" \"src/**/*.ts\" && npm run unused:check",
|
||||
"lint:fix": "eslint --fix \"src/**/*.ts\"",
|
||||
"format": "prettier --write \"index.html\" \"src/**/*.{ts,scss,json,html}\" \"scripts/**/*.mjs\" \"e2e/**/*.ts\" \".forgejo/workflows/*.yml\" \"*.{json,js,ts,md}\" \".prettierrc\"",
|
||||
"format:check": "prettier --check \"index.html\" \"src/**/*.{ts,scss,json,html}\" \"scripts/**/*.mjs\" \"e2e/**/*.ts\" \".forgejo/workflows/*.yml\" \"*.{json,js,ts,md}\" \".prettierrc\"",
|
||||
"lint:check": "eslint . && npm run unused:check",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"format": "prettier --write \"index.html\" \"public/manifest.webmanifest\" \"src/**/*.{ts,scss,json,html}\" \"e2e/**/*.ts\" \".forgejo/workflows/*.yml\" \"*.{json,js,ts,md}\" \".prettierrc\"",
|
||||
"format:check": "prettier --check \"index.html\" \"public/manifest.webmanifest\" \"src/**/*.{ts,scss,json,html}\" \"e2e/**/*.ts\" \".forgejo/workflows/*.yml\" \"*.{json,js,ts,md}\" \".prettierrc\"",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck:e2e": "tsc --noEmit --project tsconfig.playwright.json",
|
||||
"test": "vitest run",
|
||||
"test:e2e": "npm run build && playwright test",
|
||||
"test:e2e:ui": "npm run build && playwright test --ui",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:watch": "vitest",
|
||||
"unused:check": "node scripts/check-unused-exports.mjs",
|
||||
"generate-icons": "pwa-assets-generator",
|
||||
"update": "ncu"
|
||||
"unused:check": "knip --production --files --dependencies && knip --exports --include-entry-exports",
|
||||
"generate-icons": "pwa-assets-generator"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
|
@ -41,6 +39,11 @@
|
|||
"browserslist": [
|
||||
"supports webgpu and last 2 years"
|
||||
],
|
||||
"knip": {
|
||||
"ignoreFiles": [
|
||||
"pwa-assets.config.ts"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@ianvs/prettier-plugin-sort-imports": "^4.7.1",
|
||||
|
|
@ -51,14 +54,13 @@
|
|||
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
||||
"@webgpu/types": "^0.1.69",
|
||||
"browserslist": "^4.28.2",
|
||||
"browserslist-to-esbuild": "^2.1.1",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"eslint-plugin-unused-imports": "^4.4.1",
|
||||
"gl-matrix": "^3.4.4",
|
||||
"globals": "^17.6.0",
|
||||
"knip": "^6.14.1",
|
||||
"lightningcss": "^1.32.0",
|
||||
"npm-check-updates": "^22.1.0",
|
||||
"prettier": "^3.8.3",
|
||||
"sass": "^1.99.0",
|
||||
"typescript": "^6.0.3",
|
||||
|
|
@ -68,6 +70,7 @@
|
|||
"vitest": "^4.1.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@plausible-analytics/tracker": "^0.4.5",
|
||||
"tweakpane": "^4.0.5"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,16 +17,21 @@ export default defineConfig({
|
|||
trace: 'on-first-retry',
|
||||
},
|
||||
webServer: {
|
||||
command: `npm run preview -- --host 127.0.0.1 --port ${port}`,
|
||||
command: `npm run build && npm run preview -- --host 127.0.0.1 --port ${port}`,
|
||||
ignoreHTTPSErrors: true,
|
||||
reuseExistingServer: !isCi,
|
||||
reuseExistingServer: false,
|
||||
timeout: 120_000,
|
||||
url: baseURL,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
launchOptions: {
|
||||
args: ['--enable-unsafe-webgpu'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -1,20 +1,17 @@
|
|||
{
|
||||
"name": "Fleeting Garden",
|
||||
"short_name": "Garden",
|
||||
"description": "A joyful WebGPU drawing garden where coloured paths grow into organic agent trails.",
|
||||
"description": "Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser.",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"display": "fullscreen",
|
||||
"display_override": ["fullscreen", "standalone", "minimal-ui"],
|
||||
"orientation": "any",
|
||||
"background_color": "#10151f",
|
||||
"theme_color": "#10151f",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
"type": "image/svg+xml"
|
||||
},
|
||||
{
|
||||
"src": "pwa-64x64.png",
|
||||
|
|
|
|||
BIN
public/og-image.jpg
Normal file
|
After Width: | Height: | Size: 27 KiB |
|
|
@ -1,2 +1,4 @@
|
|||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://schmelczer.dev/fleeting/sitemap.xml
|
||||
|
|
|
|||
6
public/sitemap.xml
Normal 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>
|
||||
|
|
@ -1,197 +0,0 @@
|
|||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import ts from 'typescript';
|
||||
|
||||
const projectRoot = process.cwd();
|
||||
const sourceRoot = path.join(projectRoot, 'src');
|
||||
|
||||
const toPosix = (value) => value.split(path.sep).join('/');
|
||||
|
||||
const listTypeScriptFiles = (directory) =>
|
||||
readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const entryPath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
return listTypeScriptFiles(entryPath);
|
||||
}
|
||||
return entry.isFile() && entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')
|
||||
? [entryPath]
|
||||
: [];
|
||||
});
|
||||
|
||||
const files = listTypeScriptFiles(sourceRoot);
|
||||
const fileSet = new Set(files.map((file) => path.resolve(file)));
|
||||
|
||||
const resolveModule = (fromFile, specifier) => {
|
||||
if (!specifier.startsWith('.')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const base = path.resolve(path.dirname(fromFile), specifier);
|
||||
const candidates = [
|
||||
`${base}.ts`,
|
||||
path.join(base, 'index.ts'),
|
||||
base.endsWith('.ts') ? base : null,
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
candidates.find((candidate) => existsSync(candidate) && fileSet.has(candidate)) ??
|
||||
null
|
||||
);
|
||||
};
|
||||
|
||||
const exportKey = (file, name) => `${path.resolve(file)}:${name}`;
|
||||
const isExported = (node) =>
|
||||
ts.canHaveModifiers(node) &&
|
||||
(ts.getModifiers(node) ?? []).some(
|
||||
(modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword
|
||||
);
|
||||
const isDefaultExported = (node) =>
|
||||
ts.canHaveModifiers(node) &&
|
||||
(ts.getModifiers(node) ?? []).some(
|
||||
(modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword
|
||||
);
|
||||
|
||||
const exportedDeclarations = new Map();
|
||||
const usedExports = new Set();
|
||||
const wildcardUsedFiles = new Set();
|
||||
|
||||
const markUsed = (fromFile, name) => {
|
||||
usedExports.add(exportKey(fromFile, name));
|
||||
};
|
||||
|
||||
const collectImportUsage = (file, sourceFile) => {
|
||||
sourceFile.forEachChild((node) => {
|
||||
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
|
||||
const resolved = resolveModule(file, node.moduleSpecifier.text);
|
||||
if (!resolved || !node.importClause) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.importClause.name) {
|
||||
markUsed(resolved, 'default');
|
||||
}
|
||||
|
||||
const namedBindings = node.importClause.namedBindings;
|
||||
if (namedBindings && ts.isNamedImports(namedBindings)) {
|
||||
namedBindings.elements.forEach((element) => {
|
||||
markUsed(resolved, (element.propertyName ?? element.name).text);
|
||||
});
|
||||
} else if (namedBindings && ts.isNamespaceImport(namedBindings)) {
|
||||
wildcardUsedFiles.add(path.resolve(resolved));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
ts.isExportDeclaration(node) &&
|
||||
node.moduleSpecifier &&
|
||||
ts.isStringLiteral(node.moduleSpecifier)
|
||||
) {
|
||||
const resolved = resolveModule(file, node.moduleSpecifier.text);
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!node.exportClause) {
|
||||
wildcardUsedFiles.add(path.resolve(resolved));
|
||||
return;
|
||||
}
|
||||
|
||||
if (ts.isNamedExports(node.exportClause)) {
|
||||
node.exportClause.elements.forEach((element) => {
|
||||
markUsed(resolved, (element.propertyName ?? element.name).text);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const collectExportedDeclarations = (file, sourceFile) => {
|
||||
if (file.endsWith('.test.ts')) {
|
||||
return;
|
||||
}
|
||||
|
||||
sourceFile.forEachChild((node) => {
|
||||
if (ts.isVariableStatement(node) && isExported(node)) {
|
||||
node.declarationList.declarations.forEach((declaration) => {
|
||||
if (ts.isIdentifier(declaration.name)) {
|
||||
exportedDeclarations.set(exportKey(file, declaration.name.text), {
|
||||
file,
|
||||
name: declaration.name.text,
|
||||
});
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(ts.isFunctionDeclaration(node) ||
|
||||
ts.isClassDeclaration(node) ||
|
||||
ts.isInterfaceDeclaration(node) ||
|
||||
ts.isTypeAliasDeclaration(node) ||
|
||||
ts.isEnumDeclaration(node)) &&
|
||||
isExported(node)
|
||||
) {
|
||||
if (isDefaultExported(node)) {
|
||||
exportedDeclarations.set(exportKey(file, 'default'), { file, name: 'default' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.name) {
|
||||
exportedDeclarations.set(exportKey(file, node.name.text), {
|
||||
file,
|
||||
name: node.name.text,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
ts.isExportDeclaration(node) &&
|
||||
!node.moduleSpecifier &&
|
||||
node.exportClause &&
|
||||
ts.isNamedExports(node.exportClause)
|
||||
) {
|
||||
node.exportClause.elements.forEach((element) => {
|
||||
exportedDeclarations.set(exportKey(file, element.name.text), {
|
||||
file,
|
||||
name: element.name.text,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const parsedFiles = files.map((file) => ({
|
||||
file,
|
||||
sourceFile: ts.createSourceFile(
|
||||
file,
|
||||
readFileSync(file, 'utf8'),
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
ts.ScriptKind.TS
|
||||
),
|
||||
}));
|
||||
|
||||
parsedFiles.forEach(({ file, sourceFile }) => collectImportUsage(file, sourceFile));
|
||||
parsedFiles.forEach(({ file, sourceFile }) =>
|
||||
collectExportedDeclarations(file, sourceFile)
|
||||
);
|
||||
|
||||
const unusedExports = Array.from(exportedDeclarations.entries())
|
||||
.filter(
|
||||
([key, declaration]) =>
|
||||
!usedExports.has(key) && !wildcardUsedFiles.has(declaration.file)
|
||||
)
|
||||
.map(([, declaration]) => declaration)
|
||||
.sort((left, right) =>
|
||||
`${left.file}:${left.name}`.localeCompare(`${right.file}:${right.name}`)
|
||||
);
|
||||
|
||||
if (unusedExports.length > 0) {
|
||||
console.error('Unused exported declarations found:');
|
||||
unusedExports.forEach(({ file, name }) => {
|
||||
console.error(`- ${toPosix(path.relative(projectRoot, file))}: ${name}`);
|
||||
});
|
||||
process.exitCode = 1;
|
||||
}
|
||||
67
src/analytics.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import {
|
||||
init as plausibleInit,
|
||||
track as plausibleTrack,
|
||||
type PlausibleEventOptions,
|
||||
} from '@plausible-analytics/tracker';
|
||||
|
||||
import type { VibeId } from './vibes';
|
||||
|
||||
let isInitialized = false;
|
||||
|
||||
const track = (eventName: string, options: PlausibleEventOptions = {}) => {
|
||||
try {
|
||||
plausibleTrack(eventName, options);
|
||||
} catch (error) {
|
||||
console.warn(`Could not track analytics event "${eventName}".`, error);
|
||||
}
|
||||
};
|
||||
|
||||
export const initAnalytics = () => {
|
||||
if (isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
plausibleInit({
|
||||
domain: 'schmelczer.dev/floating',
|
||||
endpoint: 'https://stats.schmelczer.dev/status',
|
||||
autoCapturePageviews: true,
|
||||
logging: true,
|
||||
});
|
||||
isInitialized = true;
|
||||
} catch (error) {
|
||||
console.warn('Could not initialize analytics.', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const trackVibeChange = ({
|
||||
vibeId,
|
||||
vibeName,
|
||||
source,
|
||||
}: {
|
||||
vibeId: VibeId;
|
||||
vibeName: string;
|
||||
source: string;
|
||||
}) => {
|
||||
track('Vibe Change', {
|
||||
props: {
|
||||
vibeId,
|
||||
vibeName,
|
||||
source,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const trackStart = () => {
|
||||
track('Start');
|
||||
};
|
||||
|
||||
export const trackExport = ({ vibeId }: { vibeId: VibeId }) => {
|
||||
track('Export', {
|
||||
props: {
|
||||
format: 'png',
|
||||
resolution: 'internal-buffer',
|
||||
vibeId,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -1,71 +1,119 @@
|
|||
import { appConfig } from '../config';
|
||||
|
||||
type GardenAudioChordQuality = 'major' | 'minor';
|
||||
import { DEFAULT_AUDIO_VOLUME } from '../consts';
|
||||
import type { PianoNoteRole } from './garden-audio-types';
|
||||
|
||||
export interface GardenAudioChord {
|
||||
rootOffset: number;
|
||||
quality: GardenAudioChordQuality;
|
||||
quality: 'major' | 'minor';
|
||||
}
|
||||
|
||||
interface GardenAudioColorVoice {
|
||||
scaleDegreeOffset: number;
|
||||
velocityMultiplier: number;
|
||||
panOffset: number;
|
||||
export interface GardenAudioVibeSettings {
|
||||
idleIntensity: number;
|
||||
bpm: number;
|
||||
rampUpIntensity: number;
|
||||
rampUpTime: number;
|
||||
noteLength: number;
|
||||
notePitchOffset: number;
|
||||
brightness: number;
|
||||
}
|
||||
|
||||
export interface GardenAudioVibeProfile {
|
||||
export interface GardenAudioVibeProfile extends GardenAudioVibeSettings {
|
||||
rootMidi: number;
|
||||
scale: Array<number>;
|
||||
brightness: number;
|
||||
delayTimeMultiplier: number;
|
||||
progression: Array<GardenAudioChord>;
|
||||
}
|
||||
|
||||
export interface GardenAudioConfig {
|
||||
masterVolume: number;
|
||||
fadeInSeconds: number;
|
||||
updateRampSeconds: number;
|
||||
highPassFrequencyHz: number;
|
||||
fallbackVibeId: string;
|
||||
compressor: {
|
||||
thresholdDb: number;
|
||||
kneeDb: number;
|
||||
ratio: number;
|
||||
attackSeconds: number;
|
||||
releaseSeconds: number;
|
||||
};
|
||||
delay: {
|
||||
timeSeconds: number;
|
||||
feedback: number;
|
||||
wetGain: number;
|
||||
};
|
||||
piano: {
|
||||
maxVoices: number;
|
||||
gain: number;
|
||||
sustainSeconds: number;
|
||||
sustainLevel: number;
|
||||
releaseSeconds: number;
|
||||
lowpassHz: number;
|
||||
};
|
||||
input: {
|
||||
pressureFallback: number;
|
||||
};
|
||||
rhythm: {
|
||||
bpm: number;
|
||||
stepsPerBeat: number;
|
||||
stepsPerBar: number;
|
||||
lookaheadSeconds: number;
|
||||
speedForFullEnergyPixelsPerSecond: number;
|
||||
sparseActivity: number;
|
||||
};
|
||||
eraser: {
|
||||
minIntervalSeconds: number;
|
||||
noiseGain: number;
|
||||
filterMinHz: number;
|
||||
filterMaxHz: number;
|
||||
};
|
||||
colorVoices: [GardenAudioColorVoice, GardenAudioColorVoice, GardenAudioColorVoice];
|
||||
vibes: Record<string, GardenAudioVibeProfile>;
|
||||
}
|
||||
export const defaultGardenAudioVibeSettings: GardenAudioVibeSettings = {
|
||||
idleIntensity: 0.08,
|
||||
bpm: 74,
|
||||
rampUpIntensity: 0.85,
|
||||
rampUpTime: 0.08,
|
||||
noteLength: 0.42,
|
||||
notePitchOffset: 0,
|
||||
brightness: 1,
|
||||
};
|
||||
|
||||
export const gardenAudioConfig: GardenAudioConfig = appConfig.audio;
|
||||
export const createGardenAudioConfig = () => ({
|
||||
masterVolume: DEFAULT_AUDIO_VOLUME,
|
||||
fadeInSeconds: 0.45,
|
||||
updateRampSeconds: 0.08,
|
||||
delay: {
|
||||
timeSeconds: 0.405,
|
||||
feedback: 0.12,
|
||||
wetGain: 0.044,
|
||||
erasingActivity: 0.12,
|
||||
activityFeedbackWeight: 0.08,
|
||||
feedbackMax: 0.32,
|
||||
feedbackMin: 0.04,
|
||||
outputActivityWeight: 0.5,
|
||||
outputBase: 0.65,
|
||||
outputActivityDuck: 0.28,
|
||||
timeRampSeconds: 0.12,
|
||||
},
|
||||
piano: {
|
||||
maxVoices: 24,
|
||||
gain: 0.48,
|
||||
sustainSeconds: 0.42,
|
||||
sustainLevel: 0.26,
|
||||
releaseSeconds: 0.34,
|
||||
lowpassHz: 7000,
|
||||
gainAttackSeconds: 0.006,
|
||||
lowpassMaxHz: 12000,
|
||||
lowpassMinHz: 1400,
|
||||
sustainBase: 0.45,
|
||||
sustainVelocityRange: 0.55,
|
||||
},
|
||||
rhythm: {
|
||||
idleIntensity: defaultGardenAudioVibeSettings.idleIntensity,
|
||||
bpm: defaultGardenAudioVibeSettings.bpm,
|
||||
stepsPerBeat: 4,
|
||||
stepsPerBar: 16,
|
||||
sparseActivity: 0.055,
|
||||
},
|
||||
eraser: {
|
||||
minIntervalSeconds: 0.12,
|
||||
noiseGain: 0.028,
|
||||
filterMinHz: 650,
|
||||
filterMaxHz: 3600,
|
||||
durationSeconds: 0.08,
|
||||
pan: 0,
|
||||
pianoActivity: 0,
|
||||
},
|
||||
energy: {
|
||||
decaySeconds: 0.9,
|
||||
releaseSeconds: 1.15,
|
||||
strokeDecaySeconds: 0.32,
|
||||
},
|
||||
graph: {
|
||||
pianoBusGains: {
|
||||
pad: 0.86,
|
||||
support: 0.94,
|
||||
texture: 0.88,
|
||||
gesture: 1,
|
||||
brush: 0.9,
|
||||
stinger: 0.92,
|
||||
} satisfies Record<PianoNoteRole, number>,
|
||||
pianoBusActivityDucking: {
|
||||
pad: 0.42,
|
||||
support: 0.18,
|
||||
texture: -0.06,
|
||||
gesture: 0,
|
||||
brush: -0.08,
|
||||
stinger: 0,
|
||||
} satisfies Record<PianoNoteRole, number>,
|
||||
noiseBusGain: 0.72,
|
||||
},
|
||||
input: {
|
||||
fullActivitySpeed: 0.86,
|
||||
activityNoiseFloorSpeed: 0.025,
|
||||
activityCurve: 0.74,
|
||||
activitySoftCeiling: 0.96,
|
||||
activityAttackSeconds: 0.055,
|
||||
activityReleaseSeconds: 0.2,
|
||||
minAudibleDistance: 0.0025,
|
||||
manicActivityThreshold: 0.9,
|
||||
manicReleaseThreshold: 0.76,
|
||||
maniaSmoothingSeconds: 0.12,
|
||||
},
|
||||
});
|
||||
|
||||
export type GardenAudioConfig = ReturnType<typeof createGardenAudioConfig>;
|
||||
|
|
|
|||
|
|
@ -1,49 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { appConfig } from '../config';
|
||||
import { GardenAudioEnergy } from './garden-audio-energy';
|
||||
|
||||
describe('GardenAudioEnergy', () => {
|
||||
it('suspends activity but keeps a fading level when the gesture ends', () => {
|
||||
const energy = new GardenAudioEnergy(appConfig.audioEngine);
|
||||
|
||||
energy.beginGesture(0);
|
||||
energy.recordStroke(0.8, 0.1);
|
||||
energy.update(0.1);
|
||||
energy.update(0.2);
|
||||
|
||||
const levelBeforeLift = energy.getLevel();
|
||||
expect(energy.getActivity()).toBeGreaterThan(0);
|
||||
|
||||
energy.endGesture();
|
||||
|
||||
expect(energy.getActivity()).toBe(0);
|
||||
expect(energy.getLevel()).toBe(levelBeforeLift);
|
||||
energy.update(0.3);
|
||||
expect(energy.getLevel()).toBeLessThan(levelBeforeLift);
|
||||
expect(energy.getLevel()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('uses recent stroke intensity rather than gesture duration alone', () => {
|
||||
const energy = new GardenAudioEnergy(appConfig.audioEngine);
|
||||
|
||||
energy.beginGesture(0);
|
||||
energy.recordStroke(1, 0.1);
|
||||
energy.update(0.1);
|
||||
energy.update(0.2);
|
||||
const activeLevel = energy.getActivity();
|
||||
|
||||
energy.update(1.2);
|
||||
|
||||
expect(energy.getActivity()).toBeLessThan(activeLevel);
|
||||
});
|
||||
|
||||
it('raises activity immediately when a stroke is recorded', () => {
|
||||
const energy = new GardenAudioEnergy(appConfig.audioEngine);
|
||||
|
||||
energy.beginGesture(0);
|
||||
energy.recordStroke(0.12, 0.05);
|
||||
|
||||
expect(energy.getActivity()).toBeGreaterThan(0.09);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
import type { GardenAudioEngineConfig } from '../config';
|
||||
import { clamp01 } from '../utils/clamp';
|
||||
|
||||
const STROKE_IMMEDIATE_ACTIVITY_SCALE = 0.85;
|
||||
import { approach, clamp01 } from '../utils/math';
|
||||
import type { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
|
||||
|
||||
export class GardenAudioEnergy {
|
||||
private isGestureActive = false;
|
||||
|
|
@ -9,7 +7,7 @@ export class GardenAudioEnergy {
|
|||
private targetEnergy = 0;
|
||||
private lastEnergyUpdateAt = 0;
|
||||
|
||||
public constructor(private readonly engineConfig: GardenAudioEngineConfig) {}
|
||||
public constructor(private readonly config: GardenAudioConfig) {}
|
||||
|
||||
public beginGesture(now: number): void {
|
||||
this.isGestureActive = true;
|
||||
|
|
@ -21,17 +19,11 @@ export class GardenAudioEnergy {
|
|||
this.targetEnergy = 0;
|
||||
}
|
||||
|
||||
public recordStroke(strokeEnergy: number, now: number): void {
|
||||
const energy = clamp01(strokeEnergy);
|
||||
this.targetEnergy = Math.max(this.targetEnergy, energy);
|
||||
public recordStroke(strokeEnergy: number, profile: GardenAudioVibeProfile): void {
|
||||
this.targetEnergy = Math.max(this.targetEnergy, strokeEnergy);
|
||||
if (this.isGestureActive) {
|
||||
this.energy = Math.max(this.energy, energy * STROKE_IMMEDIATE_ACTIVITY_SCALE);
|
||||
this.energy = Math.max(this.energy, strokeEnergy * profile.rampUpIntensity);
|
||||
}
|
||||
this.lastEnergyUpdateAt ||= now;
|
||||
}
|
||||
|
||||
public recordEraserStroke(): void {
|
||||
this.targetEnergy = 0;
|
||||
}
|
||||
|
||||
public silence(): void {
|
||||
|
|
@ -39,35 +31,26 @@ export class GardenAudioEnergy {
|
|||
this.energy = 0;
|
||||
}
|
||||
|
||||
public update(now: number): void {
|
||||
public update(now: number, profile: GardenAudioVibeProfile): void {
|
||||
if (this.lastEnergyUpdateAt <= 0) {
|
||||
this.lastEnergyUpdateAt = now;
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsedSeconds = Math.max(0, now - this.lastEnergyUpdateAt);
|
||||
const elapsedSeconds = now - this.lastEnergyUpdateAt;
|
||||
this.lastEnergyUpdateAt = now;
|
||||
this.targetEnergy *= Math.exp(
|
||||
-elapsedSeconds / this.engineConfig.energy.strokeDecaySeconds
|
||||
-elapsedSeconds / this.config.energy.strokeDecaySeconds
|
||||
);
|
||||
|
||||
const target = this.isGestureActive ? this.targetEnergy : 0;
|
||||
let timeConstant = this.engineConfig.energy.decaySeconds;
|
||||
let timeConstant = this.config.energy.decaySeconds;
|
||||
if (!this.isGestureActive) {
|
||||
timeConstant = this.engineConfig.energy.releaseSeconds;
|
||||
timeConstant = this.config.energy.releaseSeconds;
|
||||
} else if (target > this.energy) {
|
||||
timeConstant = this.engineConfig.energy.attackSeconds;
|
||||
timeConstant = profile.rampUpTime;
|
||||
}
|
||||
const amount = 1 - Math.exp(-elapsedSeconds / timeConstant);
|
||||
this.energy += (target - this.energy) * amount;
|
||||
}
|
||||
|
||||
public getActivity(): number {
|
||||
if (!this.isGestureActive) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return this.getLevel();
|
||||
this.energy = approach(this.energy, target, elapsedSeconds, timeConstant);
|
||||
}
|
||||
|
||||
public getLevel(): number {
|
||||
|
|
|
|||
|
|
@ -1,385 +1,75 @@
|
|||
import type { GardenAudioEngineConfig } from '../config';
|
||||
import { clamp, clamp01 } from '../utils/clamp';
|
||||
import type {
|
||||
GardenAudioColorIndex,
|
||||
GardenAudioStroke,
|
||||
GardenAudioTouchDown,
|
||||
} from './garden-audio-types';
|
||||
import { approach, clamp, clamp01, smoothstep } from '../utils/math';
|
||||
import type { GardenAudioConfig } from './garden-audio-config';
|
||||
import type { GardenAudioStrokeMetrics } from './garden-audio-input';
|
||||
|
||||
type GardenAudioGestureMode = 'calm' | 'active' | 'manic' | 'afterglow';
|
||||
|
||||
interface GardenAudioGestureFrame {
|
||||
mode: GardenAudioGestureMode;
|
||||
activity: number;
|
||||
maniaAmount: number;
|
||||
panBias: number;
|
||||
registerBias: number;
|
||||
brightnessBias: number;
|
||||
contour: number;
|
||||
pressure: number;
|
||||
pressureDelta: number;
|
||||
mirrorAmount: number;
|
||||
speedAmount: number;
|
||||
}
|
||||
|
||||
interface GestureSample {
|
||||
at: number;
|
||||
speed: number;
|
||||
acceleration: number;
|
||||
distancePixels: number;
|
||||
turned: boolean;
|
||||
}
|
||||
|
||||
const WINDOW_SECONDS = 0.75;
|
||||
const BIN_SECONDS = 0.05;
|
||||
const MIN_TURN_DEGREES = 55;
|
||||
const MIN_TURN_DISTANCE_PIXELS = 6;
|
||||
|
||||
const DEFAULT_FRAME: GardenAudioGestureFrame = {
|
||||
mode: 'calm',
|
||||
activity: 0,
|
||||
maniaAmount: 0,
|
||||
panBias: 0,
|
||||
registerBias: 0,
|
||||
brightnessBias: 0,
|
||||
contour: 0,
|
||||
pressure: 0,
|
||||
pressureDelta: 0,
|
||||
mirrorAmount: 0,
|
||||
speedAmount: 0,
|
||||
};
|
||||
|
||||
export class GardenAudioGestureState {
|
||||
private readonly samples: Array<GestureSample> = [];
|
||||
private gestureClockSeconds = 0;
|
||||
private isGestureActive = false;
|
||||
private previousPressure = 0;
|
||||
private previousVelocityPixelsPerSecond = 0;
|
||||
private previousVector: [number, number] | null = null;
|
||||
private activity = 0;
|
||||
private maniaAmount = 0;
|
||||
private peakActivity = 0;
|
||||
private lastFrame: GardenAudioGestureFrame = DEFAULT_FRAME;
|
||||
private isManic = false;
|
||||
|
||||
public constructor(
|
||||
private readonly speedForFullEnergyPixelsPerSecond: number,
|
||||
private readonly inputConfig: GardenAudioEngineConfig['input']
|
||||
) {}
|
||||
|
||||
public beginGesture(): void {
|
||||
this.samples.length = 0;
|
||||
this.gestureClockSeconds = 0;
|
||||
this.isGestureActive = true;
|
||||
this.previousPressure = 0;
|
||||
this.previousVelocityPixelsPerSecond = 0;
|
||||
this.previousVector = null;
|
||||
this.maniaAmount = 0;
|
||||
this.peakActivity = 0;
|
||||
this.lastFrame = DEFAULT_FRAME;
|
||||
}
|
||||
|
||||
public endGesture(): GardenAudioGestureFrame {
|
||||
this.isGestureActive = false;
|
||||
this.samples.length = 0;
|
||||
this.previousVector = null;
|
||||
this.previousVelocityPixelsPerSecond = 0;
|
||||
this.maniaAmount = 0;
|
||||
this.lastFrame = {
|
||||
...this.lastFrame,
|
||||
mode: this.peakActivity >= 0.42 ? 'afterglow' : 'calm',
|
||||
activity: 0,
|
||||
maniaAmount: 0,
|
||||
speedAmount: 0,
|
||||
};
|
||||
return this.lastFrame;
|
||||
}
|
||||
|
||||
public recordTouchDown({
|
||||
touch,
|
||||
colorIndex,
|
||||
mirrorAmount,
|
||||
pressure,
|
||||
strength,
|
||||
}: {
|
||||
touch: GardenAudioTouchDown;
|
||||
colorIndex: GardenAudioColorIndex;
|
||||
mirrorAmount: number;
|
||||
pressure: number;
|
||||
strength: number;
|
||||
}): GardenAudioGestureFrame {
|
||||
const spatial = getSpatialBias(touch.position, touch.canvasSize);
|
||||
const normalizedStrength = clamp01(strength);
|
||||
|
||||
this.previousPressure = pressure;
|
||||
this.peakActivity = Math.max(this.peakActivity, normalizedStrength);
|
||||
this.lastFrame = {
|
||||
mode: normalizedStrength >= 0.38 ? 'active' : 'calm',
|
||||
activity: normalizedStrength,
|
||||
maniaAmount: 0,
|
||||
panBias: spatial.panBias,
|
||||
registerBias: spatial.registerBias,
|
||||
brightnessBias: spatial.brightnessBias,
|
||||
contour: colorIndex === 2 ? 0.25 : colorIndex === 0 ? -0.15 : 0,
|
||||
pressure,
|
||||
pressureDelta: 0,
|
||||
mirrorAmount,
|
||||
speedAmount: 0,
|
||||
};
|
||||
|
||||
return this.lastFrame;
|
||||
}
|
||||
public constructor(private readonly inputConfig: GardenAudioConfig['input']) {}
|
||||
|
||||
public recordStroke({
|
||||
stroke,
|
||||
metrics,
|
||||
mirrorAmount,
|
||||
}: {
|
||||
stroke: GardenAudioStroke;
|
||||
metrics: GardenAudioStrokeMetrics;
|
||||
mirrorAmount: number;
|
||||
}): GardenAudioGestureFrame {
|
||||
const elapsedSeconds = this.getElapsedSeconds(stroke);
|
||||
this.gestureClockSeconds += elapsedSeconds;
|
||||
|
||||
const dx = stroke.to[0] - stroke.from[0];
|
||||
const dy = stroke.to[1] - stroke.from[1];
|
||||
const distancePixels = metrics.distancePixels;
|
||||
const speedRatio =
|
||||
metrics.speedPixelsPerSecond /
|
||||
Math.max(1, this.speedForFullEnergyPixelsPerSecond);
|
||||
const speed = smoothstep(0.45, 1.2, speedRatio);
|
||||
const acceleration = smoothstep(
|
||||
3,
|
||||
12,
|
||||
Math.abs(metrics.speedPixelsPerSecond - this.previousVelocityPixelsPerSecond) /
|
||||
(Math.max(1, this.speedForFullEnergyPixelsPerSecond) * elapsedSeconds)
|
||||
const targetActivity = this.getTargetActivity(metrics);
|
||||
const activityTimeConstant =
|
||||
targetActivity > this.activity
|
||||
? this.inputConfig.activityAttackSeconds
|
||||
: this.inputConfig.activityReleaseSeconds;
|
||||
this.activity = approach(
|
||||
this.activity,
|
||||
targetActivity,
|
||||
metrics.elapsedSeconds,
|
||||
activityTimeConstant
|
||||
);
|
||||
const currentVector: [number, number] =
|
||||
distancePixels > 0.001 ? [dx / distancePixels, dy / distancePixels] : [0, 0];
|
||||
const turned = this.getTurned(currentVector, distancePixels, metrics.speedAmount);
|
||||
const spatial = getSpatialBias(stroke.to, stroke.canvasSize);
|
||||
const pressureDelta = clamp(metrics.pressure - this.previousPressure, -1, 1);
|
||||
const contour = distancePixels > 0.001 ? clamp(-dy / distancePixels, -1, 1) : 0;
|
||||
|
||||
if (distancePixels > 0.5) {
|
||||
this.samples.push({
|
||||
at: this.gestureClockSeconds,
|
||||
speed,
|
||||
acceleration,
|
||||
distancePixels,
|
||||
turned,
|
||||
});
|
||||
if (this.activity >= this.inputConfig.manicActivityThreshold) {
|
||||
this.isManic = true;
|
||||
} else if (this.activity <= this.inputConfig.manicReleaseThreshold) {
|
||||
this.isManic = false;
|
||||
}
|
||||
this.trimSamples();
|
||||
|
||||
const features = this.getWindowFeatures();
|
||||
const distanceFeature = smoothstep(10, 90, metrics.distancePixels);
|
||||
const normalIntensity = clamp01(
|
||||
0.1 +
|
||||
features.speed * 0.46 +
|
||||
metrics.pressure * 0.2 +
|
||||
distanceFeature * 0.16 +
|
||||
mirrorAmount * 0.08
|
||||
);
|
||||
const hasKineticChange = features.acceleration > 0.35 || features.turns > 0.35;
|
||||
const maniaGate =
|
||||
!stroke.isErasing &&
|
||||
this.isGestureActive &&
|
||||
this.gestureClockSeconds > 0.2 &&
|
||||
features.pathPixels > 60 &&
|
||||
features.speed > 0.45 &&
|
||||
hasKineticChange;
|
||||
const maniaEvidence = maniaGate
|
||||
? clamp01(
|
||||
features.speed * 0.34 +
|
||||
features.acceleration * 0.26 +
|
||||
features.strokeFrequency * 0.2 +
|
||||
features.turns * 0.2
|
||||
) *
|
||||
(1 + mirrorAmount * 0.22)
|
||||
const maniaTarget = this.isManic
|
||||
? smoothstep(this.inputConfig.manicReleaseThreshold, 1, this.activity)
|
||||
: 0;
|
||||
const maniaTarget = smoothstep(0.55, 0.85, maniaEvidence);
|
||||
const timeConstant = maniaTarget > this.maniaAmount ? 0.12 : 0.65;
|
||||
const maniaMove = 1 - Math.exp(-elapsedSeconds / timeConstant);
|
||||
this.maniaAmount = approach(
|
||||
this.maniaAmount,
|
||||
maniaTarget,
|
||||
metrics.elapsedSeconds,
|
||||
this.inputConfig.maniaSmoothingSeconds
|
||||
);
|
||||
|
||||
this.maniaAmount += (maniaTarget - this.maniaAmount) * maniaMove;
|
||||
this.previousPressure = metrics.pressure;
|
||||
this.previousVelocityPixelsPerSecond = metrics.speedPixelsPerSecond;
|
||||
this.previousVector = currentVector;
|
||||
|
||||
const activity = clamp01(normalIntensity + this.maniaAmount * 0.28);
|
||||
this.peakActivity = Math.max(this.peakActivity, activity);
|
||||
this.lastFrame = {
|
||||
mode: this.getMode(activity, this.maniaAmount),
|
||||
activity,
|
||||
maniaAmount: clamp01(this.maniaAmount),
|
||||
panBias: spatial.panBias,
|
||||
registerBias: spatial.registerBias,
|
||||
brightnessBias: clamp01(
|
||||
spatial.brightnessBias * 0.65 + metrics.pressure * 0.2 + speed * 0.15
|
||||
),
|
||||
contour,
|
||||
pressure: metrics.pressure,
|
||||
pressureDelta,
|
||||
mirrorAmount,
|
||||
speedAmount: metrics.speedAmount,
|
||||
return {
|
||||
activity: this.activity,
|
||||
maniaAmount: this.maniaAmount,
|
||||
};
|
||||
|
||||
return this.lastFrame;
|
||||
}
|
||||
|
||||
public getFrame(): GardenAudioGestureFrame {
|
||||
return this.lastFrame;
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.samples.length = 0;
|
||||
this.gestureClockSeconds = 0;
|
||||
this.isGestureActive = false;
|
||||
this.previousPressure = 0;
|
||||
this.previousVelocityPixelsPerSecond = 0;
|
||||
this.previousVector = null;
|
||||
this.activity = 0;
|
||||
this.maniaAmount = 0;
|
||||
this.peakActivity = 0;
|
||||
this.lastFrame = DEFAULT_FRAME;
|
||||
this.isManic = false;
|
||||
}
|
||||
|
||||
private getElapsedSeconds(stroke: GardenAudioStroke): number {
|
||||
if (
|
||||
stroke.elapsedSeconds !== undefined &&
|
||||
Number.isFinite(stroke.elapsedSeconds) &&
|
||||
stroke.elapsedSeconds > 0
|
||||
) {
|
||||
return clamp(stroke.elapsedSeconds, 0.001, 0.15);
|
||||
}
|
||||
|
||||
return this.inputConfig.fallbackFrameSeconds;
|
||||
}
|
||||
|
||||
private getTurned(
|
||||
currentVector: [number, number],
|
||||
distancePixels: number,
|
||||
speedAmount: number
|
||||
): boolean {
|
||||
if (
|
||||
!this.previousVector ||
|
||||
distancePixels <= MIN_TURN_DISTANCE_PIXELS ||
|
||||
speedAmount <= 0.35
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const dot = clamp(
|
||||
this.previousVector[0] * currentVector[0] +
|
||||
this.previousVector[1] * currentVector[1],
|
||||
-1,
|
||||
1
|
||||
private getTargetActivity(metrics: GardenAudioStrokeMetrics): number {
|
||||
const speedRange =
|
||||
this.inputConfig.fullActivitySpeed - this.inputConfig.activityNoiseFloorSpeed;
|
||||
const speedAmount = clamp01(
|
||||
(metrics.normalizedSpeed - this.inputConfig.activityNoiseFloorSpeed) / speedRange
|
||||
);
|
||||
const degrees = (Math.acos(dot) * 180) / Math.PI;
|
||||
return degrees > MIN_TURN_DEGREES;
|
||||
}
|
||||
const distanceAmount = clamp01(
|
||||
metrics.normalizedDistance / this.inputConfig.minAudibleDistance
|
||||
);
|
||||
const activity = Math.pow(speedAmount, this.inputConfig.activityCurve);
|
||||
|
||||
private trimSamples(): void {
|
||||
const earliest = this.gestureClockSeconds - WINDOW_SECONDS;
|
||||
while (this.samples.length > 0 && this.samples[0].at < earliest) {
|
||||
this.samples.shift();
|
||||
}
|
||||
}
|
||||
|
||||
private getWindowFeatures(): {
|
||||
speed: number;
|
||||
acceleration: number;
|
||||
strokeFrequency: number;
|
||||
turns: number;
|
||||
pathPixels: number;
|
||||
} {
|
||||
if (this.samples.length === 0) {
|
||||
return {
|
||||
speed: 0,
|
||||
acceleration: 0,
|
||||
strokeFrequency: 0,
|
||||
turns: 0,
|
||||
pathPixels: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const first = this.samples[0];
|
||||
const last = this.samples[this.samples.length - 1];
|
||||
const spanSeconds = clamp(last.at - first.at, 0.2, WINDOW_SECONDS);
|
||||
const bins = new Set<number>();
|
||||
let pathPixels = 0;
|
||||
let turnCount = 0;
|
||||
|
||||
this.samples.forEach((sample) => {
|
||||
if (sample.distancePixels > 1) {
|
||||
bins.add(Math.floor(sample.at / BIN_SECONDS));
|
||||
}
|
||||
if (sample.turned) {
|
||||
turnCount += 1;
|
||||
}
|
||||
pathPixels += sample.distancePixels;
|
||||
});
|
||||
|
||||
return {
|
||||
speed: percentile(this.samples.map((sample) => sample.speed), 0.75),
|
||||
acceleration: percentile(
|
||||
this.samples.map((sample) => sample.acceleration),
|
||||
0.75
|
||||
),
|
||||
strokeFrequency: smoothstep(6, 14, bins.size / spanSeconds),
|
||||
turns: smoothstep(2, 7, turnCount / spanSeconds),
|
||||
pathPixels,
|
||||
};
|
||||
}
|
||||
|
||||
private getMode(activity: number, maniaAmount: number): GardenAudioGestureMode {
|
||||
if (maniaAmount >= 0.72) {
|
||||
return 'manic';
|
||||
}
|
||||
|
||||
return activity >= 0.38 ? 'active' : 'calm';
|
||||
return clamp(activity * distanceAmount, 0, this.inputConfig.activitySoftCeiling);
|
||||
}
|
||||
}
|
||||
|
||||
const getSpatialBias = (
|
||||
position: ArrayLike<number> | undefined,
|
||||
canvasSize: ArrayLike<number> | undefined
|
||||
): {
|
||||
panBias: number;
|
||||
registerBias: number;
|
||||
brightnessBias: number;
|
||||
} => {
|
||||
if (!position || !canvasSize) {
|
||||
return {
|
||||
panBias: 0,
|
||||
registerBias: 0,
|
||||
brightnessBias: 0.5,
|
||||
};
|
||||
}
|
||||
|
||||
const width = Math.max(1, canvasSize[0]);
|
||||
const height = Math.max(1, canvasSize[1]);
|
||||
const x = clamp01(position[0] / width);
|
||||
const y = clamp01(position[1] / height);
|
||||
|
||||
return {
|
||||
panBias: clamp(x * 2 - 1, -1, 1),
|
||||
registerBias: clamp(1 - y * 2, -1, 1),
|
||||
brightnessBias: clamp01(1 - y * 0.72),
|
||||
};
|
||||
};
|
||||
|
||||
const percentile = (values: Array<number>, amount: number): number => {
|
||||
if (values.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const index = clamp(Math.floor((sorted.length - 1) * amount), 0, sorted.length - 1);
|
||||
return sorted[index];
|
||||
};
|
||||
|
||||
const smoothstep = (edge0: number, edge1: number, value: number): number => {
|
||||
const amount = clamp01((value - edge0) / (edge1 - edge0));
|
||||
return amount * amount * (3 - 2 * amount);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,23 +1,62 @@
|
|||
import type { GardenAudioEngineConfig } from '../config';
|
||||
import { clamp } from '../utils/clamp';
|
||||
import { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
|
||||
import { clamp } from '../utils/math';
|
||||
import type { GardenAudioConfig } from './garden-audio-config';
|
||||
import type { PianoNoteRole } from './garden-audio-types';
|
||||
|
||||
type NavigatorWithAudioSession = Navigator & {
|
||||
audioSession?: {
|
||||
type:
|
||||
| 'auto'
|
||||
| 'playback'
|
||||
| 'ambient'
|
||||
| 'transient'
|
||||
| 'transient-solo'
|
||||
| 'play-and-record';
|
||||
};
|
||||
};
|
||||
|
||||
const outputHighPassFrequencyHz = 45;
|
||||
const noiseBufferDurationSeconds = 1;
|
||||
const graphTuning = {
|
||||
closeGain: 0.0001,
|
||||
closeRampSeconds: 0.015,
|
||||
delayMaxSeconds: 2,
|
||||
eventBusGain: 1,
|
||||
noiseMax: 1,
|
||||
noiseMin: -1,
|
||||
latencyHint: 'interactive' as AudioContextLatencyCategory,
|
||||
outputFilterType: 'highpass' as BiquadFilterType,
|
||||
compressor: {
|
||||
thresholdDb: -18,
|
||||
kneeDb: 18,
|
||||
ratio: 2.1,
|
||||
attackSeconds: 0.018,
|
||||
releaseSeconds: 0.18,
|
||||
},
|
||||
};
|
||||
const delayFilterTuning = {
|
||||
feedbackHighPassHz: 180,
|
||||
feedbackLowPassHz: 5200,
|
||||
returnLowPassHz: 6200,
|
||||
};
|
||||
|
||||
export class GardenAudioGraph {
|
||||
public context: AudioContext | null = null;
|
||||
public eventBus: GainNode | null = null;
|
||||
public delayInput: GainNode | null = null;
|
||||
public noiseBus: GainNode | null = null;
|
||||
public noiseBuffer: AudioBuffer | null = null;
|
||||
|
||||
private masterGain: GainNode | null = null;
|
||||
private delayNode: DelayNode | null = null;
|
||||
private delayFeedback: GainNode | null = null;
|
||||
private delayOutput: GainNode | null = null;
|
||||
private hasUnlocked = false;
|
||||
private lastPianoBusActivity = 0;
|
||||
private pianoBusGainScale = 1;
|
||||
private pianoBusGainScaleAutomationUntil = 0;
|
||||
private pianoBusGainScaleTimeConstantSeconds = 0;
|
||||
private readonly pianoBuses = new Map<PianoNoteRole, GainNode>();
|
||||
|
||||
public constructor(
|
||||
private readonly config: GardenAudioConfig,
|
||||
private readonly engineConfig: GardenAudioEngineConfig
|
||||
) {}
|
||||
public constructor(private readonly config: GardenAudioConfig) {}
|
||||
|
||||
public ensureContext(canCreate: boolean): AudioContext | null {
|
||||
if (this.context) {
|
||||
|
|
@ -28,19 +67,34 @@ export class GardenAudioGraph {
|
|||
return null;
|
||||
}
|
||||
|
||||
const context = new AudioContext({ latencyHint: 'interactive' });
|
||||
const AudioContextConstructor = globalThis.AudioContext;
|
||||
if (!AudioContextConstructor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Tells iOS to treat this as media playback, so the hardware ringer/mute
|
||||
// switch does not silence Web Audio output. No-op on browsers without the
|
||||
// Audio Session API.
|
||||
const audioSession = (navigator as NavigatorWithAudioSession).audioSession;
|
||||
if (audioSession) {
|
||||
audioSession.type = 'playback';
|
||||
}
|
||||
|
||||
const context = new AudioContextConstructor({
|
||||
latencyHint: graphTuning.latencyHint,
|
||||
});
|
||||
const masterGain = context.createGain();
|
||||
const highPass = context.createBiquadFilter();
|
||||
const compressor = context.createDynamicsCompressor();
|
||||
|
||||
masterGain.gain.value = 0;
|
||||
highPass.type = 'highpass';
|
||||
highPass.frequency.value = this.config.highPassFrequencyHz;
|
||||
compressor.threshold.value = this.config.compressor.thresholdDb;
|
||||
compressor.knee.value = this.config.compressor.kneeDb;
|
||||
compressor.ratio.value = this.config.compressor.ratio;
|
||||
compressor.attack.value = this.config.compressor.attackSeconds;
|
||||
compressor.release.value = this.config.compressor.releaseSeconds;
|
||||
highPass.type = graphTuning.outputFilterType;
|
||||
highPass.frequency.value = outputHighPassFrequencyHz;
|
||||
compressor.threshold.value = graphTuning.compressor.thresholdDb;
|
||||
compressor.knee.value = graphTuning.compressor.kneeDb;
|
||||
compressor.ratio.value = graphTuning.compressor.ratio;
|
||||
compressor.attack.value = graphTuning.compressor.attackSeconds;
|
||||
compressor.release.value = graphTuning.compressor.releaseSeconds;
|
||||
|
||||
masterGain.connect(highPass);
|
||||
highPass.connect(compressor);
|
||||
|
|
@ -55,26 +109,6 @@ export class GardenAudioGraph {
|
|||
return context;
|
||||
}
|
||||
|
||||
// iOS WebKit (Safari + Chrome iOS) only fully unlocks audio output once
|
||||
// a buffer source has been started inside a user-gesture handler. Calling
|
||||
// resume() alone leaves the context "running" but silent.
|
||||
public unlock(): void {
|
||||
if (!this.context || this.hasUnlocked) {
|
||||
return;
|
||||
}
|
||||
|
||||
const buffer = this.context.createBuffer(
|
||||
1,
|
||||
this.engineConfig.graph.unlockBufferLength,
|
||||
this.engineConfig.graph.unlockSampleRate
|
||||
);
|
||||
const source = this.context.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.connect(this.context.destination);
|
||||
source.start(0);
|
||||
this.hasUnlocked = true;
|
||||
}
|
||||
|
||||
public setMasterGain(targetGain: number, timeConstantSeconds: number): void {
|
||||
if (!this.context || !this.masterGain) {
|
||||
return;
|
||||
|
|
@ -87,46 +121,67 @@ export class GardenAudioGraph {
|
|||
);
|
||||
}
|
||||
|
||||
public applyDelayProfile(profile: GardenAudioVibeProfile): void {
|
||||
public applyDelayProfile(): void {
|
||||
if (!this.context || !this.delayNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.delayNode.delayTime.setTargetAtTime(
|
||||
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
|
||||
this.config.delay.timeSeconds,
|
||||
this.context.currentTime,
|
||||
this.engineConfig.graph.delayTimeRampSeconds
|
||||
this.config.delay.timeRampSeconds
|
||||
);
|
||||
}
|
||||
|
||||
public updateDelay(profile: GardenAudioVibeProfile, activity: number): void {
|
||||
public updateDelay(activity: number): void {
|
||||
if (!this.context || !this.delayNode || !this.delayFeedback || !this.delayOutput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = this.context.currentTime;
|
||||
const normalizedActivity = clamp(activity, 0, 1);
|
||||
this.delayNode.delayTime.setTargetAtTime(
|
||||
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
|
||||
this.config.delay.timeSeconds,
|
||||
now,
|
||||
this.engineConfig.graph.delayTimeRampSeconds
|
||||
this.config.delay.timeRampSeconds
|
||||
);
|
||||
this.delayFeedback.gain.setTargetAtTime(
|
||||
clamp(
|
||||
this.config.delay.feedback +
|
||||
activity * this.engineConfig.graph.delayActivityFeedbackWeight,
|
||||
this.engineConfig.graph.delayFeedbackMin,
|
||||
this.engineConfig.graph.delayFeedbackMax
|
||||
normalizedActivity * this.config.delay.activityFeedbackWeight,
|
||||
this.config.delay.feedbackMin,
|
||||
this.config.delay.feedbackMax
|
||||
),
|
||||
now,
|
||||
this.config.updateRampSeconds
|
||||
);
|
||||
this.delayOutput.gain.setTargetAtTime(
|
||||
this.config.delay.wetGain *
|
||||
(this.engineConfig.graph.delayOutputBase +
|
||||
activity * this.engineConfig.graph.delayOutputActivityWeight),
|
||||
(this.config.delay.outputBase +
|
||||
normalizedActivity * this.config.delay.outputActivityWeight) *
|
||||
(1 - normalizedActivity * this.config.delay.outputActivityDuck),
|
||||
now,
|
||||
this.config.updateRampSeconds
|
||||
);
|
||||
this.updatePianoBusGains(normalizedActivity, now);
|
||||
}
|
||||
|
||||
public getPianoBus(role: PianoNoteRole | undefined): GainNode | null {
|
||||
return this.pianoBuses.get(role ?? 'gesture') ?? this.eventBus;
|
||||
}
|
||||
|
||||
public setPianoBusGainScale(targetScale: number, timeConstantSeconds: number): void {
|
||||
if (!this.context) {
|
||||
this.pianoBusGainScale = clamp(targetScale, 0, 1);
|
||||
return;
|
||||
}
|
||||
|
||||
const now = this.context.currentTime;
|
||||
|
||||
this.pianoBusGainScale = clamp(targetScale, 0, 1);
|
||||
this.pianoBusGainScaleTimeConstantSeconds = timeConstantSeconds;
|
||||
this.pianoBusGainScaleAutomationUntil = now + timeConstantSeconds * 4;
|
||||
this.updatePianoBusGains(this.lastPianoBusActivity, now, timeConstantSeconds);
|
||||
}
|
||||
|
||||
public async close(): Promise<void> {
|
||||
|
|
@ -137,9 +192,9 @@ export class GardenAudioGraph {
|
|||
|
||||
if (this.masterGain && context.state !== 'closed') {
|
||||
this.masterGain.gain.setTargetAtTime(
|
||||
this.engineConfig.graph.closeGain,
|
||||
graphTuning.closeGain,
|
||||
context.currentTime,
|
||||
this.engineConfig.graph.closeRampSeconds
|
||||
graphTuning.closeRampSeconds
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -152,18 +207,30 @@ export class GardenAudioGraph {
|
|||
|
||||
private createDelay(context: AudioContext, masterGain: GainNode): void {
|
||||
const delayInput = context.createGain();
|
||||
const delayNode = context.createDelay(2);
|
||||
const delayNode = context.createDelay(graphTuning.delayMaxSeconds);
|
||||
const delayFeedback = context.createGain();
|
||||
const delayOutput = context.createGain();
|
||||
const feedbackHighPass = context.createBiquadFilter();
|
||||
const feedbackLowPass = context.createBiquadFilter();
|
||||
const returnLowPass = context.createBiquadFilter();
|
||||
|
||||
delayNode.delayTime.value = this.config.delay.timeSeconds;
|
||||
delayFeedback.gain.value = this.config.delay.feedback;
|
||||
delayOutput.gain.value = this.config.delay.wetGain;
|
||||
feedbackHighPass.type = 'highpass';
|
||||
feedbackHighPass.frequency.value = delayFilterTuning.feedbackHighPassHz;
|
||||
feedbackLowPass.type = 'lowpass';
|
||||
feedbackLowPass.frequency.value = delayFilterTuning.feedbackLowPassHz;
|
||||
returnLowPass.type = 'lowpass';
|
||||
returnLowPass.frequency.value = delayFilterTuning.returnLowPassHz;
|
||||
|
||||
delayInput.connect(delayNode);
|
||||
delayNode.connect(delayFeedback);
|
||||
delayNode.connect(feedbackHighPass);
|
||||
feedbackHighPass.connect(feedbackLowPass);
|
||||
feedbackLowPass.connect(delayFeedback);
|
||||
delayFeedback.connect(delayNode);
|
||||
delayNode.connect(delayOutput);
|
||||
delayNode.connect(returnLowPass);
|
||||
returnLowPass.connect(delayOutput);
|
||||
delayOutput.connect(masterGain);
|
||||
|
||||
this.delayInput = delayInput;
|
||||
|
|
@ -173,20 +240,61 @@ export class GardenAudioGraph {
|
|||
}
|
||||
|
||||
private createBuses(context: AudioContext, masterGain: GainNode): void {
|
||||
this.eventBus = context.createGain();
|
||||
this.eventBus.gain.value = this.engineConfig.graph.eventBusGain;
|
||||
this.eventBus.connect(masterGain);
|
||||
const eventBus = context.createGain();
|
||||
eventBus.gain.value = graphTuning.eventBusGain;
|
||||
eventBus.connect(masterGain);
|
||||
this.eventBus = eventBus;
|
||||
this.pianoBuses.clear();
|
||||
|
||||
(Object.keys(this.config.graph.pianoBusGains) as Array<PianoNoteRole>).forEach(
|
||||
(role) => {
|
||||
const bus = context.createGain();
|
||||
bus.gain.value = this.config.graph.pianoBusGains[role];
|
||||
bus.connect(eventBus);
|
||||
this.pianoBuses.set(role, bus);
|
||||
}
|
||||
);
|
||||
|
||||
this.noiseBus = context.createGain();
|
||||
this.noiseBus.gain.value = this.config.graph.noiseBusGain;
|
||||
this.noiseBus.connect(eventBus);
|
||||
}
|
||||
|
||||
private updatePianoBusGains(
|
||||
activity: number,
|
||||
now: number,
|
||||
timeConstantSeconds?: number
|
||||
): void {
|
||||
const effectiveTimeConstantSeconds =
|
||||
timeConstantSeconds ??
|
||||
(now < this.pianoBusGainScaleAutomationUntil
|
||||
? this.pianoBusGainScaleTimeConstantSeconds
|
||||
: this.config.updateRampSeconds);
|
||||
|
||||
this.lastPianoBusActivity = activity;
|
||||
this.pianoBuses.forEach((bus, role) => {
|
||||
const baseGain = this.config.graph.pianoBusGains[role];
|
||||
const ducking = this.config.graph.pianoBusActivityDucking[role];
|
||||
bus.gain.setTargetAtTime(
|
||||
Math.max(0, baseGain * (1 - activity * ducking) * this.pianoBusGainScale),
|
||||
now,
|
||||
effectiveTimeConstantSeconds
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private createNoiseBuffer(context: AudioContext): AudioBuffer {
|
||||
const buffer = context.createBuffer(1, context.sampleRate, context.sampleRate);
|
||||
const buffer = context.createBuffer(
|
||||
1,
|
||||
Math.floor(context.sampleRate * noiseBufferDurationSeconds),
|
||||
context.sampleRate
|
||||
);
|
||||
const data = buffer.getChannelData(0);
|
||||
|
||||
for (let index = 0; index < data.length; index++) {
|
||||
data[index] =
|
||||
this.engineConfig.graph.noiseMin +
|
||||
Math.random() *
|
||||
(this.engineConfig.graph.noiseMax - this.engineConfig.graph.noiseMin);
|
||||
graphTuning.noiseMin +
|
||||
Math.random() * (graphTuning.noiseMax - graphTuning.noiseMin);
|
||||
}
|
||||
|
||||
return buffer;
|
||||
|
|
@ -196,11 +304,16 @@ export class GardenAudioGraph {
|
|||
this.context = null;
|
||||
this.eventBus = null;
|
||||
this.delayInput = null;
|
||||
this.noiseBus = null;
|
||||
this.noiseBuffer = null;
|
||||
this.masterGain = null;
|
||||
this.delayNode = null;
|
||||
this.delayFeedback = null;
|
||||
this.delayOutput = null;
|
||||
this.hasUnlocked = false;
|
||||
this.lastPianoBusActivity = 0;
|
||||
this.pianoBusGainScale = 1;
|
||||
this.pianoBusGainScaleAutomationUntil = 0;
|
||||
this.pianoBusGainScaleTimeConstantSeconds = 0;
|
||||
this.pianoBuses.clear();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,77 +1,27 @@
|
|||
import type { GardenAudioEngineConfig } from '../config';
|
||||
import { clamp01 } from '../utils/clamp';
|
||||
import { GardenAudioStroke } from './garden-audio-types';
|
||||
import type { GardenAudioStroke } from './garden-audio-types';
|
||||
|
||||
const minElapsedSeconds = 0.001;
|
||||
|
||||
export interface GardenAudioStrokeMetrics {
|
||||
distancePixels: number;
|
||||
pressure: number;
|
||||
speedPixelsPerSecond: number;
|
||||
speedAmount: number;
|
||||
effectiveEnergy: number;
|
||||
elapsedSeconds: number;
|
||||
normalizedDistance: number;
|
||||
normalizedSpeed: number;
|
||||
}
|
||||
|
||||
export const getStrokeMetrics = (
|
||||
stroke: GardenAudioStroke,
|
||||
speedForFullEnergyPixelsPerSecond: number,
|
||||
fallbackPressure: number,
|
||||
inputConfig: GardenAudioEngineConfig['input']
|
||||
): GardenAudioStrokeMetrics => {
|
||||
export const getStrokeMetrics = (stroke: GardenAudioStroke): GardenAudioStrokeMetrics => {
|
||||
const dx = stroke.to[0] - stroke.from[0];
|
||||
const dy = stroke.to[1] - stroke.from[1];
|
||||
const distancePixels = Math.hypot(dx, dy);
|
||||
const speedPixelsPerSecond = getStrokeVelocity(stroke, distancePixels, inputConfig);
|
||||
const pressure = getPressureAmount(stroke, fallbackPressure, inputConfig);
|
||||
const speedAmount = clamp01(speedPixelsPerSecond / speedForFullEnergyPixelsPerSecond);
|
||||
const strokeEnergy = clamp01(
|
||||
inputConfig.strokeEnergyBase +
|
||||
speedAmount * inputConfig.strokeEnergySpeedWeight +
|
||||
pressure * inputConfig.strokeEnergyPressureWeight
|
||||
const elapsedSeconds = Math.max(minElapsedSeconds, stroke.elapsedSeconds ?? 0);
|
||||
const normalizationPixels = Math.max(
|
||||
1,
|
||||
Math.min(stroke.canvasSize[0], stroke.canvasSize[1])
|
||||
);
|
||||
const effectiveEnergy =
|
||||
strokeEnergy *
|
||||
(inputConfig.distanceEnergyBase +
|
||||
clamp01(distancePixels / inputConfig.distanceForFullEnergyPixels) *
|
||||
inputConfig.distanceEnergyScale);
|
||||
const normalizedDistance = distancePixels / normalizationPixels;
|
||||
|
||||
return {
|
||||
distancePixels,
|
||||
pressure,
|
||||
speedPixelsPerSecond,
|
||||
speedAmount,
|
||||
effectiveEnergy,
|
||||
elapsedSeconds,
|
||||
normalizedDistance,
|
||||
normalizedSpeed: normalizedDistance / elapsedSeconds,
|
||||
};
|
||||
};
|
||||
|
||||
const getStrokeVelocity = (
|
||||
stroke: GardenAudioStroke,
|
||||
distancePixels: number,
|
||||
inputConfig: GardenAudioEngineConfig['input']
|
||||
): number => {
|
||||
if (
|
||||
stroke.velocityPixelsPerSecond !== undefined &&
|
||||
Number.isFinite(stroke.velocityPixelsPerSecond) &&
|
||||
stroke.velocityPixelsPerSecond >= 0
|
||||
) {
|
||||
return stroke.velocityPixelsPerSecond;
|
||||
}
|
||||
|
||||
return distancePixels / inputConfig.fallbackFrameSeconds;
|
||||
};
|
||||
|
||||
const getPressureAmount = (
|
||||
stroke: GardenAudioStroke,
|
||||
fallbackPressure: number,
|
||||
inputConfig: GardenAudioEngineConfig['input']
|
||||
): number => {
|
||||
if (
|
||||
stroke.pressure !== undefined &&
|
||||
Number.isFinite(stroke.pressure) &&
|
||||
stroke.pressure > 0
|
||||
) {
|
||||
return clamp01(stroke.pressure);
|
||||
}
|
||||
|
||||
return stroke.pointerType === 'pen'
|
||||
? Math.max(inputConfig.penMinPressure, clamp01(fallbackPressure))
|
||||
: clamp01(fallbackPressure);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,39 +1,34 @@
|
|||
import { VibePreset } from '../vibes';
|
||||
import {
|
||||
GardenAudioChord,
|
||||
GardenAudioConfig,
|
||||
GardenAudioVibeProfile,
|
||||
} from './garden-audio-config';
|
||||
import { GardenAudioColorIndex } from './garden-audio-types';
|
||||
import type { VibePreset } from '../vibes';
|
||||
import type { GardenAudioChord, GardenAudioVibeProfile } from './garden-audio-config';
|
||||
|
||||
export const normalizeColorIndex = (index: number): GardenAudioColorIndex =>
|
||||
Math.max(0, Math.min(2, Math.round(index))) as GardenAudioColorIndex;
|
||||
export const PITCH_SEMITONES_PER_OCTAVE = 12;
|
||||
|
||||
export const getVibeProfile = (
|
||||
config: GardenAudioConfig,
|
||||
vibe: VibePreset
|
||||
): GardenAudioVibeProfile =>
|
||||
config.vibes[vibe.id] ??
|
||||
config.vibes[config.fallbackVibeId] ??
|
||||
Object.values(config.vibes)[0];
|
||||
const DEFAULT_PROGRESSION: ReadonlyArray<GardenAudioChord> = [
|
||||
{ rootOffset: 0, quality: 'major' },
|
||||
{ rootOffset: 9, quality: 'minor' },
|
||||
{ rootOffset: 5, quality: 'major' },
|
||||
{ rootOffset: 7, quality: 'major' },
|
||||
];
|
||||
|
||||
export const getChordIntervals = (
|
||||
chord: GardenAudioChord,
|
||||
openVoicing: boolean
|
||||
): Array<number> => {
|
||||
if (openVoicing) {
|
||||
return chord.quality === 'major' ? [0, 7, 12, 16] : [0, 7, 12, 15];
|
||||
const DEFAULT_ROOT_MIDI = 57;
|
||||
const DEFAULT_SCALE: ReadonlyArray<number> = [0, 2, 4, 7, 9];
|
||||
|
||||
const profileCache = new WeakMap<VibePreset, GardenAudioVibeProfile>();
|
||||
|
||||
export const getVibeProfile = (vibe: VibePreset): GardenAudioVibeProfile => {
|
||||
let profile = profileCache.get(vibe);
|
||||
if (!profile) {
|
||||
profile = {
|
||||
...vibe.audio,
|
||||
rootMidi: DEFAULT_ROOT_MIDI + vibe.audio.notePitchOffset,
|
||||
scale: DEFAULT_SCALE as Array<number>,
|
||||
progression: DEFAULT_PROGRESSION as Array<GardenAudioChord>,
|
||||
};
|
||||
profileCache.set(vibe, profile);
|
||||
return profile;
|
||||
}
|
||||
|
||||
return chord.quality === 'major' ? [0, 4, 7, 12, 16] : [0, 3, 7, 12, 15];
|
||||
};
|
||||
|
||||
export const degreeToSemitone = (
|
||||
profile: GardenAudioVibeProfile,
|
||||
degree: number
|
||||
): number => {
|
||||
const scaleIndex =
|
||||
((degree % profile.scale.length) + profile.scale.length) % profile.scale.length;
|
||||
const octave = Math.floor(degree / profile.scale.length);
|
||||
return profile.scale[scaleIndex] + octave * 12;
|
||||
Object.assign(profile, vibe.audio);
|
||||
profile.rootMidi = DEFAULT_ROOT_MIDI + vibe.audio.notePitchOffset;
|
||||
return profile;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,12 +1,8 @@
|
|||
import { VibePreset } from '../vibes';
|
||||
|
||||
export type GardenAudioColorIndex = 0 | 1 | 2;
|
||||
|
||||
export interface GardenAudioSnapshot {
|
||||
vibe: VibePreset;
|
||||
selectedColorIndex: number;
|
||||
isErasing: boolean;
|
||||
mirrorSegmentCount?: number;
|
||||
}
|
||||
|
||||
export interface GardenAudioStroke {
|
||||
|
|
@ -14,28 +10,8 @@ export interface GardenAudioStroke {
|
|||
from: ArrayLike<number>;
|
||||
to: ArrayLike<number>;
|
||||
canvasSize: ArrayLike<number>;
|
||||
colorIndex: number;
|
||||
isErasing: boolean;
|
||||
pressure?: number;
|
||||
velocityPixelsPerSecond?: number;
|
||||
elapsedSeconds?: number;
|
||||
eraserSizePixels?: number;
|
||||
mirrorSegmentCount?: number;
|
||||
pointerType?: string;
|
||||
}
|
||||
|
||||
export interface GardenAudioTouchDown {
|
||||
vibe: VibePreset;
|
||||
colorIndex: number;
|
||||
position?: ArrayLike<number>;
|
||||
canvasSize?: ArrayLike<number>;
|
||||
mirrorSegmentCount?: number;
|
||||
pressure?: number;
|
||||
pointerType?: string;
|
||||
}
|
||||
|
||||
export interface GardenAudioStartOptions {
|
||||
userGesture?: boolean;
|
||||
elapsedSeconds: number;
|
||||
}
|
||||
|
||||
export interface LoadedPianoSample {
|
||||
|
|
@ -43,23 +19,26 @@ export interface LoadedPianoSample {
|
|||
buffer: AudioBuffer;
|
||||
}
|
||||
|
||||
export interface ActivePianoVoice {
|
||||
gain: GainNode;
|
||||
source: AudioBufferSourceNode;
|
||||
startAt: number;
|
||||
stopAt: number;
|
||||
}
|
||||
|
||||
export interface PianoNote {
|
||||
midi: number;
|
||||
velocity: number;
|
||||
startTime: number;
|
||||
durationSeconds: number;
|
||||
pan: number;
|
||||
role?: PianoNoteRole;
|
||||
delaySend?: number;
|
||||
lowpassHz?: number;
|
||||
sustainSeconds?: number;
|
||||
}
|
||||
|
||||
export type PianoNoteRole =
|
||||
| 'pad'
|
||||
| 'support'
|
||||
| 'texture'
|
||||
| 'gesture'
|
||||
| 'brush'
|
||||
| 'stinger';
|
||||
|
||||
export interface NoiseBurst {
|
||||
startTime: number;
|
||||
durationSeconds: number;
|
||||
|
|
|
|||
|
|
@ -1,220 +0,0 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { appConfig } from '../config';
|
||||
import { VIBE_PRESETS } from '../vibes';
|
||||
import { GardenAudio } from './garden-audio';
|
||||
import { gardenAudioConfig, GardenAudioConfig } from './garden-audio-config';
|
||||
|
||||
const calls = {
|
||||
constructed: 0,
|
||||
resumed: 0,
|
||||
sourcesStarted: 0,
|
||||
};
|
||||
|
||||
let contextState: AudioContextState = 'suspended';
|
||||
|
||||
class FakeAudioParam {
|
||||
public value = 0;
|
||||
public setTargetAtTime = vi.fn();
|
||||
public setValueAtTime = vi.fn();
|
||||
public exponentialRampToValueAtTime = vi.fn();
|
||||
public cancelScheduledValues = vi.fn();
|
||||
}
|
||||
|
||||
class FakeAudioNode {
|
||||
public readonly gain = new FakeAudioParam();
|
||||
public readonly frequency = new FakeAudioParam();
|
||||
public readonly Q = new FakeAudioParam();
|
||||
public readonly threshold = new FakeAudioParam();
|
||||
public readonly knee = new FakeAudioParam();
|
||||
public readonly ratio = new FakeAudioParam();
|
||||
public readonly attack = new FakeAudioParam();
|
||||
public readonly release = new FakeAudioParam();
|
||||
public readonly delayTime = new FakeAudioParam();
|
||||
public readonly pan = new FakeAudioParam();
|
||||
public type = '';
|
||||
public addEventListener = vi.fn();
|
||||
public connect = vi.fn();
|
||||
public disconnect = vi.fn();
|
||||
}
|
||||
|
||||
class FakeAudioBuffer {
|
||||
private readonly data: Float32Array;
|
||||
|
||||
public constructor(length: number) {
|
||||
this.data = new Float32Array(length);
|
||||
}
|
||||
|
||||
public getChannelData(): Float32Array {
|
||||
return this.data;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeAudioContext {
|
||||
public readonly currentTime = 1;
|
||||
public readonly sampleRate = 16;
|
||||
public readonly destination = new FakeAudioNode() as unknown as AudioDestinationNode;
|
||||
|
||||
public constructor() {
|
||||
calls.constructed += 1;
|
||||
}
|
||||
|
||||
public get state(): AudioContextState {
|
||||
return contextState;
|
||||
}
|
||||
|
||||
public set state(state: AudioContextState) {
|
||||
contextState = state;
|
||||
}
|
||||
|
||||
public createGain(): GainNode {
|
||||
return new FakeAudioNode() as unknown as GainNode;
|
||||
}
|
||||
|
||||
public createBiquadFilter(): BiquadFilterNode {
|
||||
return new FakeAudioNode() as unknown as BiquadFilterNode;
|
||||
}
|
||||
|
||||
public createDynamicsCompressor(): DynamicsCompressorNode {
|
||||
return new FakeAudioNode() as unknown as DynamicsCompressorNode;
|
||||
}
|
||||
|
||||
public createDelay(): DelayNode {
|
||||
return new FakeAudioNode() as unknown as DelayNode;
|
||||
}
|
||||
|
||||
public createStereoPanner(): StereoPannerNode {
|
||||
return new FakeAudioNode() as unknown as StereoPannerNode;
|
||||
}
|
||||
|
||||
public createBuffer(_channels: number, length: number): AudioBuffer {
|
||||
return new FakeAudioBuffer(length) as unknown as AudioBuffer;
|
||||
}
|
||||
|
||||
public createBufferSource(): AudioBufferSourceNode {
|
||||
const node = new FakeAudioNode() as unknown as AudioBufferSourceNode & {
|
||||
buffer: AudioBuffer | null;
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
};
|
||||
node.buffer = null;
|
||||
node.start = vi.fn(() => {
|
||||
calls.sourcesStarted += 1;
|
||||
});
|
||||
node.stop = vi.fn();
|
||||
return node;
|
||||
}
|
||||
|
||||
public async resume(): Promise<void> {
|
||||
calls.resumed += 1;
|
||||
contextState = 'running';
|
||||
}
|
||||
}
|
||||
|
||||
const makeConfig = (): GardenAudioConfig => ({
|
||||
...gardenAudioConfig,
|
||||
});
|
||||
|
||||
describe('GardenAudio startup policy', () => {
|
||||
beforeEach(() => {
|
||||
calls.constructed = 0;
|
||||
calls.resumed = 0;
|
||||
calls.sourcesStarted = 0;
|
||||
contextState = 'suspended';
|
||||
vi.stubGlobal('AudioContext', FakeAudioContext);
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('not loaded in tests')));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('does not create an AudioContext from passive audio paths', () => {
|
||||
const audio = new GardenAudio(
|
||||
makeConfig(),
|
||||
appConfig.audioEngine,
|
||||
appConfig.simulation.maxMirrorSegmentCount
|
||||
);
|
||||
const vibe = VIBE_PRESETS[0];
|
||||
|
||||
audio.start(vibe);
|
||||
audio.stroke({
|
||||
vibe,
|
||||
from: [0, 0],
|
||||
to: [12, 0],
|
||||
canvasSize: [100, 100],
|
||||
colorIndex: 0,
|
||||
isErasing: false,
|
||||
});
|
||||
|
||||
expect(calls.constructed).toBe(0);
|
||||
});
|
||||
|
||||
it('only resumes a suspended context from a user gesture start', () => {
|
||||
const audio = new GardenAudio(
|
||||
makeConfig(),
|
||||
appConfig.audioEngine,
|
||||
appConfig.simulation.maxMirrorSegmentCount
|
||||
);
|
||||
const vibe = VIBE_PRESETS[0];
|
||||
|
||||
audio.start(vibe, { userGesture: true });
|
||||
|
||||
expect(calls.constructed).toBe(1);
|
||||
expect(calls.resumed).toBe(1);
|
||||
expect(contextState).toBe('running');
|
||||
|
||||
contextState = 'suspended';
|
||||
audio.start(vibe);
|
||||
audio.setMuted(false);
|
||||
|
||||
expect(calls.resumed).toBe(1);
|
||||
});
|
||||
|
||||
it('skips cold piano fallback while preserving eraser noise', () => {
|
||||
const audio = new GardenAudio(
|
||||
makeConfig(),
|
||||
appConfig.audioEngine,
|
||||
appConfig.simulation.maxMirrorSegmentCount
|
||||
);
|
||||
const vibe = VIBE_PRESETS[0];
|
||||
|
||||
audio.start(vibe, { userGesture: true });
|
||||
expect(calls.sourcesStarted).toBe(1);
|
||||
|
||||
audio.beginGesture();
|
||||
audio.touchDown({
|
||||
vibe,
|
||||
colorIndex: 1,
|
||||
position: [30, 40],
|
||||
canvasSize: [100, 100],
|
||||
pressure: 0.7,
|
||||
});
|
||||
audio.stroke({
|
||||
vibe,
|
||||
from: [30, 40],
|
||||
to: [60, 60],
|
||||
canvasSize: [100, 100],
|
||||
colorIndex: 1,
|
||||
isErasing: false,
|
||||
pressure: 0.7,
|
||||
velocityPixelsPerSecond: 1600,
|
||||
});
|
||||
|
||||
expect(calls.sourcesStarted).toBe(1);
|
||||
|
||||
audio.stroke({
|
||||
vibe,
|
||||
from: [60, 60],
|
||||
to: [75, 80],
|
||||
canvasSize: [100, 100],
|
||||
colorIndex: 1,
|
||||
eraserSizePixels: 30,
|
||||
isErasing: true,
|
||||
pressure: 0.7,
|
||||
velocityPixelsPerSecond: 1200,
|
||||
});
|
||||
|
||||
expect(calls.sourcesStarted).toBe(2);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,29 +1,24 @@
|
|||
import type { GardenAudioEngineConfig } from '../config';
|
||||
import { clamp, clamp01 } from '../utils/clamp';
|
||||
import { VibePreset } from '../vibes';
|
||||
import { GardenAudioConfig } from './garden-audio-config';
|
||||
import { ErrorHandler, Severity } from '../utils/error-handler';
|
||||
import { clamp01 } from '../utils/math';
|
||||
import type { VibeId, VibePreset } from '../vibes';
|
||||
import type { GardenAudioConfig } from './garden-audio-config';
|
||||
import { GardenAudioEnergy } from './garden-audio-energy';
|
||||
import { GardenAudioGestureState } from './garden-audio-gesture-state';
|
||||
import { GardenAudioGraph } from './garden-audio-graph';
|
||||
import { getStrokeMetrics } from './garden-audio-input';
|
||||
import { getVibeProfile, normalizeColorIndex } from './garden-audio-music';
|
||||
import type {
|
||||
GardenAudioColorIndex,
|
||||
GardenAudioSnapshot,
|
||||
GardenAudioStartOptions,
|
||||
GardenAudioStroke,
|
||||
GardenAudioTouchDown,
|
||||
} from './garden-audio-types';
|
||||
import { getVibeProfile } from './garden-audio-music';
|
||||
import type { GardenAudioSnapshot, GardenAudioStroke } from './garden-audio-types';
|
||||
import { GenerativePianoEngine } from './generative-piano';
|
||||
import { NoiseBurstPlayer } from './noise-burst-player';
|
||||
import { PianoSampler } from './piano-sampler';
|
||||
|
||||
export type {
|
||||
GardenAudioSnapshot,
|
||||
GardenAudioStartOptions,
|
||||
GardenAudioStroke,
|
||||
GardenAudioTouchDown,
|
||||
} from './garden-audio-types';
|
||||
type AudioLifecycle = 'idle' | 'started' | 'destroyed';
|
||||
|
||||
const muteGain = 0.0001;
|
||||
const muteRampSeconds = 0.02;
|
||||
const brushUpPianoBusFadeSeconds = 2.4;
|
||||
const brushUpPianoBusFadeSettleSeconds = 3.2;
|
||||
const vibeChangeStingerMinIntervalSeconds = 0.45;
|
||||
|
||||
export class GardenAudio {
|
||||
private readonly graph: GardenAudioGraph;
|
||||
|
|
@ -33,100 +28,181 @@ export class GardenAudio {
|
|||
private readonly gestureState: GardenAudioGestureState;
|
||||
private readonly pianoEngine: GenerativePianoEngine;
|
||||
|
||||
private currentVibeId: string | null = null;
|
||||
private hasStarted = false;
|
||||
private isDestroyed = false;
|
||||
private currentVibeId: VibeId | null = null;
|
||||
private lifecycle: AudioLifecycle = 'idle';
|
||||
private isReleasingPiano = false;
|
||||
private isMuted = false;
|
||||
private isGestureActive = false;
|
||||
private selectedColorIndex: GardenAudioColorIndex = 0;
|
||||
private hasQueuedPianoLoad = false;
|
||||
private fadePianoAt: number | null = null;
|
||||
private masterVolume: number;
|
||||
private stopPianoAt: number | null = null;
|
||||
private lastEraserAt = Number.NEGATIVE_INFINITY;
|
||||
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
||||
private startRequestId = 0;
|
||||
|
||||
public constructor(
|
||||
private readonly config: GardenAudioConfig,
|
||||
private readonly engineConfig: GardenAudioEngineConfig,
|
||||
private readonly maxMirrorSegmentCount: number
|
||||
) {
|
||||
this.graph = new GardenAudioGraph(config, engineConfig);
|
||||
this.piano = new PianoSampler(config, engineConfig, this.graph);
|
||||
this.noise = new NoiseBurstPlayer(engineConfig, this.graph);
|
||||
this.energy = new GardenAudioEnergy(engineConfig);
|
||||
this.gestureState = new GardenAudioGestureState(
|
||||
config.rhythm.speedForFullEnergyPixelsPerSecond,
|
||||
engineConfig.input
|
||||
);
|
||||
this.pianoEngine = new GenerativePianoEngine(config, engineConfig, (note) =>
|
||||
this.piano.play(note)
|
||||
);
|
||||
public constructor(private readonly config: GardenAudioConfig) {
|
||||
this.masterVolume = clamp01(config.masterVolume);
|
||||
this.graph = new GardenAudioGraph(config);
|
||||
this.piano = new PianoSampler(config, this.graph);
|
||||
this.noise = new NoiseBurstPlayer(this.graph);
|
||||
this.energy = new GardenAudioEnergy(config);
|
||||
this.gestureState = new GardenAudioGestureState(config.input);
|
||||
this.pianoEngine = new GenerativePianoEngine(config, (note) => this.piano.play(note));
|
||||
}
|
||||
|
||||
public start(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
|
||||
if (this.isDestroyed || this.isMuted) {
|
||||
public start(vibe: VibePreset, options: { userGesture?: boolean } = {}): void {
|
||||
const isUserGesture = options.userGesture === true;
|
||||
|
||||
if (this.lifecycle === 'destroyed') {
|
||||
return;
|
||||
}
|
||||
|
||||
const context = this.graph.ensureContext(options.userGesture === true);
|
||||
if (
|
||||
this.lifecycle === 'started' &&
|
||||
this.currentVibeId === vibe.id &&
|
||||
this.graph.context?.state === 'running'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const context = this.graph.ensureContext(isUserGesture);
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.userGesture === true) {
|
||||
this.graph.unlock();
|
||||
}
|
||||
const startupRampSeconds = isUserGesture
|
||||
? muteRampSeconds
|
||||
: this.config.fadeInSeconds;
|
||||
const needsResume = context.state !== 'running' && context.state !== 'closed';
|
||||
|
||||
if (context.state === 'suspended') {
|
||||
if (options.userGesture !== true) {
|
||||
if (needsResume) {
|
||||
if (!isUserGesture) {
|
||||
return;
|
||||
}
|
||||
void context.resume().catch(() => undefined);
|
||||
void context
|
||||
.resume()
|
||||
.then(() => {
|
||||
if (this.graph.context === context && this.lifecycle !== 'destroyed') {
|
||||
this.completeStart(vibe, { context, startupRampSeconds });
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
ErrorHandler.addException(error, {
|
||||
fallbackMessage: 'Could not resume audio playback.',
|
||||
severity: Severity.WARNING,
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.hasStarted = true;
|
||||
this.applyVibe(vibe);
|
||||
this.pianoEngine.prime(context.currentTime);
|
||||
this.graph.setMasterGain(
|
||||
this.config.masterVolume,
|
||||
options.userGesture === true
|
||||
? this.engineConfig.muteRampSeconds
|
||||
: this.config.fadeInSeconds
|
||||
);
|
||||
this.completeStart(vibe, { context, startupRampSeconds });
|
||||
}
|
||||
|
||||
if (!this.hasQueuedPianoLoad) {
|
||||
this.hasQueuedPianoLoad = true;
|
||||
void this.piano.load(context).then(() => {
|
||||
if (this.graph.context === context && !this.isDestroyed) {
|
||||
this.pianoEngine.cue(context.currentTime);
|
||||
private completeStart(
|
||||
vibe: VibePreset,
|
||||
{
|
||||
context,
|
||||
startupRampSeconds,
|
||||
}: {
|
||||
context: AudioContext;
|
||||
startupRampSeconds: number;
|
||||
}
|
||||
): void {
|
||||
if (this.graph.context !== context || this.lifecycle === 'destroyed') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isMuted) {
|
||||
this.graph.setMasterGain(muteGain, muteRampSeconds);
|
||||
return;
|
||||
}
|
||||
|
||||
const startRequestId = ++this.startRequestId;
|
||||
void this.piano
|
||||
.load(context)
|
||||
.then(() => {
|
||||
if (!this.canCompleteStart(context, startRequestId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.activateStart(vibe, context, startupRampSeconds, true);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (this.canCompleteStart(context, startRequestId)) {
|
||||
this.activateStart(vibe, context, startupRampSeconds, false);
|
||||
}
|
||||
ErrorHandler.addException(error, {
|
||||
fallbackMessage: 'Could not load piano samples.',
|
||||
severity: Severity.WARNING,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private canCompleteStart(context: AudioContext, startRequestId: number): boolean {
|
||||
return (
|
||||
this.graph.context === context &&
|
||||
this.lifecycle !== 'destroyed' &&
|
||||
!this.isMuted &&
|
||||
this.startRequestId === startRequestId
|
||||
);
|
||||
}
|
||||
|
||||
private activateStart(
|
||||
vibe: VibePreset,
|
||||
context: AudioContext,
|
||||
startupRampSeconds: number,
|
||||
cuePiano: boolean
|
||||
): void {
|
||||
this.lifecycle = 'started';
|
||||
this.currentVibeId = vibe.id;
|
||||
this.graph.applyDelayProfile();
|
||||
this.graph.setMasterGain(this.masterVolume, startupRampSeconds);
|
||||
|
||||
if (cuePiano) {
|
||||
this.pianoEngine.cue(context.currentTime, getVibeProfile(vibe));
|
||||
}
|
||||
}
|
||||
|
||||
public changeVibe(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
|
||||
public changeVibe(vibe: VibePreset, options: { userGesture?: boolean } = {}): void {
|
||||
const previousVibeId = this.currentVibeId;
|
||||
this.start(vibe, options);
|
||||
const didChangeVibe = previousVibeId !== null && previousVibeId !== vibe.id;
|
||||
|
||||
if (didChangeVibe) {
|
||||
this.piano.stopAll();
|
||||
}
|
||||
|
||||
const context = this.graph.context;
|
||||
if (
|
||||
context &&
|
||||
(context.state === 'running' || options.userGesture === true) &&
|
||||
!this.isMuted &&
|
||||
!this.isDestroyed &&
|
||||
previousVibeId !== null &&
|
||||
previousVibeId !== vibe.id
|
||||
this.lifecycle !== 'destroyed' &&
|
||||
didChangeVibe
|
||||
) {
|
||||
this.playVibeChangeStinger(vibe);
|
||||
}
|
||||
}
|
||||
|
||||
public setMuted(isMuted: boolean): void {
|
||||
if (this.isMuted === isMuted) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isMuted = isMuted;
|
||||
this.graph.setMasterGain(
|
||||
isMuted ? this.engineConfig.muteGain : this.config.masterVolume,
|
||||
isMuted ? this.engineConfig.muteRampSeconds : this.config.fadeInSeconds
|
||||
isMuted ? muteGain : this.masterVolume,
|
||||
isMuted ? muteRampSeconds : this.config.fadeInSeconds
|
||||
);
|
||||
}
|
||||
|
||||
public setMasterVolume(masterVolume: number): void {
|
||||
this.masterVolume = clamp01(masterVolume);
|
||||
if (!this.isMuted) {
|
||||
this.graph.setMasterGain(this.masterVolume, this.config.updateRampSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
public beginGesture(): void {
|
||||
const context = this.graph.context;
|
||||
if (!context) {
|
||||
|
|
@ -134,86 +210,60 @@ export class GardenAudio {
|
|||
}
|
||||
|
||||
this.isGestureActive = true;
|
||||
this.gestureState.beginGesture();
|
||||
this.isReleasingPiano = false;
|
||||
this.fadePianoAt = null;
|
||||
this.stopPianoAt = null;
|
||||
this.graph.setPianoBusGainScale(1, this.config.fadeInSeconds);
|
||||
this.gestureState.reset();
|
||||
this.energy.beginGesture(context.currentTime);
|
||||
this.pianoEngine.beginGesture();
|
||||
}
|
||||
|
||||
public endGesture(): void {
|
||||
this.gestureState.endGesture();
|
||||
this.gestureState.reset();
|
||||
this.isGestureActive = false;
|
||||
this.isReleasingPiano = true;
|
||||
this.fadePianoAt = null;
|
||||
this.stopPianoAt = null;
|
||||
this.energy.endGesture();
|
||||
this.pianoEngine.endGesture();
|
||||
}
|
||||
|
||||
public touchDown(touch: GardenAudioTouchDown): void {
|
||||
if (this.isDestroyed || this.isMuted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const context = this.graph.context;
|
||||
if (!context || !this.isGestureActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedColorIndex = normalizeColorIndex(touch.colorIndex);
|
||||
const mirrorAmount = this.getMirrorAmount(touch.mirrorSegmentCount ?? 1);
|
||||
const pressure = this.getTouchPressure(touch.pressure, touch.pointerType);
|
||||
const strength = clamp01(0.36 + pressure * 0.34 + mirrorAmount * 0.22);
|
||||
const frame = this.gestureState.recordTouchDown({
|
||||
touch,
|
||||
colorIndex: this.selectedColorIndex,
|
||||
mirrorAmount,
|
||||
pressure,
|
||||
strength,
|
||||
});
|
||||
|
||||
this.energy.recordStroke(strength, context.currentTime);
|
||||
this.pianoEngine.recordTouchDown({
|
||||
vibe: touch.vibe,
|
||||
now: context.currentTime,
|
||||
strength,
|
||||
selectedColorIndex: this.selectedColorIndex,
|
||||
mirrorAmount,
|
||||
panBias: frame.panBias,
|
||||
registerBias: frame.registerBias,
|
||||
brightnessBias: frame.brightnessBias,
|
||||
contour: frame.contour,
|
||||
pressureAmount: frame.pressure,
|
||||
pressureDelta: frame.pressureDelta,
|
||||
maniaAmount: frame.maniaAmount,
|
||||
});
|
||||
}
|
||||
|
||||
public update(snapshot: GardenAudioSnapshot): void {
|
||||
const context = this.graph.context;
|
||||
if (!this.hasStarted || !context || this.isMuted) {
|
||||
if (this.lifecycle !== 'started' || !context || this.isMuted) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.applyVibe(snapshot.vibe);
|
||||
this.selectedColorIndex = normalizeColorIndex(snapshot.selectedColorIndex);
|
||||
this.energy.update(context.currentTime);
|
||||
const profile = getVibeProfile(snapshot.vibe);
|
||||
this.energy.update(context.currentTime, profile);
|
||||
|
||||
if (snapshot.isErasing) {
|
||||
this.energy.silence();
|
||||
}
|
||||
|
||||
if (!this.isGestureActive && this.isReleasingPiano) {
|
||||
this.updatePianoRelease(snapshot.vibe, context.currentTime);
|
||||
this.updateDelay(snapshot);
|
||||
return;
|
||||
}
|
||||
|
||||
this.pianoEngine.renderLookahead({
|
||||
vibe: snapshot.vibe,
|
||||
now: context.currentTime,
|
||||
activity: snapshot.isErasing ? 0 : this.energy.getLevel(),
|
||||
selectedColorIndex: this.selectedColorIndex,
|
||||
activity: snapshot.isErasing
|
||||
? this.config.eraser.pianoActivity
|
||||
: this.energy.getLevel(),
|
||||
});
|
||||
this.updateDelay(snapshot);
|
||||
}
|
||||
|
||||
public stroke(stroke: GardenAudioStroke): void {
|
||||
if (this.isDestroyed || this.isMuted) {
|
||||
if (this.lifecycle !== 'started' || this.isMuted) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.start(stroke.vibe);
|
||||
const context = this.graph.context;
|
||||
if (!context) {
|
||||
return;
|
||||
|
|
@ -222,44 +272,29 @@ export class GardenAudio {
|
|||
return;
|
||||
}
|
||||
|
||||
const metrics = getStrokeMetrics(
|
||||
stroke,
|
||||
this.config.rhythm.speedForFullEnergyPixelsPerSecond,
|
||||
this.config.input.pressureFallback,
|
||||
this.engineConfig.input
|
||||
);
|
||||
const metrics = getStrokeMetrics(stroke);
|
||||
const now = context.currentTime;
|
||||
|
||||
this.selectedColorIndex = normalizeColorIndex(stroke.colorIndex);
|
||||
const frame = this.gestureState.recordStroke({ metrics });
|
||||
const strokeEnergy = frame.activity;
|
||||
|
||||
if (stroke.isErasing) {
|
||||
this.energy.recordEraserStroke();
|
||||
this.playEraser(stroke, metrics.speedAmount, metrics.pressure, now);
|
||||
this.playEraser(strokeEnergy, now);
|
||||
return;
|
||||
}
|
||||
|
||||
const mirrorAmount = this.getMirrorAmount(stroke.mirrorSegmentCount ?? 1);
|
||||
const frame = this.gestureState.recordStroke({ stroke, metrics, mirrorAmount });
|
||||
const strokeEnergy = frame.activity;
|
||||
this.energy.recordStroke(strokeEnergy, now);
|
||||
const profile = getVibeProfile(stroke.vibe);
|
||||
this.energy.recordStroke(strokeEnergy, profile);
|
||||
this.pianoEngine.recordStroke({
|
||||
vibe: stroke.vibe,
|
||||
now,
|
||||
activity: strokeEnergy,
|
||||
selectedColorIndex: this.selectedColorIndex,
|
||||
mirrorAmount,
|
||||
panBias: frame.panBias,
|
||||
registerBias: frame.registerBias,
|
||||
brightnessBias: frame.brightnessBias,
|
||||
contour: frame.contour,
|
||||
pressureAmount: frame.pressure,
|
||||
pressureDelta: frame.pressureDelta,
|
||||
maniaAmount: frame.maniaAmount,
|
||||
});
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
this.isDestroyed = true;
|
||||
this.lifecycle = 'destroyed';
|
||||
await this.graph.close();
|
||||
|
||||
this.piano.reset();
|
||||
|
|
@ -267,10 +302,10 @@ export class GardenAudio {
|
|||
this.gestureState.reset();
|
||||
this.pianoEngine.reset();
|
||||
this.currentVibeId = null;
|
||||
this.hasStarted = false;
|
||||
this.isGestureActive = false;
|
||||
this.selectedColorIndex = 0;
|
||||
this.hasQueuedPianoLoad = false;
|
||||
this.isReleasingPiano = false;
|
||||
this.fadePianoAt = null;
|
||||
this.stopPianoAt = null;
|
||||
this.lastEraserAt = Number.NEGATIVE_INFINITY;
|
||||
this.lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
|
|
@ -282,10 +317,7 @@ export class GardenAudio {
|
|||
}
|
||||
|
||||
const now = context.currentTime;
|
||||
if (
|
||||
now - this.lastVibeStingerAt <
|
||||
this.engineConfig.vibeChangeStingerMinIntervalSeconds
|
||||
) {
|
||||
if (now - this.lastVibeStingerAt < vibeChangeStingerMinIntervalSeconds) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -293,46 +325,48 @@ export class GardenAudio {
|
|||
this.pianoEngine.playVibeChangeStinger(vibe, now);
|
||||
}
|
||||
|
||||
private playEraser(
|
||||
stroke: GardenAudioStroke,
|
||||
speedAmount: number,
|
||||
pressure: number,
|
||||
now: number
|
||||
): void {
|
||||
private updatePianoRelease(vibe: VibePreset, now: number): void {
|
||||
if (this.fadePianoAt === null && this.stopPianoAt === null) {
|
||||
this.fadePianoAt = this.pianoEngine.release(vibe, now);
|
||||
}
|
||||
|
||||
if (this.fadePianoAt !== null && now >= this.fadePianoAt) {
|
||||
this.graph.setPianoBusGainScale(0, brushUpPianoBusFadeSeconds);
|
||||
this.fadePianoAt = null;
|
||||
this.stopPianoAt = now + brushUpPianoBusFadeSettleSeconds;
|
||||
}
|
||||
|
||||
if (this.stopPianoAt !== null && now >= this.stopPianoAt) {
|
||||
this.piano.stopAll();
|
||||
this.pianoEngine.reset();
|
||||
this.stopPianoAt = null;
|
||||
this.isReleasingPiano = false;
|
||||
}
|
||||
}
|
||||
|
||||
private playEraser(activity: number, now: number): void {
|
||||
if (!this.graph.context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sizeAmount = clamp01(
|
||||
(stroke.eraserSizePixels ?? this.engineConfig.eraser.defaultSizePixels) /
|
||||
Math.max(
|
||||
1,
|
||||
stroke.canvasSize[0] * this.engineConfig.eraser.canvasWidthRatioForFullSize
|
||||
)
|
||||
);
|
||||
const x = clamp01(stroke.to[0] / Math.max(1, stroke.canvasSize[0]));
|
||||
const distanceActivity = clamp01(activity);
|
||||
if (distanceActivity <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filterHz =
|
||||
this.config.eraser.filterMinHz +
|
||||
(this.config.eraser.filterMaxHz - this.config.eraser.filterMinHz) *
|
||||
clamp01(
|
||||
speedAmount * this.engineConfig.eraser.filterSpeedWeight +
|
||||
pressure * this.engineConfig.eraser.filterPressureWeight +
|
||||
sizeAmount * this.engineConfig.eraser.filterSizeWeight
|
||||
);
|
||||
distanceActivity;
|
||||
|
||||
if (now - this.lastEraserAt >= this.config.eraser.minIntervalSeconds) {
|
||||
this.lastEraserAt = now;
|
||||
this.noise.play({
|
||||
startTime: now,
|
||||
durationSeconds: this.engineConfig.eraser.durationSeconds,
|
||||
gain:
|
||||
this.config.eraser.noiseGain *
|
||||
(this.engineConfig.eraser.gainBase +
|
||||
speedAmount * this.engineConfig.eraser.gainSpeedWeight +
|
||||
pressure * this.engineConfig.eraser.gainPressureWeight +
|
||||
sizeAmount * this.engineConfig.eraser.gainSizeWeight),
|
||||
durationSeconds: this.config.eraser.durationSeconds,
|
||||
gain: this.config.eraser.noiseGain * distanceActivity,
|
||||
filterHz,
|
||||
pan: clamp(x * 2 - 1, -1, 1),
|
||||
pan: this.config.eraser.pan,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -343,11 +377,10 @@ export class GardenAudio {
|
|||
return;
|
||||
}
|
||||
|
||||
const profile = getVibeProfile(this.config, snapshot.vibe);
|
||||
const activity = snapshot.isErasing
|
||||
? this.engineConfig.delay.erasingActivity
|
||||
? this.config.delay.erasingActivity
|
||||
: this.energy.getLevel();
|
||||
this.graph.updateDelay(profile, activity);
|
||||
this.graph.updateDelay(activity);
|
||||
}
|
||||
|
||||
private applyVibe(vibe: VibePreset): void {
|
||||
|
|
@ -356,35 +389,8 @@ export class GardenAudio {
|
|||
}
|
||||
|
||||
this.currentVibeId = vibe.id;
|
||||
this.graph.applyDelayProfile(getVibeProfile(this.config, vibe));
|
||||
this.pianoEngine.cue(this.graph.context.currentTime);
|
||||
}
|
||||
|
||||
private getMirrorAmount(mirrorSegmentCount: number): number {
|
||||
const maxMirrorSegmentCount = Math.max(1, this.maxMirrorSegmentCount);
|
||||
const segmentCount = clamp(
|
||||
Number.isFinite(mirrorSegmentCount) ? mirrorSegmentCount : 1,
|
||||
1,
|
||||
maxMirrorSegmentCount
|
||||
);
|
||||
|
||||
if (maxMirrorSegmentCount <= 1) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return clamp01((segmentCount - 1) / (maxMirrorSegmentCount - 1));
|
||||
}
|
||||
|
||||
private getTouchPressure(pressure: number | undefined, pointerType?: string): number {
|
||||
if (pressure !== undefined && Number.isFinite(pressure) && pressure > 0) {
|
||||
return clamp01(pressure);
|
||||
}
|
||||
|
||||
return pointerType === 'pen'
|
||||
? Math.max(
|
||||
this.engineConfig.input.penMinPressure,
|
||||
this.config.input.pressureFallback
|
||||
)
|
||||
: this.config.input.pressureFallback;
|
||||
const profile = getVibeProfile(vibe);
|
||||
this.graph.applyDelayProfile();
|
||||
this.pianoEngine.cue(this.graph.context.currentTime, profile);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
431
src/audio/generative-piano-tuning.ts
Normal file
|
|
@ -0,0 +1,431 @@
|
|||
export interface GardenAudioRegister {
|
||||
midiMin: number;
|
||||
midiMax: number;
|
||||
preferredMidi: number;
|
||||
pan: number;
|
||||
}
|
||||
|
||||
export interface GardenAudioStylePool extends GardenAudioRegister {
|
||||
scaleDegrees: Array<number>;
|
||||
}
|
||||
|
||||
interface GardenAudioStyleVoice {
|
||||
scaleDegreeOffset: number;
|
||||
velocityMultiplier: number;
|
||||
panOffset: number;
|
||||
}
|
||||
|
||||
interface GenerativePianoTuning {
|
||||
stylePools: [GardenAudioStylePool, GardenAudioStylePool, GardenAudioStylePool];
|
||||
padRegisters: [GardenAudioRegister, GardenAudioRegister, GardenAudioRegister];
|
||||
vibeChangeStinger: {
|
||||
velocities: [number, number, number];
|
||||
pans: [number, number, number];
|
||||
delaySends: [number, number, number];
|
||||
lowpassExpression: number;
|
||||
noteDurationSeconds: number;
|
||||
spacingSeconds: number;
|
||||
};
|
||||
releaseResolution: {
|
||||
durationSeconds: number;
|
||||
fadeAfterSeconds: number;
|
||||
velocities: [number, number, number];
|
||||
delaySend: number;
|
||||
lowpassExpression: number;
|
||||
strumSeconds: number;
|
||||
};
|
||||
highActivityExtra: {
|
||||
barOffset: number;
|
||||
expressionMultiplier: number;
|
||||
};
|
||||
padChord: {
|
||||
velocities: [number, number, number];
|
||||
expressionVelocityWeight: number;
|
||||
delaySend: number;
|
||||
lowpassExpressionWeight: number;
|
||||
};
|
||||
supportNote: {
|
||||
velocityBase: number;
|
||||
velocityExpressionWeight: number;
|
||||
durationBaseSeconds: number;
|
||||
durationExpressionSeconds: number;
|
||||
delaySendBase: number;
|
||||
delaySendExpressionWeight: number;
|
||||
lowpassExpressionWeight: number;
|
||||
expressionThreshold: number;
|
||||
offsetsByStyle: [Array<number>, Array<number>, Array<number>];
|
||||
};
|
||||
textureNote: {
|
||||
velocityBase: number;
|
||||
velocityExpressionWeight: number;
|
||||
durationBaseSeconds: number;
|
||||
durationExpressionSeconds: number;
|
||||
delaySendBase: number;
|
||||
delaySendExpressionWeight: number;
|
||||
idleExpressionThreshold: number;
|
||||
mediumExpressionThreshold: number;
|
||||
intenseSpacing: number;
|
||||
idlePhase: number;
|
||||
};
|
||||
gestureAccent: {
|
||||
rotationStrengthMultiplier: number;
|
||||
quantizeStepLookahead: number;
|
||||
velocityBase: number;
|
||||
velocityStrengthWeight: number;
|
||||
durationBaseSeconds: number;
|
||||
durationStrengthSeconds: number;
|
||||
delaySend: number;
|
||||
};
|
||||
touchNote: {
|
||||
velocityBase: number;
|
||||
velocityStrengthWeight: number;
|
||||
durationBaseSeconds: number;
|
||||
durationStrengthSeconds: number;
|
||||
delaySend: number;
|
||||
lowpassBaseExpression: number;
|
||||
lowpassStrengthWeight: number;
|
||||
};
|
||||
brushPhrase: {
|
||||
initialMotifOffset: number;
|
||||
energyDecaySeconds: number;
|
||||
maniaDecaySeconds: number;
|
||||
layerIntensityBase: number;
|
||||
layerIntensityManiaWeight: number;
|
||||
frameActivityWeight: number;
|
||||
frameManiaWeight: number;
|
||||
};
|
||||
brushStream: {
|
||||
inferredManiaThreshold: number;
|
||||
inferredManiaRange: number;
|
||||
registerManiaShift: number;
|
||||
chordToneEverySteps: number;
|
||||
durationBaseSeconds: number;
|
||||
durationIntensitySeconds: number;
|
||||
durationManiaSeconds: number;
|
||||
durationMinSeconds: number;
|
||||
durationMaxSeconds: number;
|
||||
delaySendBase: number;
|
||||
delaySendIntensityWeight: number;
|
||||
delaySendManiaWeight: number;
|
||||
delaySendMin: number;
|
||||
delaySendMax: number;
|
||||
velocityBase: number;
|
||||
velocityIntensityWeight: number;
|
||||
lowpassBaseExpression: number;
|
||||
lowpassIntensityWeight: number;
|
||||
lowpassManiaWeight: number;
|
||||
intenseThreshold: number;
|
||||
activeThreshold: number;
|
||||
};
|
||||
brushStreamEcho: {
|
||||
maniaThreshold: number;
|
||||
stepModulo: number;
|
||||
stepRemainder: number;
|
||||
intensityThreshold: number;
|
||||
octaveSemitones: number;
|
||||
maxMidi: number;
|
||||
velocityBase: number;
|
||||
velocityIntensityWeight: number;
|
||||
durationMinSeconds: number;
|
||||
durationScale: number;
|
||||
panScale: number;
|
||||
delaySendMin: number;
|
||||
delaySendScale: number;
|
||||
lowpassBaseExpression: number;
|
||||
lowpassManiaWeight: number;
|
||||
};
|
||||
brushMotif: {
|
||||
highThreshold: number;
|
||||
mediumThreshold: number;
|
||||
highOffset: number;
|
||||
mediumOffset: number;
|
||||
lowOffset: number;
|
||||
};
|
||||
registerBias: {
|
||||
maniaShiftSemitones: number;
|
||||
midiMin: number;
|
||||
midiMaxForMin: number;
|
||||
minimumSpan: number;
|
||||
midiMax: number;
|
||||
};
|
||||
candidateOctaveSearch: {
|
||||
min: number;
|
||||
max: number;
|
||||
};
|
||||
stylePanOffsetScale: number;
|
||||
lowpass: {
|
||||
midiBase: number;
|
||||
midiRange: number;
|
||||
midiLiftHz: number;
|
||||
expressionBase: number;
|
||||
expressionWeight: number;
|
||||
};
|
||||
styleRotationBars: number;
|
||||
chordBars: number;
|
||||
supportBarSpacing: number;
|
||||
supportBarOffset: number;
|
||||
idleTextureBarSpacing: number;
|
||||
mediumTextureBarSpacing: number;
|
||||
textureBeat: number;
|
||||
highActivityExtraBeat: number;
|
||||
highActivityExtraThreshold: number;
|
||||
noteScorePreferenceWeight: number;
|
||||
noteScoreRegisterWeight: number;
|
||||
noteScoreChordToneWeight: number;
|
||||
noteScoreRepeatPenalty: number;
|
||||
gestureAccentMinIntervalSeconds: number;
|
||||
strokeAccentMinSteps: number;
|
||||
strokeAccentThreshold: number;
|
||||
maxBrushPhraseLayers: number;
|
||||
maxBrushStreamNotesPerBar: number;
|
||||
brushLayerBaseSeconds: number;
|
||||
brushLayerEnergySeconds: number;
|
||||
brushLayerMinIntensity: number;
|
||||
brushStreamIdleIntervalBeats: number;
|
||||
brushStreamActiveIntervalBeats: number;
|
||||
brushStreamIntenseIntervalBeats: number;
|
||||
brushMotifMaxSteps: number;
|
||||
brushMotifCanonDelaySeconds: number;
|
||||
padDurationBarScale: number;
|
||||
}
|
||||
|
||||
export const generativePianoTuning: GenerativePianoTuning = {
|
||||
stylePools: [
|
||||
{
|
||||
midiMin: 48,
|
||||
midiMax: 67,
|
||||
preferredMidi: 55,
|
||||
pan: -0.18,
|
||||
scaleDegrees: [0, 1, 2, 4],
|
||||
},
|
||||
{
|
||||
midiMin: 55,
|
||||
midiMax: 74,
|
||||
preferredMidi: 63,
|
||||
pan: 0,
|
||||
scaleDegrees: [1, 2, 3, 5],
|
||||
},
|
||||
{
|
||||
midiMin: 62,
|
||||
midiMax: 78,
|
||||
preferredMidi: 70,
|
||||
pan: 0.18,
|
||||
scaleDegrees: [2, 3, 4, 6],
|
||||
},
|
||||
],
|
||||
padRegisters: [
|
||||
{
|
||||
midiMin: 40,
|
||||
midiMax: 55,
|
||||
preferredMidi: 48,
|
||||
pan: -0.12,
|
||||
},
|
||||
{
|
||||
midiMin: 48,
|
||||
midiMax: 64,
|
||||
preferredMidi: 55,
|
||||
pan: 0.08,
|
||||
},
|
||||
{
|
||||
midiMin: 58,
|
||||
midiMax: 76,
|
||||
preferredMidi: 67,
|
||||
pan: 0.2,
|
||||
},
|
||||
],
|
||||
vibeChangeStinger: {
|
||||
velocities: [0.1, 0.085, 0.07],
|
||||
pans: [-0.16, 0, 0.16],
|
||||
delaySends: [0.012, 0.014, 0.016],
|
||||
lowpassExpression: 0.35,
|
||||
noteDurationSeconds: 1.1,
|
||||
spacingSeconds: 0.08,
|
||||
},
|
||||
releaseResolution: {
|
||||
durationSeconds: 3.4,
|
||||
fadeAfterSeconds: 2.4,
|
||||
velocities: [0.064, 0.05, 0.038],
|
||||
delaySend: 0.018,
|
||||
lowpassExpression: 0.34,
|
||||
strumSeconds: 0.055,
|
||||
},
|
||||
highActivityExtra: {
|
||||
barOffset: 1,
|
||||
expressionMultiplier: 0.9,
|
||||
},
|
||||
padChord: {
|
||||
velocities: [0.046, 0.036, 0.029],
|
||||
expressionVelocityWeight: 0.018,
|
||||
delaySend: 0.008,
|
||||
lowpassExpressionWeight: 0.24,
|
||||
},
|
||||
supportNote: {
|
||||
velocityBase: 0.105,
|
||||
velocityExpressionWeight: 0.07,
|
||||
durationBaseSeconds: 1.35,
|
||||
durationExpressionSeconds: 0.4,
|
||||
delaySendBase: 0.016,
|
||||
delaySendExpressionWeight: 0.006,
|
||||
lowpassExpressionWeight: 0.7,
|
||||
expressionThreshold: 0.55,
|
||||
offsetsByStyle: [
|
||||
[0, 2, 12],
|
||||
[1, 2, 0, 12],
|
||||
[2, 12, 3, 13],
|
||||
],
|
||||
},
|
||||
textureNote: {
|
||||
velocityBase: 0.09,
|
||||
velocityExpressionWeight: 0.08,
|
||||
durationBaseSeconds: 0.62,
|
||||
durationExpressionSeconds: 0.24,
|
||||
delaySendBase: 0.016,
|
||||
delaySendExpressionWeight: 0.006,
|
||||
idleExpressionThreshold: 0.35,
|
||||
mediumExpressionThreshold: 0.7,
|
||||
intenseSpacing: 1,
|
||||
idlePhase: 1,
|
||||
},
|
||||
gestureAccent: {
|
||||
rotationStrengthMultiplier: 3,
|
||||
quantizeStepLookahead: 1,
|
||||
velocityBase: 0.12,
|
||||
velocityStrengthWeight: 0.09,
|
||||
durationBaseSeconds: 0.48,
|
||||
durationStrengthSeconds: 0.22,
|
||||
delaySend: 0.012,
|
||||
},
|
||||
touchNote: {
|
||||
velocityBase: 0.14,
|
||||
velocityStrengthWeight: 0.11,
|
||||
durationBaseSeconds: 0.55,
|
||||
durationStrengthSeconds: 0.18,
|
||||
delaySend: 0.006,
|
||||
lowpassBaseExpression: 0.55,
|
||||
lowpassStrengthWeight: 0.35,
|
||||
},
|
||||
brushPhrase: {
|
||||
initialMotifOffset: -1,
|
||||
energyDecaySeconds: 0.72,
|
||||
maniaDecaySeconds: 0.54,
|
||||
layerIntensityBase: 0.8,
|
||||
layerIntensityManiaWeight: 0.42,
|
||||
frameActivityWeight: 0.42,
|
||||
frameManiaWeight: 0.18,
|
||||
},
|
||||
brushStream: {
|
||||
inferredManiaThreshold: 0.82,
|
||||
inferredManiaRange: 0.18,
|
||||
registerManiaShift: 0.3,
|
||||
chordToneEverySteps: 4,
|
||||
durationBaseSeconds: 0.48,
|
||||
durationIntensitySeconds: 0.08,
|
||||
durationManiaSeconds: 0.34,
|
||||
durationMinSeconds: 0.14,
|
||||
durationMaxSeconds: 0.62,
|
||||
delaySendBase: 0.012,
|
||||
delaySendIntensityWeight: 0.011,
|
||||
delaySendManiaWeight: 0.006,
|
||||
delaySendMin: 0.006,
|
||||
delaySendMax: 0.032,
|
||||
velocityBase: 0.1,
|
||||
velocityIntensityWeight: 0.1,
|
||||
lowpassBaseExpression: 0.39,
|
||||
lowpassIntensityWeight: 0.48,
|
||||
lowpassManiaWeight: 0.18,
|
||||
intenseThreshold: 0.68,
|
||||
activeThreshold: 0.34,
|
||||
},
|
||||
brushStreamEcho: {
|
||||
maniaThreshold: 0.92,
|
||||
stepModulo: 3,
|
||||
stepRemainder: 1,
|
||||
intensityThreshold: 0.95,
|
||||
octaveSemitones: 12,
|
||||
maxMidi: 84,
|
||||
velocityBase: 0.035,
|
||||
velocityIntensityWeight: 0.04,
|
||||
durationMinSeconds: 0.11,
|
||||
durationScale: 0.68,
|
||||
panScale: -0.75,
|
||||
delaySendMin: 0.006,
|
||||
delaySendScale: 0.72,
|
||||
lowpassBaseExpression: 0.62,
|
||||
lowpassManiaWeight: 0.24,
|
||||
},
|
||||
brushMotif: {
|
||||
highThreshold: 0.82,
|
||||
mediumThreshold: 0.55,
|
||||
highOffset: 1,
|
||||
mediumOffset: 0,
|
||||
lowOffset: -1,
|
||||
},
|
||||
registerBias: {
|
||||
maniaShiftSemitones: 2,
|
||||
midiMin: 36,
|
||||
midiMaxForMin: 86,
|
||||
minimumSpan: 4,
|
||||
midiMax: 91,
|
||||
},
|
||||
candidateOctaveSearch: {
|
||||
min: -3,
|
||||
max: 3,
|
||||
},
|
||||
stylePanOffsetScale: 0.35,
|
||||
lowpass: {
|
||||
midiBase: 48,
|
||||
midiRange: 33,
|
||||
midiLiftHz: 500,
|
||||
expressionBase: 0.58,
|
||||
expressionWeight: 0.32,
|
||||
},
|
||||
styleRotationBars: 2,
|
||||
chordBars: 4,
|
||||
supportBarSpacing: 2,
|
||||
supportBarOffset: 1,
|
||||
idleTextureBarSpacing: 2,
|
||||
mediumTextureBarSpacing: 1,
|
||||
textureBeat: 2,
|
||||
highActivityExtraBeat: 3,
|
||||
highActivityExtraThreshold: 0.45,
|
||||
noteScorePreferenceWeight: 1.8,
|
||||
noteScoreRegisterWeight: 0.28,
|
||||
noteScoreChordToneWeight: 0.75,
|
||||
noteScoreRepeatPenalty: 3.2,
|
||||
gestureAccentMinIntervalSeconds: 2.5,
|
||||
strokeAccentMinSteps: 12,
|
||||
strokeAccentThreshold: 0.58,
|
||||
maxBrushPhraseLayers: 3,
|
||||
maxBrushStreamNotesPerBar: 7,
|
||||
brushLayerBaseSeconds: 5.5,
|
||||
brushLayerEnergySeconds: 2.5,
|
||||
brushLayerMinIntensity: 0.12,
|
||||
brushStreamIdleIntervalBeats: 2,
|
||||
brushStreamActiveIntervalBeats: 1,
|
||||
brushStreamIntenseIntervalBeats: 0.75,
|
||||
brushMotifMaxSteps: 8,
|
||||
brushMotifCanonDelaySeconds: 0.055,
|
||||
padDurationBarScale: 0.82,
|
||||
};
|
||||
|
||||
export const styleVoices: [
|
||||
GardenAudioStyleVoice,
|
||||
GardenAudioStyleVoice,
|
||||
GardenAudioStyleVoice,
|
||||
] = [
|
||||
{
|
||||
scaleDegreeOffset: 0,
|
||||
velocityMultiplier: 0.92,
|
||||
panOffset: -0.14,
|
||||
},
|
||||
{
|
||||
scaleDegreeOffset: 1,
|
||||
velocityMultiplier: 1,
|
||||
panOffset: 0,
|
||||
},
|
||||
{
|
||||
scaleDegreeOffset: 2,
|
||||
velocityMultiplier: 0.86,
|
||||
panOffset: 0.14,
|
||||
},
|
||||
];
|
||||
|
|
@ -1,238 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { appConfig } from '../config';
|
||||
import { VIBE_PRESETS } from '../vibes';
|
||||
import { gardenAudioConfig } from './garden-audio-config';
|
||||
import { PianoNote } from './garden-audio-types';
|
||||
import { GenerativePianoEngine } from './generative-piano';
|
||||
|
||||
const makeEngine = () => {
|
||||
const notes: Array<PianoNote> = [];
|
||||
const engine = new GenerativePianoEngine(
|
||||
gardenAudioConfig,
|
||||
appConfig.audioEngine,
|
||||
(note) => {
|
||||
notes.push(note);
|
||||
}
|
||||
);
|
||||
|
||||
return { engine, notes };
|
||||
};
|
||||
|
||||
const getBeatSeconds = (): number => 60 / gardenAudioConfig.rhythm.bpm;
|
||||
|
||||
const getBeatsPerBar = (): number =>
|
||||
Math.round(
|
||||
gardenAudioConfig.rhythm.stepsPerBar / gardenAudioConfig.rhythm.stepsPerBeat
|
||||
);
|
||||
|
||||
const renderBars = (
|
||||
engine: GenerativePianoEngine,
|
||||
activity: number,
|
||||
selectedColorIndex = 0,
|
||||
bars = 8
|
||||
) => {
|
||||
engine.renderLookahead({
|
||||
vibe: VIBE_PRESETS[0],
|
||||
now: 0,
|
||||
activity,
|
||||
selectedColorIndex: selectedColorIndex as 0 | 1 | 2,
|
||||
lookaheadSeconds: getBeatSeconds() * getBeatsPerBar() * bars,
|
||||
});
|
||||
};
|
||||
|
||||
const average = (values: Array<number>): number =>
|
||||
values.reduce((sum, value) => sum + value, 0) / values.length;
|
||||
|
||||
const uniqueStartTimes = (notes: Array<PianoNote>): Array<string> =>
|
||||
Array.from(new Set(notes.map((note) => note.startTime.toFixed(3))));
|
||||
|
||||
const countNotesBetween = (
|
||||
notes: Array<PianoNote>,
|
||||
startSeconds: number,
|
||||
endSeconds: number
|
||||
): number =>
|
||||
notes.filter((note) => note.startTime >= startSeconds && note.startTime < endSeconds)
|
||||
.length;
|
||||
|
||||
describe('GenerativePianoEngine', () => {
|
||||
it('plays quiet background music even when the garden is idle', () => {
|
||||
const { engine, notes } = makeEngine();
|
||||
|
||||
renderBars(engine, 0);
|
||||
|
||||
expect(notes.length).toBeGreaterThan(0);
|
||||
expect(notes.some((note) => note.durationSeconds > getBeatSeconds() * 6)).toBe(true);
|
||||
expect(Math.max(...notes.map((note) => note.velocity))).toBeLessThan(0.12);
|
||||
});
|
||||
|
||||
it('keeps the background sparse instead of filling every beat', () => {
|
||||
const { engine, notes } = makeEngine();
|
||||
|
||||
renderBars(engine, 0, 1, 4);
|
||||
|
||||
expect(uniqueStartTimes(notes).length).toBeLessThan(8);
|
||||
});
|
||||
|
||||
it('lets activity add density without changing the beat grid', () => {
|
||||
const idle = makeEngine();
|
||||
const active = makeEngine();
|
||||
const startDelaySeconds = 0.02;
|
||||
|
||||
renderBars(idle.engine, 0, 1, 8);
|
||||
renderBars(active.engine, 1, 1, 8);
|
||||
|
||||
expect(active.notes.length).toBeGreaterThan(idle.notes.length);
|
||||
active.notes.forEach((note) => {
|
||||
const beatsFromStart = (note.startTime - startDelaySeconds) / getBeatSeconds();
|
||||
expect(Math.abs(beatsFromStart - Math.round(beatsFromStart))).toBeLessThan(0.001);
|
||||
});
|
||||
});
|
||||
|
||||
it('uses color pools with multiple notes instead of one key per color', () => {
|
||||
([0, 1, 2] as const).forEach((selectedColorIndex) => {
|
||||
const { engine, notes } = makeEngine();
|
||||
|
||||
renderBars(engine, 1, selectedColorIndex, 16);
|
||||
|
||||
expect(new Set(notes.map((note) => note.midi)).size).toBeGreaterThan(3);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the upper color higher and wider than the lower color', () => {
|
||||
const lower = makeEngine();
|
||||
const upper = makeEngine();
|
||||
|
||||
renderBars(lower.engine, 1, 0, 16);
|
||||
renderBars(upper.engine, 1, 2, 16);
|
||||
|
||||
expect(average(upper.notes.map((note) => note.midi))).toBeGreaterThan(
|
||||
average(lower.notes.map((note) => note.midi))
|
||||
);
|
||||
expect(average(upper.notes.map((note) => note.pan))).toBeGreaterThan(
|
||||
average(lower.notes.map((note) => note.pan))
|
||||
);
|
||||
});
|
||||
|
||||
it('starts a fading brush phrase layer with each new brush gesture', () => {
|
||||
const baseline = makeEngine();
|
||||
const layered = makeEngine();
|
||||
const now = 4;
|
||||
|
||||
baseline.engine.renderLookahead({
|
||||
vibe: VIBE_PRESETS[0],
|
||||
now,
|
||||
activity: 0.35,
|
||||
selectedColorIndex: 1,
|
||||
lookaheadSeconds: 12,
|
||||
});
|
||||
|
||||
layered.engine.beginGesture();
|
||||
layered.engine.recordStroke({
|
||||
vibe: VIBE_PRESETS[0],
|
||||
now,
|
||||
activity: 0.85,
|
||||
selectedColorIndex: 1,
|
||||
mirrorAmount: 0.45,
|
||||
});
|
||||
layered.engine.renderLookahead({
|
||||
vibe: VIBE_PRESETS[0],
|
||||
now,
|
||||
activity: 0.35,
|
||||
selectedColorIndex: 1,
|
||||
lookaheadSeconds: 12,
|
||||
});
|
||||
|
||||
const earlyExtra =
|
||||
countNotesBetween(layered.notes, now + 1, now + 5) -
|
||||
countNotesBetween(baseline.notes, now + 1, now + 5);
|
||||
const lateExtra =
|
||||
countNotesBetween(layered.notes, now + 10.5, now + 12) -
|
||||
countNotesBetween(baseline.notes, now + 10.5, now + 12);
|
||||
|
||||
expect(earlyExtra).toBeGreaterThan(2);
|
||||
expect(lateExtra).toBe(0);
|
||||
});
|
||||
|
||||
it('makes brush phrase layers denser at higher mirror amounts', () => {
|
||||
const lowMirror = makeEngine();
|
||||
const highMirror = makeEngine();
|
||||
const now = 4;
|
||||
|
||||
lowMirror.engine.beginGesture();
|
||||
lowMirror.engine.recordStroke({
|
||||
vibe: VIBE_PRESETS[0],
|
||||
now,
|
||||
activity: 0.85,
|
||||
selectedColorIndex: 2,
|
||||
mirrorAmount: 0,
|
||||
});
|
||||
lowMirror.engine.renderLookahead({
|
||||
vibe: VIBE_PRESETS[0],
|
||||
now,
|
||||
activity: 0.35,
|
||||
selectedColorIndex: 2,
|
||||
lookaheadSeconds: 9,
|
||||
});
|
||||
|
||||
highMirror.engine.beginGesture();
|
||||
highMirror.engine.recordStroke({
|
||||
vibe: VIBE_PRESETS[0],
|
||||
now,
|
||||
activity: 0.85,
|
||||
selectedColorIndex: 2,
|
||||
mirrorAmount: 1,
|
||||
});
|
||||
highMirror.engine.renderLookahead({
|
||||
vibe: VIBE_PRESETS[0],
|
||||
now,
|
||||
activity: 0.35,
|
||||
selectedColorIndex: 2,
|
||||
lookaheadSeconds: 9,
|
||||
});
|
||||
|
||||
expect(highMirror.notes.length).toBeGreaterThan(lowMirror.notes.length);
|
||||
});
|
||||
|
||||
it('plays one immediate touch note and throttles later stroke accents', () => {
|
||||
const { engine, notes } = makeEngine();
|
||||
const now = 4;
|
||||
|
||||
engine.beginGesture();
|
||||
engine.recordStroke({
|
||||
vibe: VIBE_PRESETS[0],
|
||||
now,
|
||||
activity: 0.9,
|
||||
selectedColorIndex: 1,
|
||||
});
|
||||
engine.recordStroke({
|
||||
vibe: VIBE_PRESETS[0],
|
||||
now: now + 1,
|
||||
activity: 0.95,
|
||||
selectedColorIndex: 1,
|
||||
});
|
||||
|
||||
expect(notes).toHaveLength(1);
|
||||
expect(notes[0].startTime).toBe(now);
|
||||
|
||||
engine.recordStroke({
|
||||
vibe: VIBE_PRESETS[0],
|
||||
now: now + 6,
|
||||
activity: 0.95,
|
||||
selectedColorIndex: 1,
|
||||
});
|
||||
|
||||
expect(notes).toHaveLength(2);
|
||||
expect(new Set(notes.map((note) => note.midi)).size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('is deterministic for the same musical inputs', () => {
|
||||
const first = makeEngine();
|
||||
const second = makeEngine();
|
||||
|
||||
renderBars(first.engine, 0.78, 2, 16);
|
||||
renderBars(second.engine, 0.78, 2, 16);
|
||||
|
||||
expect(second.notes).toEqual(first.notes);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,21 +1,27 @@
|
|||
import type { GardenAudioEngineConfig } from '../config';
|
||||
import { GardenAudioGraph } from './garden-audio-graph';
|
||||
import { NoiseBurst } from './garden-audio-types';
|
||||
import { clamp } from '../utils/math';
|
||||
import type { GardenAudioGraph } from './garden-audio-graph';
|
||||
import type { NoiseBurst } from './garden-audio-types';
|
||||
|
||||
const noiseBurstTuning = {
|
||||
attackSeconds: 0.004,
|
||||
filterQ: 1.4,
|
||||
offsetRandomSeconds: 0.4,
|
||||
scheduleAheadSeconds: 0.002,
|
||||
silentGain: 0.0001,
|
||||
filterType: 'bandpass' as BiquadFilterType,
|
||||
};
|
||||
|
||||
export class NoiseBurstPlayer {
|
||||
public constructor(
|
||||
private readonly engineConfig: GardenAudioEngineConfig,
|
||||
private readonly graph: GardenAudioGraph
|
||||
) {}
|
||||
public constructor(private readonly graph: GardenAudioGraph) {}
|
||||
|
||||
public play({ startTime, durationSeconds, gain, filterHz, pan }: NoiseBurst): void {
|
||||
const { context, eventBus, noiseBuffer } = this.graph;
|
||||
if (!context || !eventBus || !noiseBuffer) {
|
||||
const { context, noiseBus, noiseBuffer } = this.graph;
|
||||
if (!context || !noiseBus || !noiseBuffer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scheduledStart = Math.max(
|
||||
context.currentTime + this.engineConfig.noiseBurst.scheduleAheadSeconds,
|
||||
context.currentTime + noiseBurstTuning.scheduleAheadSeconds,
|
||||
startTime
|
||||
);
|
||||
const source = context.createBufferSource();
|
||||
|
|
@ -25,28 +31,21 @@ export class NoiseBurstPlayer {
|
|||
const stopAt = scheduledStart + durationSeconds;
|
||||
|
||||
source.buffer = noiseBuffer;
|
||||
filter.type = 'bandpass';
|
||||
filter.type = noiseBurstTuning.filterType;
|
||||
filter.frequency.setValueAtTime(filterHz, scheduledStart);
|
||||
filter.Q.value = this.engineConfig.noiseBurst.filterQ;
|
||||
envelope.gain.setValueAtTime(this.engineConfig.noiseBurst.silentGain, scheduledStart);
|
||||
filter.Q.value = noiseBurstTuning.filterQ;
|
||||
envelope.gain.setValueAtTime(noiseBurstTuning.silentGain, scheduledStart);
|
||||
envelope.gain.exponentialRampToValueAtTime(
|
||||
Math.max(this.engineConfig.noiseBurst.silentGain, gain),
|
||||
scheduledStart + this.engineConfig.noiseBurst.attackSeconds
|
||||
Math.max(noiseBurstTuning.silentGain, gain),
|
||||
scheduledStart + noiseBurstTuning.attackSeconds
|
||||
);
|
||||
envelope.gain.exponentialRampToValueAtTime(
|
||||
this.engineConfig.noiseBurst.silentGain,
|
||||
stopAt
|
||||
);
|
||||
panner.pan.setValueAtTime(pan, scheduledStart);
|
||||
|
||||
envelope.gain.exponentialRampToValueAtTime(noiseBurstTuning.silentGain, stopAt);
|
||||
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
|
||||
source.connect(filter);
|
||||
filter.connect(envelope);
|
||||
envelope.connect(panner);
|
||||
panner.connect(eventBus);
|
||||
source.start(
|
||||
scheduledStart,
|
||||
Math.random() * this.engineConfig.noiseBurst.offsetRandomSeconds
|
||||
);
|
||||
panner.connect(noiseBus);
|
||||
source.start(scheduledStart, Math.random() * noiseBurstTuning.offsetRandomSeconds);
|
||||
source.stop(stopAt);
|
||||
source.addEventListener(
|
||||
'ended',
|
||||
|
|
|
|||
|
|
@ -1,45 +1,52 @@
|
|||
import type { GardenAudioEngineConfig } from '../config';
|
||||
import { clamp, clamp01 } from '../utils/clamp';
|
||||
import { GardenAudioConfig } from './garden-audio-config';
|
||||
import { GardenAudioGraph } from './garden-audio-graph';
|
||||
import { ActivePianoVoice, LoadedPianoSample, PianoNote } from './garden-audio-types';
|
||||
import { pianoSampleDefinitions } from './piano-samples';
|
||||
import { clamp, clamp01 } from '../utils/math';
|
||||
import type { GardenAudioConfig } from './garden-audio-config';
|
||||
import type { GardenAudioGraph } from './garden-audio-graph';
|
||||
import { PITCH_SEMITONES_PER_OCTAVE } from './garden-audio-music';
|
||||
import type { LoadedPianoSample, PianoNote } from './garden-audio-types';
|
||||
import { getLoadedPianoSamples, loadPianoSamples } from './piano-samples';
|
||||
|
||||
export const PIANO_SCHEDULE_AHEAD_SECONDS = 0.002;
|
||||
|
||||
interface ActivePianoVoice {
|
||||
gain: GainNode;
|
||||
source: AudioScheduledSourceNode;
|
||||
stopAt: number;
|
||||
}
|
||||
|
||||
const pianoSamplerTuning = {
|
||||
filterType: 'lowpass' as BiquadFilterType,
|
||||
filterQ: 0.7,
|
||||
minDurationSeconds: 0.08,
|
||||
minFadeSeconds: 0.08,
|
||||
minGain: 0.0001,
|
||||
tailStopExtraSeconds: 0.05,
|
||||
voiceStealFadeSeconds: 0.025,
|
||||
voiceStealStopSeconds: 0.05,
|
||||
};
|
||||
|
||||
export class PianoSampler {
|
||||
private sampleLoadPromise: Promise<void> | null = null;
|
||||
private samples: Array<LoadedPianoSample> = [];
|
||||
private activeVoices: Array<ActivePianoVoice> = [];
|
||||
|
||||
public constructor(
|
||||
private readonly config: GardenAudioConfig,
|
||||
private readonly engineConfig: GardenAudioEngineConfig,
|
||||
private readonly graph: GardenAudioGraph
|
||||
) {}
|
||||
|
||||
public async load(context: AudioContext): Promise<void> {
|
||||
if (this.sampleLoadPromise) {
|
||||
return this.sampleLoadPromise;
|
||||
public load(context: BaseAudioContext): Promise<void> {
|
||||
if (this.samples.length > 0) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
this.sampleLoadPromise = Promise.all(
|
||||
pianoSampleDefinitions.map(async (sample) => {
|
||||
const response = await fetch(sample.url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unable to load piano sample ${sample.url}`);
|
||||
}
|
||||
const audioData = await response.arrayBuffer();
|
||||
const buffer = await context.decodeAudioData(audioData);
|
||||
return { midi: sample.midi, buffer };
|
||||
})
|
||||
)
|
||||
.then((samples) => {
|
||||
this.samples = samples.sort((a, b) => a.midi - b.midi);
|
||||
})
|
||||
.catch(() => {
|
||||
this.samples = [];
|
||||
});
|
||||
const loadedSamples = getLoadedPianoSamples();
|
||||
if (loadedSamples) {
|
||||
this.setSamples(loadedSamples);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return this.sampleLoadPromise;
|
||||
return loadPianoSamples(context).then((samples) => {
|
||||
this.setSamples(samples);
|
||||
});
|
||||
}
|
||||
|
||||
public play({
|
||||
|
|
@ -48,10 +55,13 @@ export class PianoSampler {
|
|||
startTime,
|
||||
durationSeconds,
|
||||
pan,
|
||||
role,
|
||||
delaySend = 0,
|
||||
lowpassHz = this.config.piano.lowpassHz,
|
||||
sustainSeconds: profileSustainSeconds = this.config.piano.sustainSeconds,
|
||||
}: PianoNote): void {
|
||||
const { context, eventBus, delayInput } = this.graph;
|
||||
const { context } = this.graph;
|
||||
const eventBus = this.graph.getPianoBus(role);
|
||||
if (!context || !eventBus) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -62,25 +72,105 @@ export class PianoSampler {
|
|||
}
|
||||
|
||||
const scheduledStart = Math.max(
|
||||
context.currentTime + this.engineConfig.piano.scheduleAheadSeconds,
|
||||
context.currentTime + PIANO_SCHEDULE_AHEAD_SECONDS,
|
||||
startTime
|
||||
);
|
||||
const noteVelocity = clamp01(velocity);
|
||||
const noteGainValue = Math.max(
|
||||
this.engineConfig.piano.minGain,
|
||||
this.config.piano.gain * noteVelocity
|
||||
);
|
||||
const noteGainValue = this.computeNoteGain(noteVelocity);
|
||||
const sustainSeconds =
|
||||
this.config.piano.sustainSeconds *
|
||||
(this.engineConfig.piano.sustainBase +
|
||||
noteVelocity * this.engineConfig.piano.sustainVelocityRange);
|
||||
profileSustainSeconds *
|
||||
(this.config.piano.sustainBase +
|
||||
noteVelocity * this.config.piano.sustainVelocityRange);
|
||||
const sustainAt =
|
||||
scheduledStart +
|
||||
Math.max(this.engineConfig.piano.minDurationSeconds, durationSeconds);
|
||||
scheduledStart + Math.max(pianoSamplerTuning.minDurationSeconds, durationSeconds);
|
||||
const releaseAt = sustainAt + sustainSeconds;
|
||||
const releaseSeconds = this.config.piano.releaseSeconds;
|
||||
const stopAt = releaseAt + releaseSeconds;
|
||||
const stopAt = releaseAt + this.config.piano.releaseSeconds;
|
||||
const source = context.createBufferSource();
|
||||
|
||||
source.buffer = sample.buffer;
|
||||
source.playbackRate.setValueAtTime(
|
||||
Math.pow(2, (midi - sample.midi) / PITCH_SEMITONES_PER_OCTAVE),
|
||||
scheduledStart
|
||||
);
|
||||
|
||||
this.scheduleVoice({
|
||||
source,
|
||||
scheduledStart,
|
||||
stopAt,
|
||||
pan,
|
||||
lowpassHz,
|
||||
delaySend,
|
||||
eventBus,
|
||||
configureGainEnvelope: (gain) => {
|
||||
gain.gain.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart);
|
||||
gain.gain.exponentialRampToValueAtTime(
|
||||
noteGainValue,
|
||||
scheduledStart + this.config.piano.gainAttackSeconds
|
||||
);
|
||||
gain.gain.setTargetAtTime(
|
||||
Math.max(
|
||||
pianoSamplerTuning.minGain,
|
||||
noteGainValue * this.config.piano.sustainLevel
|
||||
),
|
||||
sustainAt,
|
||||
Math.max(
|
||||
pianoSamplerTuning.minFadeSeconds,
|
||||
sustainSeconds * this.config.piano.sustainBase
|
||||
)
|
||||
);
|
||||
gain.gain.setTargetAtTime(
|
||||
pianoSamplerTuning.minGain,
|
||||
releaseAt,
|
||||
this.config.piano.releaseSeconds
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public stopAll(): void {
|
||||
const context = this.graph.context;
|
||||
if (!context) {
|
||||
this.activeVoices = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const now = context.currentTime;
|
||||
|
||||
this.activeVoices.forEach((voice) => {
|
||||
this.stopVoice(voice, now);
|
||||
});
|
||||
this.activeVoices = [];
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.samples = [];
|
||||
this.activeVoices = [];
|
||||
}
|
||||
|
||||
private scheduleVoice({
|
||||
source,
|
||||
scheduledStart,
|
||||
stopAt,
|
||||
pan,
|
||||
lowpassHz,
|
||||
delaySend,
|
||||
eventBus,
|
||||
configureGainEnvelope,
|
||||
}: {
|
||||
source: AudioScheduledSourceNode;
|
||||
scheduledStart: number;
|
||||
stopAt: number;
|
||||
pan: number;
|
||||
lowpassHz: number;
|
||||
delaySend: number;
|
||||
eventBus: GainNode;
|
||||
configureGainEnvelope: (gain: GainNode) => void;
|
||||
}): void {
|
||||
const { context, delayInput } = this.graph;
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filter = context.createBiquadFilter();
|
||||
const gain = context.createGain();
|
||||
const panner = context.createStereoPanner();
|
||||
|
|
@ -88,49 +178,17 @@ export class PianoSampler {
|
|||
|
||||
this.trimActiveVoices(scheduledStart);
|
||||
while (this.activeVoices.length >= this.config.piano.maxVoices) {
|
||||
const oldest = this.activeVoices.shift();
|
||||
oldest?.gain.gain.cancelScheduledValues(scheduledStart);
|
||||
oldest?.gain.gain.setTargetAtTime(
|
||||
this.engineConfig.piano.minGain,
|
||||
scheduledStart,
|
||||
this.engineConfig.piano.voiceStealFadeSeconds
|
||||
);
|
||||
oldest?.source.stop(scheduledStart + this.engineConfig.piano.voiceStealStopSeconds);
|
||||
this.stopVoice(this.activeVoices.shift() as ActivePianoVoice, scheduledStart);
|
||||
}
|
||||
|
||||
source.buffer = sample.buffer;
|
||||
source.playbackRate.setValueAtTime(
|
||||
Math.pow(2, (midi - sample.midi) / this.engineConfig.piano.pitchSemitonesPerOctave),
|
||||
scheduledStart
|
||||
);
|
||||
filter.type = 'lowpass';
|
||||
filter.type = pianoSamplerTuning.filterType;
|
||||
filter.frequency.setValueAtTime(
|
||||
clamp(
|
||||
lowpassHz,
|
||||
this.engineConfig.piano.lowpassMinHz,
|
||||
this.engineConfig.piano.lowpassMaxHz
|
||||
),
|
||||
clamp(lowpassHz, this.config.piano.lowpassMinHz, this.config.piano.lowpassMaxHz),
|
||||
scheduledStart
|
||||
);
|
||||
filter.Q.value = this.engineConfig.piano.filterQ;
|
||||
gain.gain.setValueAtTime(this.engineConfig.piano.minGain, scheduledStart);
|
||||
gain.gain.exponentialRampToValueAtTime(
|
||||
noteGainValue,
|
||||
scheduledStart + this.engineConfig.piano.gainAttackSeconds
|
||||
);
|
||||
gain.gain.setTargetAtTime(
|
||||
Math.max(
|
||||
this.engineConfig.piano.minGain,
|
||||
noteGainValue * this.config.piano.sustainLevel
|
||||
),
|
||||
sustainAt,
|
||||
Math.max(
|
||||
this.engineConfig.piano.minFadeSeconds,
|
||||
sustainSeconds * this.engineConfig.piano.sustainBase
|
||||
)
|
||||
);
|
||||
gain.gain.setTargetAtTime(this.engineConfig.piano.minGain, releaseAt, releaseSeconds);
|
||||
filter.Q.value = pianoSamplerTuning.filterQ;
|
||||
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
|
||||
configureGainEnvelope(gain);
|
||||
|
||||
source.connect(filter);
|
||||
filter.connect(gain);
|
||||
|
|
@ -145,8 +203,8 @@ export class PianoSampler {
|
|||
}
|
||||
|
||||
source.start(scheduledStart);
|
||||
source.stop(stopAt + this.engineConfig.piano.tailStopExtraSeconds);
|
||||
this.activeVoices.push({ gain, source, startAt: scheduledStart, stopAt });
|
||||
source.stop(stopAt + pianoSamplerTuning.tailStopExtraSeconds);
|
||||
this.activeVoices.push({ gain, source, stopAt });
|
||||
|
||||
source.addEventListener(
|
||||
'ended',
|
||||
|
|
@ -162,10 +220,11 @@ export class PianoSampler {
|
|||
);
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.sampleLoadPromise = null;
|
||||
this.samples = [];
|
||||
this.activeVoices = [];
|
||||
private computeNoteGain(velocity: number, scale = 1): number {
|
||||
return Math.max(
|
||||
pianoSamplerTuning.minGain,
|
||||
this.config.piano.gain * velocity * scale
|
||||
);
|
||||
}
|
||||
|
||||
private findNearestSample(midi: number): LoadedPianoSample | null {
|
||||
|
|
@ -181,4 +240,21 @@ export class PianoSampler {
|
|||
private trimActiveVoices(now: number): void {
|
||||
this.activeVoices = this.activeVoices.filter((voice) => voice.stopAt > now);
|
||||
}
|
||||
|
||||
private stopVoice(voice: ActivePianoVoice, now: number): void {
|
||||
const stopAt = now + pianoSamplerTuning.voiceStealStopSeconds;
|
||||
|
||||
voice.gain.gain.cancelScheduledValues(now);
|
||||
voice.gain.gain.setTargetAtTime(
|
||||
pianoSamplerTuning.minGain,
|
||||
now,
|
||||
pianoSamplerTuning.voiceStealFadeSeconds
|
||||
);
|
||||
voice.stopAt = stopAt;
|
||||
voice.source.stop(stopAt);
|
||||
}
|
||||
|
||||
private setSamples(samples: Array<LoadedPianoSample>): void {
|
||||
this.samples = samples.slice().sort((a, b) => a.midi - b.midi);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,46 +1,207 @@
|
|||
import type { LoadedPianoSample } from './garden-audio-types';
|
||||
import a0SampleUrl from './samples/A0v12.m4a?url&no-inline';
|
||||
import a1SampleUrl from './samples/A1v12.m4a?url&no-inline';
|
||||
import a2SampleUrl from './samples/A2v12.m4a?url&no-inline';
|
||||
import a3SampleUrl from './samples/A3v12.m4a?url&no-inline';
|
||||
import a4SampleUrl from './samples/A4v12.m4a?url&no-inline';
|
||||
import a5SampleUrl from './samples/A5v12.m4a?url&no-inline';
|
||||
import a6SampleUrl from './samples/A6v12.m4a?url&no-inline';
|
||||
import a7SampleUrl from './samples/A7v12.m4a?url&no-inline';
|
||||
import c1SampleUrl from './samples/C1v12.m4a?url&no-inline';
|
||||
import c2SampleUrl from './samples/C2v12.m4a?url&no-inline';
|
||||
import c3SampleUrl from './samples/C3v12.m4a?url&no-inline';
|
||||
import c4SampleUrl from './samples/C4v12.m4a?url&no-inline';
|
||||
import c5SampleUrl from './samples/C5v12.m4a?url&no-inline';
|
||||
import c6SampleUrl from './samples/C6v12.m4a?url&no-inline';
|
||||
import c7SampleUrl from './samples/C7v12.m4a?url&no-inline';
|
||||
import c8SampleUrl from './samples/C8v12.m4a?url&no-inline';
|
||||
import dSharp1SampleUrl from './samples/Dsharp1v12.m4a?url&no-inline';
|
||||
import dSharp2SampleUrl from './samples/Dsharp2v12.m4a?url&no-inline';
|
||||
import dSharp3SampleUrl from './samples/Dsharp3v12.m4a?url&no-inline';
|
||||
import dSharp4SampleUrl from './samples/Dsharp4v12.m4a?url&no-inline';
|
||||
import dSharp5SampleUrl from './samples/Dsharp5v12.m4a?url&no-inline';
|
||||
import dSharp6SampleUrl from './samples/Dsharp6v12.m4a?url&no-inline';
|
||||
import dSharp7SampleUrl from './samples/Dsharp7v12.m4a?url&no-inline';
|
||||
import fSharp1SampleUrl from './samples/Fsharp1v12.m4a?url&no-inline';
|
||||
import fSharp2SampleUrl from './samples/Fsharp2v12.m4a?url&no-inline';
|
||||
import fSharp3SampleUrl from './samples/Fsharp3v12.m4a?url&no-inline';
|
||||
import fSharp4SampleUrl from './samples/Fsharp4v12.m4a?url&no-inline';
|
||||
import fSharp5SampleUrl from './samples/Fsharp5v12.m4a?url&no-inline';
|
||||
import fSharp6SampleUrl from './samples/Fsharp6v12.m4a?url&no-inline';
|
||||
import fSharp7SampleUrl from './samples/Fsharp7v12.m4a?url&no-inline';
|
||||
|
||||
interface PianoSampleDefinition {
|
||||
midi: number;
|
||||
path: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
const sampleBaseUrl = `${import.meta.env.BASE_URL}audio/piano/`;
|
||||
export interface PianoSampleLoadProgress {
|
||||
loadedCount: number;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
const sampleFiles: Array<[fileName: string, midi: number]> = [
|
||||
['A0v12.m4a', 21],
|
||||
['C1v12.m4a', 24],
|
||||
['Dsharp1v12.m4a', 27],
|
||||
['Fsharp1v12.m4a', 30],
|
||||
['A1v12.m4a', 33],
|
||||
['C2v12.m4a', 36],
|
||||
['Dsharp2v12.m4a', 39],
|
||||
['Fsharp2v12.m4a', 42],
|
||||
['A2v12.m4a', 45],
|
||||
['C3v12.m4a', 48],
|
||||
['Dsharp3v12.m4a', 51],
|
||||
['Fsharp3v12.m4a', 54],
|
||||
['A3v12.m4a', 57],
|
||||
['C4v12.m4a', 60],
|
||||
['Dsharp4v12.m4a', 63],
|
||||
['Fsharp4v12.m4a', 66],
|
||||
['A4v12.m4a', 69],
|
||||
['C5v12.m4a', 72],
|
||||
['Dsharp5v12.m4a', 75],
|
||||
['Fsharp5v12.m4a', 78],
|
||||
['A5v12.m4a', 81],
|
||||
['C6v12.m4a', 84],
|
||||
['Dsharp6v12.m4a', 87],
|
||||
['Fsharp6v12.m4a', 90],
|
||||
['A6v12.m4a', 93],
|
||||
['C7v12.m4a', 96],
|
||||
['Dsharp7v12.m4a', 99],
|
||||
['Fsharp7v12.m4a', 102],
|
||||
['A7v12.m4a', 105],
|
||||
['C8v12.m4a', 108],
|
||||
const pianoSampleDefinitions: Array<PianoSampleDefinition> = [
|
||||
{ url: a0SampleUrl, path: './samples/A0v12.m4a', midi: 21 },
|
||||
{ url: c1SampleUrl, path: './samples/C1v12.m4a', midi: 24 },
|
||||
{ url: dSharp1SampleUrl, path: './samples/Dsharp1v12.m4a', midi: 27 },
|
||||
{ url: fSharp1SampleUrl, path: './samples/Fsharp1v12.m4a', midi: 30 },
|
||||
{ url: a1SampleUrl, path: './samples/A1v12.m4a', midi: 33 },
|
||||
{ url: c2SampleUrl, path: './samples/C2v12.m4a', midi: 36 },
|
||||
{ url: dSharp2SampleUrl, path: './samples/Dsharp2v12.m4a', midi: 39 },
|
||||
{ url: fSharp2SampleUrl, path: './samples/Fsharp2v12.m4a', midi: 42 },
|
||||
{ url: a2SampleUrl, path: './samples/A2v12.m4a', midi: 45 },
|
||||
{ url: c3SampleUrl, path: './samples/C3v12.m4a', midi: 48 },
|
||||
{ url: dSharp3SampleUrl, path: './samples/Dsharp3v12.m4a', midi: 51 },
|
||||
{ url: fSharp3SampleUrl, path: './samples/Fsharp3v12.m4a', midi: 54 },
|
||||
{ url: a3SampleUrl, path: './samples/A3v12.m4a', midi: 57 },
|
||||
{ url: c4SampleUrl, path: './samples/C4v12.m4a', midi: 60 },
|
||||
{ url: dSharp4SampleUrl, path: './samples/Dsharp4v12.m4a', midi: 63 },
|
||||
{ url: fSharp4SampleUrl, path: './samples/Fsharp4v12.m4a', midi: 66 },
|
||||
{ url: a4SampleUrl, path: './samples/A4v12.m4a', midi: 69 },
|
||||
{ url: c5SampleUrl, path: './samples/C5v12.m4a', midi: 72 },
|
||||
{ url: dSharp5SampleUrl, path: './samples/Dsharp5v12.m4a', midi: 75 },
|
||||
{ url: fSharp5SampleUrl, path: './samples/Fsharp5v12.m4a', midi: 78 },
|
||||
{ url: a5SampleUrl, path: './samples/A5v12.m4a', midi: 81 },
|
||||
{ url: c6SampleUrl, path: './samples/C6v12.m4a', midi: 84 },
|
||||
{ url: dSharp6SampleUrl, path: './samples/Dsharp6v12.m4a', midi: 87 },
|
||||
{ url: fSharp6SampleUrl, path: './samples/Fsharp6v12.m4a', midi: 90 },
|
||||
{ url: a6SampleUrl, path: './samples/A6v12.m4a', midi: 93 },
|
||||
{ url: c7SampleUrl, path: './samples/C7v12.m4a', midi: 96 },
|
||||
{ url: dSharp7SampleUrl, path: './samples/Dsharp7v12.m4a', midi: 99 },
|
||||
{ url: fSharp7SampleUrl, path: './samples/Fsharp7v12.m4a', midi: 102 },
|
||||
{ url: a7SampleUrl, path: './samples/A7v12.m4a', midi: 105 },
|
||||
{ url: c8SampleUrl, path: './samples/C8v12.m4a', midi: 108 },
|
||||
];
|
||||
|
||||
export const pianoSampleDefinitions: Array<PianoSampleDefinition> = sampleFiles
|
||||
.map(([fileName, midi]) => ({
|
||||
midi,
|
||||
url: `${sampleBaseUrl}${fileName}`,
|
||||
}))
|
||||
.sort((a, b) => a.midi - b.midi);
|
||||
let loadedPianoSamples: Array<LoadedPianoSample> | null = null;
|
||||
let pianoSampleLoadPromise: Promise<Array<LoadedPianoSample>> | null = null;
|
||||
|
||||
const sampleLoadTuning = {
|
||||
concurrency: 4,
|
||||
sampleTimeoutMs: 15_000,
|
||||
};
|
||||
|
||||
export const preloadPianoSamples = (
|
||||
onProgress?: (progress: PianoSampleLoadProgress) => void
|
||||
): Promise<Array<LoadedPianoSample>> => {
|
||||
const OfflineAudioContextConstructor = globalThis.OfflineAudioContext;
|
||||
|
||||
if (!OfflineAudioContextConstructor) {
|
||||
return Promise.reject(
|
||||
new Error('OfflineAudioContext is required to preload piano samples.')
|
||||
);
|
||||
}
|
||||
|
||||
// Decoding ignores these, but the constructor demands real numbers.
|
||||
const decodeContext = new OfflineAudioContextConstructor(1, 1, 48_000);
|
||||
return loadPianoSamples(decodeContext, onProgress);
|
||||
};
|
||||
|
||||
export const loadPianoSamples = (
|
||||
decodeContext: BaseAudioContext,
|
||||
onProgress?: (progress: PianoSampleLoadProgress) => void
|
||||
): Promise<Array<LoadedPianoSample>> => {
|
||||
if (loadedPianoSamples) {
|
||||
onProgress?.({
|
||||
loadedCount: loadedPianoSamples.length,
|
||||
totalCount: pianoSampleDefinitions.length,
|
||||
});
|
||||
return Promise.resolve([...loadedPianoSamples]);
|
||||
}
|
||||
|
||||
if (pianoSampleLoadPromise) {
|
||||
return pianoSampleLoadPromise;
|
||||
}
|
||||
|
||||
let loadedCount = 0;
|
||||
const totalCount = pianoSampleDefinitions.length;
|
||||
onProgress?.({ loadedCount, totalCount });
|
||||
|
||||
pianoSampleLoadPromise = loadPianoSampleBatch(
|
||||
pianoSampleDefinitions,
|
||||
async (sample) => {
|
||||
try {
|
||||
return await withTimeout(
|
||||
(signal) => loadPianoSample(decodeContext, sample, signal),
|
||||
sampleLoadTuning.sampleTimeoutMs
|
||||
);
|
||||
} finally {
|
||||
loadedCount += 1;
|
||||
onProgress?.({ loadedCount, totalCount });
|
||||
}
|
||||
}
|
||||
).then(
|
||||
(samples) => {
|
||||
loadedPianoSamples = samples.sort((a, b) => a.midi - b.midi);
|
||||
if (loadedPianoSamples.length !== pianoSampleDefinitions.length) {
|
||||
throw new Error(
|
||||
`Loaded ${loadedPianoSamples.length}/${pianoSampleDefinitions.length} piano samples.`
|
||||
);
|
||||
}
|
||||
return [...loadedPianoSamples];
|
||||
},
|
||||
(error: unknown) => {
|
||||
pianoSampleLoadPromise = null;
|
||||
throw error;
|
||||
}
|
||||
);
|
||||
|
||||
return pianoSampleLoadPromise;
|
||||
};
|
||||
|
||||
export const getLoadedPianoSamples = (): Array<LoadedPianoSample> | null =>
|
||||
loadedPianoSamples ? [...loadedPianoSamples] : null;
|
||||
|
||||
const loadPianoSample = async (
|
||||
decodeContext: BaseAudioContext,
|
||||
sample: PianoSampleDefinition,
|
||||
signal: AbortSignal
|
||||
): Promise<LoadedPianoSample> => {
|
||||
const response = await fetch(sample.url, { signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unable to load piano sample ${sample.path}`);
|
||||
}
|
||||
|
||||
const audioData = await response.arrayBuffer();
|
||||
const buffer = await decodeContext.decodeAudioData(audioData);
|
||||
return { midi: sample.midi, buffer };
|
||||
};
|
||||
|
||||
const loadPianoSampleBatch = async (
|
||||
samples: Array<PianoSampleDefinition>,
|
||||
loadSample: (sample: PianoSampleDefinition) => Promise<LoadedPianoSample>
|
||||
): Promise<Array<LoadedPianoSample>> => {
|
||||
const results: Array<LoadedPianoSample> = [];
|
||||
|
||||
for (let index = 0; index < samples.length; index += sampleLoadTuning.concurrency) {
|
||||
const batch = samples.slice(index, index + sampleLoadTuning.concurrency);
|
||||
const batchResults = await Promise.all(batch.map((sample) => loadSample(sample)));
|
||||
results.push(...batchResults);
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
const withTimeout = <T>(
|
||||
operation: (signal: AbortSignal) => Promise<T>,
|
||||
timeoutMs: number
|
||||
): Promise<T> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const controller = new AbortController();
|
||||
const timeout = globalThis.setTimeout(() => {
|
||||
controller.abort();
|
||||
reject(new Error('Timed out while loading a piano sample.'));
|
||||
}, timeoutMs);
|
||||
|
||||
operation(controller.signal).then(
|
||||
(value) => {
|
||||
globalThis.clearTimeout(timeout);
|
||||
resolve(value);
|
||||
},
|
||||
(error: unknown) => {
|
||||
globalThis.clearTimeout(timeout);
|
||||
reject(error);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
|
|
|||
256
src/config.ts
|
|
@ -1,171 +1,34 @@
|
|||
import { runtimeSettings } from './config/runtime-settings';
|
||||
import { createGardenAudioConfig } from './audio/garden-audio-config';
|
||||
import { defaultSettings } from './config/default-settings';
|
||||
import { runtimeControls } from './config/runtime-controls';
|
||||
import type { GardenAppConfig } from './config/types';
|
||||
import { audioVibes, defaultVibeId, vibePresets } from './config/vibe-presets';
|
||||
import { defaultVibeId, vibePresets } from './config/vibe-presets';
|
||||
import { APP_STORAGE_KEYS, DEFAULT_AUDIO_VOLUME } from './consts';
|
||||
|
||||
export {
|
||||
normalizeNumberControlValue,
|
||||
normalizeRuntimeSettings,
|
||||
} from './config/normalize-runtime-settings';
|
||||
|
||||
export type {
|
||||
AgentColorInteractionSettings,
|
||||
GardenAppConfig,
|
||||
GardenAudioEngineConfig,
|
||||
GardenRuntimeSettings,
|
||||
GardenSimulationConfig,
|
||||
GardenStorageConfig,
|
||||
GardenVibeSettings,
|
||||
NumberControlConfig,
|
||||
RuntimeSettingControlConfig,
|
||||
VibePreset,
|
||||
} from './config/types';
|
||||
|
||||
export const appConfig = {
|
||||
audio: {
|
||||
masterVolume: 0.42,
|
||||
fadeInSeconds: 0.45,
|
||||
updateRampSeconds: 0.08,
|
||||
highPassFrequencyHz: 45,
|
||||
fallbackVibeId: defaultVibeId,
|
||||
compressor: {
|
||||
thresholdDb: -18,
|
||||
kneeDb: 18,
|
||||
ratio: 2.4,
|
||||
attackSeconds: 0.006,
|
||||
releaseSeconds: 0.18,
|
||||
},
|
||||
delay: {
|
||||
timeSeconds: 0.46,
|
||||
feedback: 0.12,
|
||||
wetGain: 0.044,
|
||||
},
|
||||
piano: {
|
||||
maxVoices: 24,
|
||||
gain: 0.48,
|
||||
sustainSeconds: 0.42,
|
||||
sustainLevel: 0.32,
|
||||
releaseSeconds: 0.24,
|
||||
lowpassHz: 7600,
|
||||
},
|
||||
input: {
|
||||
pressureFallback: 0.48,
|
||||
},
|
||||
rhythm: {
|
||||
bpm: 74,
|
||||
stepsPerBeat: 4,
|
||||
stepsPerBar: 16,
|
||||
lookaheadSeconds: 0.3,
|
||||
speedForFullEnergyPixelsPerSecond: 1800,
|
||||
sparseActivity: 0.055,
|
||||
},
|
||||
eraser: {
|
||||
minIntervalSeconds: 0.12,
|
||||
noiseGain: 0.028,
|
||||
filterMinHz: 650,
|
||||
filterMaxHz: 3600,
|
||||
},
|
||||
colorVoices: [
|
||||
{
|
||||
scaleDegreeOffset: 0,
|
||||
velocityMultiplier: 0.92,
|
||||
panOffset: -0.14,
|
||||
},
|
||||
{
|
||||
scaleDegreeOffset: 1,
|
||||
velocityMultiplier: 1,
|
||||
panOffset: 0,
|
||||
},
|
||||
{
|
||||
scaleDegreeOffset: 2,
|
||||
velocityMultiplier: 0.86,
|
||||
panOffset: 0.14,
|
||||
},
|
||||
],
|
||||
vibes: audioVibes,
|
||||
},
|
||||
audioEngine: {
|
||||
energy: {
|
||||
attackSeconds: 0.08,
|
||||
decaySeconds: 0.9,
|
||||
releaseSeconds: 1.15,
|
||||
strokeDecaySeconds: 0.32,
|
||||
},
|
||||
eraser: {
|
||||
canvasWidthRatioForFullSize: 0.18,
|
||||
defaultSizePixels: 96,
|
||||
durationSeconds: 0.08,
|
||||
filterPressureWeight: 0.26,
|
||||
filterSizeWeight: 0.16,
|
||||
filterSpeedWeight: 0.58,
|
||||
gainBase: 0.45,
|
||||
gainPressureWeight: 0.24,
|
||||
gainSizeWeight: 0.18,
|
||||
gainSpeedWeight: 0.38,
|
||||
},
|
||||
delay: {
|
||||
erasingActivity: 0.12,
|
||||
},
|
||||
graph: {
|
||||
closeGain: 0.0001,
|
||||
closeRampSeconds: 0.015,
|
||||
delayActivityFeedbackWeight: 0.08,
|
||||
delayFeedbackMax: 0.32,
|
||||
delayFeedbackMin: 0.04,
|
||||
delayOutputActivityWeight: 0.5,
|
||||
delayOutputBase: 0.65,
|
||||
delayTimeRampSeconds: 0.12,
|
||||
eventBusGain: 1,
|
||||
noiseMax: 1,
|
||||
noiseMin: -1,
|
||||
unlockBufferLength: 1,
|
||||
unlockSampleRate: 22050,
|
||||
},
|
||||
input: {
|
||||
distanceEnergyBase: 0.34,
|
||||
distanceEnergyScale: 0.66,
|
||||
distanceForFullEnergyPixels: 140,
|
||||
fallbackFrameSeconds: 1 / 60,
|
||||
penMinPressure: 0.56,
|
||||
strokeEnergyBase: 0.18,
|
||||
strokeEnergyPressureWeight: 0.22,
|
||||
strokeEnergySpeedWeight: 0.62,
|
||||
},
|
||||
muteGain: 0.0001,
|
||||
muteRampSeconds: 0.02,
|
||||
noiseBurst: {
|
||||
attackSeconds: 0.004,
|
||||
filterQ: 1.4,
|
||||
offsetRandomSeconds: 0.4,
|
||||
scheduleAheadSeconds: 0.002,
|
||||
silentGain: 0.0001,
|
||||
},
|
||||
piano: {
|
||||
filterQ: 0.7,
|
||||
gainAttackSeconds: 0.006,
|
||||
lowpassMaxHz: 12000,
|
||||
lowpassMinHz: 1400,
|
||||
minDurationSeconds: 0.08,
|
||||
minFadeSeconds: 0.08,
|
||||
minGain: 0.0001,
|
||||
pitchSemitonesPerOctave: 12,
|
||||
scheduleAheadSeconds: 0.002,
|
||||
sustainBase: 0.45,
|
||||
sustainVelocityRange: 0.55,
|
||||
tailStopExtraSeconds: 0.05,
|
||||
voiceStealFadeSeconds: 0.025,
|
||||
voiceStealStopSeconds: 0.05,
|
||||
},
|
||||
startDelaySeconds: 0.02,
|
||||
vibeChangeStingerMinIntervalSeconds: 0.45,
|
||||
},
|
||||
audio: createGardenAudioConfig(),
|
||||
deltaTime: {
|
||||
fpsExponentialDecayStrength: 0.01,
|
||||
maxDeltaTimeSeconds: 1 / 30,
|
||||
minDeltaTimeSeconds: 1 / 240,
|
||||
},
|
||||
export4k: {
|
||||
exportSnapshot: {
|
||||
bytesPerPixel: 4,
|
||||
height: 2160,
|
||||
jsHeapSafetyMultiplier: 1.5,
|
||||
lowMemoryDeviceGiB: 2,
|
||||
lowMemoryExportFraction: 0.08,
|
||||
filenameExtension: 'png',
|
||||
filenamePrefix: 'fleeting-garden',
|
||||
filenameSuffix: '-snapshot',
|
||||
mimeType: 'image/png',
|
||||
rowAlignmentBytes: 256,
|
||||
width: 3840,
|
||||
},
|
||||
menuHider: {
|
||||
bottomRevealDistancePx: 96,
|
||||
|
|
@ -173,6 +36,17 @@ export const appConfig = {
|
|||
hideDelayMs: 3000,
|
||||
},
|
||||
pipelines: {
|
||||
common: {
|
||||
noiseChannelSeeds: [0, 1, 2, 3],
|
||||
noiseClearValue: { r: 1, g: 1, b: 1, a: 1 },
|
||||
noiseDrawInstanceCount: 1,
|
||||
noiseDrawVertexCount: 3,
|
||||
noiseHashMultiplier: 43758.5453123,
|
||||
noiseHashX: 12.9898,
|
||||
noiseHashY: 78.233,
|
||||
noiseTextureFormat: 'r8unorm',
|
||||
noiseTextureSize: 2048,
|
||||
},
|
||||
brush: {
|
||||
maxLineCount: 240,
|
||||
},
|
||||
|
|
@ -180,33 +54,31 @@ export const appConfig = {
|
|||
minDiffusionRate: 0.000001,
|
||||
},
|
||||
eraser: {
|
||||
maxSegmentCount: 384,
|
||||
maxTextureLineCount: 384,
|
||||
segmentFloatCount: 4,
|
||||
workgroupSize: 64,
|
||||
},
|
||||
},
|
||||
runtimeSettings,
|
||||
defaultSettings,
|
||||
runtimeSettings: {
|
||||
controls: runtimeControls,
|
||||
},
|
||||
simulation: {
|
||||
budget: {
|
||||
adaptiveCapDecreaseAgentsPerSecond: 50_000,
|
||||
adaptiveCapMin: 500_000,
|
||||
fpsHeadroom: 0.95,
|
||||
fpsSmoothingNew: 0.06,
|
||||
fpsSmoothingRetain: 0.94,
|
||||
},
|
||||
brushEffectFramesPerSecond: 60,
|
||||
globalAgentCap: 10_000_000,
|
||||
clearColor: { r: 0, g: 0, b: 0, a: 0 },
|
||||
initialAgentCount: 180_000,
|
||||
// How long the source map continues to be diffused after a brush stroke ends.
|
||||
// 600 frames at ~60 FPS ≈ 10 seconds.
|
||||
sourceActiveFramesAfterWrite: 600,
|
||||
intro: {
|
||||
angleJitterRadians: Math.PI * 0.08,
|
||||
angleEaseEnd: 1,
|
||||
angleEaseStart: 0.6,
|
||||
circleMaxSideRatio: 0.46,
|
||||
circleMinSideRatio: 0.32,
|
||||
drawHintClass: 'draw-hint',
|
||||
drawHintDelayMs: 3000,
|
||||
durationSeconds: 4,
|
||||
entryJitterSideRatio: 0.035,
|
||||
fontScaleDown: 0.94,
|
||||
fontFamily: '"Open Sans", sans-serif',
|
||||
initialFontHeightRatio: 0.28,
|
||||
initialFontWidthRatio: 0.19,
|
||||
letterSpacingEm: 0.07,
|
||||
|
|
@ -218,7 +90,12 @@ export const appConfig = {
|
|||
minEntryJitterPx: 6,
|
||||
minFontSizePx: 18,
|
||||
minTargetJitterPx: 1,
|
||||
pathEasing: 'easeOutQuad' as GardenAppConfig['simulation']['intro']['pathEasing'],
|
||||
pathProgressEpsilon: 0.001,
|
||||
radialJitterRatio: 0.35,
|
||||
radialStartEpsilon: 0.001,
|
||||
resizeMinimumRemainingSeconds: 1.4,
|
||||
resizeSettleMs: 120,
|
||||
targetDelayDistanceMultiplier: 0.12,
|
||||
targetDelayMax: 0.22,
|
||||
targetDelayRandomMultiplier: 0.06,
|
||||
|
|
@ -232,21 +109,15 @@ export const appConfig = {
|
|||
},
|
||||
introMoveSpeedBaseMultiplier: 1.8,
|
||||
introMoveSpeedProgressMultiplier: 0.35,
|
||||
maxMirrorSegmentCount: 12,
|
||||
stroke: {
|
||||
angleJitterRadians: Math.PI * 0.7,
|
||||
densityMultiplier: 110,
|
||||
maxAgentCount: 2_400,
|
||||
minAgentCount: 140,
|
||||
},
|
||||
},
|
||||
storage: {
|
||||
audioMutedKey: 'fleeting-garden:audio-muted',
|
||||
vibeKey: 'fleeting-garden:vibe',
|
||||
},
|
||||
telemetry: {
|
||||
enabled: false,
|
||||
intervalMs: 1000,
|
||||
audioMutedKey: APP_STORAGE_KEYS.audioMuted,
|
||||
audioVolumeKey: APP_STORAGE_KEYS.audioVolume,
|
||||
vibeKey: APP_STORAGE_KEYS.vibe,
|
||||
},
|
||||
toolbar: {
|
||||
eraser: {
|
||||
|
|
@ -259,6 +130,7 @@ export const appConfig = {
|
|||
},
|
||||
mirror: {
|
||||
default: 1,
|
||||
fallbackSegmentName: 'slices',
|
||||
max: 12,
|
||||
min: 1,
|
||||
names: {
|
||||
|
|
@ -274,13 +146,43 @@ export const appConfig = {
|
|||
11: 'elevenths',
|
||||
12: 'twelfths',
|
||||
},
|
||||
offLabel: 'Mirror off',
|
||||
step: 1,
|
||||
},
|
||||
contrast: {
|
||||
backgroundOpacityMax: 0.82,
|
||||
brightLuminanceThreshold: 0.32,
|
||||
brightWeight: 0.65,
|
||||
bytesPerSample: 4,
|
||||
contrastOffset: 0.05,
|
||||
linearChannelBreakpoint: 0.03928,
|
||||
linearChannelDivisor: 12.92,
|
||||
linearChannelGamma: 2.4,
|
||||
linearChannelOffset: 0.055,
|
||||
linearChannelScale: 1.055,
|
||||
lowContrastThreshold: 3,
|
||||
lowContrastWeight: 1.8,
|
||||
luminanceBase: 0.11,
|
||||
luminanceBlueWeight: 0.0722,
|
||||
luminanceGreenWeight: 0.7152,
|
||||
luminanceRange: 0.28,
|
||||
luminanceRedWeight: 0.2126,
|
||||
sampleColumns: 13,
|
||||
sampleIntervalMs: 300,
|
||||
sampleRows: 7,
|
||||
whiteContrastNumerator: 1.05,
|
||||
},
|
||||
volume: {
|
||||
default: DEFAULT_AUDIO_VOLUME,
|
||||
max: 1,
|
||||
min: 0,
|
||||
step: 0.01,
|
||||
},
|
||||
},
|
||||
tuningPane: {
|
||||
expandedDepth: 1,
|
||||
showFpsOverlay: import.meta.env.DEV,
|
||||
startHidden: true,
|
||||
title: 'Garden Config',
|
||||
title: 'Garden Settings',
|
||||
},
|
||||
vibes: {
|
||||
defaultVibeId,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,6 @@
|
|||
import type { AgentColorInteractionSettings, NumberControlConfig } from './types';
|
||||
import type { NumberControlConfig } from './types';
|
||||
|
||||
const agentInteractionOptions: Record<string, number> = {
|
||||
Follow: 1,
|
||||
Avoid: -1,
|
||||
Ignore: 0,
|
||||
};
|
||||
|
||||
export const defaultColorInteractionSettings: AgentColorInteractionSettings = {
|
||||
export const colorInteractionSettings = {
|
||||
color1ToColor1: 1,
|
||||
color1ToColor2: 0,
|
||||
color1ToColor3: 0,
|
||||
|
|
@ -18,52 +12,15 @@ export const defaultColorInteractionSettings: AgentColorInteractionSettings = {
|
|||
color3ToColor3: 1,
|
||||
};
|
||||
|
||||
const hashString = (value: string): number => {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
hash ^= value.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return hash >>> 0;
|
||||
};
|
||||
|
||||
const createSeededRandom = (seed: number): (() => number) => {
|
||||
let state = seed;
|
||||
return () => {
|
||||
let value = (state += 0x6d2b79f5);
|
||||
value = Math.imul(value ^ (value >>> 15), value | 1);
|
||||
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
|
||||
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
};
|
||||
|
||||
export const createColorInteractionSettings = (
|
||||
seedSource: string
|
||||
): AgentColorInteractionSettings => {
|
||||
const random = createSeededRandom(hashString(seedSource));
|
||||
const values = Object.values(agentInteractionOptions);
|
||||
const randomInteraction = () =>
|
||||
values[Math.floor(random() * values.length)] ??
|
||||
defaultColorInteractionSettings.color1ToColor2;
|
||||
|
||||
return {
|
||||
color1ToColor1: 1,
|
||||
color1ToColor2: randomInteraction(),
|
||||
color1ToColor3: randomInteraction(),
|
||||
color2ToColor1: randomInteraction(),
|
||||
color2ToColor2: 1,
|
||||
color2ToColor3: randomInteraction(),
|
||||
color3ToColor1: randomInteraction(),
|
||||
color3ToColor2: randomInteraction(),
|
||||
color3ToColor3: 1,
|
||||
};
|
||||
};
|
||||
|
||||
export const colorInteractionControl = (label: string): NumberControlConfig => ({
|
||||
folder: 'Color Reactions',
|
||||
label,
|
||||
min: -1,
|
||||
max: 1,
|
||||
step: 1,
|
||||
options: agentInteractionOptions,
|
||||
options: {
|
||||
'Move Toward': 1,
|
||||
Ignore: 0,
|
||||
'Move Away': -1,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
86
src/config/default-settings.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { colorInteractionSettings } from './color-interactions';
|
||||
import { runtimeControls } from './runtime-controls';
|
||||
import type { GardenAppConfig } from './types';
|
||||
|
||||
// Mirrors the historical render-scale cap so the default render area stays
|
||||
// roughly equivalent to native rendering on high-DPR phones without the
|
||||
// pipeline applying its own clamp. The slider can override freely.
|
||||
const DEFAULT_DEVICE_PIXEL_RATIO_CAP = 2;
|
||||
const INTERNAL_RENDER_AREA_BOUNDS = {
|
||||
min: runtimeControls.internalRenderAreaMegapixels?.min ?? 0.5,
|
||||
max: runtimeControls.internalRenderAreaMegapixels?.max ?? 16.6,
|
||||
};
|
||||
|
||||
const computeDefaultInternalRenderAreaMegapixels = (): number => {
|
||||
const rawDpr =
|
||||
typeof window !== 'undefined' && Number.isFinite(window.devicePixelRatio)
|
||||
? window.devicePixelRatio
|
||||
: 1;
|
||||
const dpr = Math.min(Math.max(rawDpr, 1), DEFAULT_DEVICE_PIXEL_RATIO_CAP);
|
||||
const cssWidth = typeof window !== 'undefined' ? window.innerWidth : 1920;
|
||||
const cssHeight = typeof window !== 'undefined' ? window.innerHeight : 1080;
|
||||
const cssMegapixels = (Math.max(cssWidth, 1) * Math.max(cssHeight, 1)) / 1_000_000;
|
||||
return Math.min(
|
||||
INTERNAL_RENDER_AREA_BOUNDS.max,
|
||||
Math.max(INTERNAL_RENDER_AREA_BOUNDS.min, dpr * dpr * cssMegapixels)
|
||||
);
|
||||
};
|
||||
|
||||
export const defaultSettings: GardenAppConfig['defaultSettings'] = {
|
||||
...colorInteractionSettings,
|
||||
selectedColorIndex: 0,
|
||||
|
||||
turnWhenLost: 0.8,
|
||||
forwardRotationScale: 0.25,
|
||||
sensorOffsetAngle: 32,
|
||||
introNearDistanceMin: 28,
|
||||
introNearDistanceInner: 4,
|
||||
introNearSensorOffsetMultiplier: 0.75,
|
||||
introTargetAngleBlend: 0.2,
|
||||
introProgressCutoff: 0.999,
|
||||
introTurnRateMultiplier: 3.4,
|
||||
introRandomTurnMultiplier: 0.18,
|
||||
introFarMoveMultiplier: 2.65,
|
||||
introNearMoveMultiplier: 0.01,
|
||||
introStepStopDistance: 0.5,
|
||||
randomTimeScale: 0.34816,
|
||||
|
||||
diffusionRateTrails: 0.22,
|
||||
decayRateBrush: 18,
|
||||
diffusionDecayRateDivisor: 1000,
|
||||
diffusionNeighborDivisor: 8,
|
||||
brushDecayAlphaOffset: 1.001,
|
||||
brushEffectDuration: 8,
|
||||
|
||||
brushCurveResolution: 12,
|
||||
brushCurveMinBrushRadius: 1,
|
||||
brushCurveMinSegmentSpacing: 4,
|
||||
brushCurveMirrorResolutionExponent: 0.5,
|
||||
brushCurveSegmentBrushRadiusRatio: 0.65,
|
||||
brushSmoothingMinSampleDistance: 0.5,
|
||||
strokeAngleJitterRadians: Math.PI * 0.7,
|
||||
|
||||
brushAlpha: 1,
|
||||
brushDiscardThreshold: 0.02,
|
||||
brushGrainNoiseScale: 22,
|
||||
brushGrainNoiseOffsetX: 0.31,
|
||||
brushGrainNoiseOffsetY: 0.67,
|
||||
brushGrainMinStrength: 0.45,
|
||||
brushGrainMaxStrength: 1,
|
||||
|
||||
eraserClearAlpha: 0,
|
||||
eraserClearBlue: 0,
|
||||
eraserClearGreen: 0,
|
||||
eraserClearRed: 0,
|
||||
eraserLineDistanceEpsilon: 0.0001,
|
||||
eraserMaskAlphaThreshold: 0.5,
|
||||
|
||||
adaptiveCapInitial: 1_000_000,
|
||||
adaptiveCapMin: 50_000,
|
||||
internalRenderAreaMegapixels: computeDefaultInternalRenderAreaMegapixels(),
|
||||
maxAgentCount: 700_000,
|
||||
|
||||
renderTraceNormalizationFloor: 1,
|
||||
renderBrushColorBase: 1.2,
|
||||
renderBrushColorStrengthMultiplier: 1.6,
|
||||
};
|
||||
46
src/config/normalize-runtime-settings.ts
Normal 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;
|
||||
};
|
||||
121
src/config/runtime-controls.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { colorInteractionControl } from './color-interactions';
|
||||
import type { GardenAppConfig } from './types';
|
||||
|
||||
const formatPercent = (value: number): string => `${Math.round(value * 100)}%`;
|
||||
const formatRadiansAsDegrees = (value: number): string =>
|
||||
`${Math.round((value * 180) / Math.PI)} deg`;
|
||||
|
||||
export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
||||
color1ToColor1: colorInteractionControl('Color 1 Follows Color 1'),
|
||||
color1ToColor2: colorInteractionControl('Color 1 Follows Color 2'),
|
||||
color1ToColor3: colorInteractionControl('Color 1 Follows Color 3'),
|
||||
color2ToColor1: colorInteractionControl('Color 2 Follows Color 1'),
|
||||
color2ToColor2: colorInteractionControl('Color 2 Follows Color 2'),
|
||||
color2ToColor3: colorInteractionControl('Color 2 Follows Color 3'),
|
||||
color3ToColor1: colorInteractionControl('Color 3 Follows Color 1'),
|
||||
color3ToColor2: colorInteractionControl('Color 3 Follows Color 2'),
|
||||
color3ToColor3: colorInteractionControl('Color 3 Follows Color 3'),
|
||||
|
||||
brushSize: {
|
||||
folder: 'Brush',
|
||||
label: 'Brush Size',
|
||||
min: 1,
|
||||
max: 60,
|
||||
step: 0.25,
|
||||
},
|
||||
spawnPerPixel: {
|
||||
folder: 'Brush',
|
||||
label: 'Density',
|
||||
min: 0.01,
|
||||
max: 1,
|
||||
step: 0.001,
|
||||
},
|
||||
strokeAngleJitterRadians: {
|
||||
folder: 'Brush',
|
||||
format: formatRadiansAsDegrees,
|
||||
label: 'Spawn Spread',
|
||||
min: 0,
|
||||
max: Math.PI * 2,
|
||||
step: 0.01,
|
||||
},
|
||||
sensorOffsetDistance: {
|
||||
folder: 'Movement',
|
||||
label: 'Sensor Reach',
|
||||
min: 0,
|
||||
max: 200,
|
||||
step: 1,
|
||||
},
|
||||
moveSpeed: {
|
||||
folder: 'Movement',
|
||||
label: 'Travel Speed',
|
||||
min: 10,
|
||||
max: 500,
|
||||
step: 1,
|
||||
},
|
||||
turnSpeed: {
|
||||
folder: 'Movement',
|
||||
label: 'Turning Speed',
|
||||
min: 1,
|
||||
max: 200,
|
||||
step: 1,
|
||||
},
|
||||
forwardRotationScale: {
|
||||
folder: 'Movement',
|
||||
format: formatPercent,
|
||||
label: 'Forward Focus',
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.01,
|
||||
},
|
||||
turnWhenLost: {
|
||||
folder: 'Movement',
|
||||
label: 'Wander Turn',
|
||||
min: 0,
|
||||
max: 6.28,
|
||||
step: 0.01,
|
||||
},
|
||||
individualTrailWeight: {
|
||||
folder: 'Movement',
|
||||
label: 'Trail Strength',
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.001,
|
||||
},
|
||||
decayRateTrails: {
|
||||
folder: 'Movement',
|
||||
label: 'Trail Fade',
|
||||
min: 800,
|
||||
max: 1000,
|
||||
step: 1,
|
||||
},
|
||||
|
||||
clarity: {
|
||||
folder: 'Look',
|
||||
label: 'Sharpness',
|
||||
min: 0.00001,
|
||||
max: 1,
|
||||
step: 0.001,
|
||||
},
|
||||
backgroundGrainStrength: {
|
||||
folder: 'Look',
|
||||
label: 'Background Grain',
|
||||
min: 0,
|
||||
max: 0.12,
|
||||
step: 0.001,
|
||||
},
|
||||
|
||||
maxAgentCount: {
|
||||
folder: 'Performance',
|
||||
integer: true,
|
||||
label: 'Population Limit',
|
||||
min: 0,
|
||||
step: 10_000,
|
||||
},
|
||||
internalRenderAreaMegapixels: {
|
||||
folder: 'Performance',
|
||||
label: 'Render Quality (MP)',
|
||||
min: 0.5,
|
||||
max: 16.6,
|
||||
step: 0.1,
|
||||
},
|
||||
};
|
||||
|
|
@ -1,206 +0,0 @@
|
|||
import {
|
||||
colorInteractionControl,
|
||||
defaultColorInteractionSettings,
|
||||
} from './color-interactions';
|
||||
import type { GardenAppConfig } from './types';
|
||||
|
||||
export const runtimeSettings: GardenAppConfig['runtimeSettings'] = {
|
||||
defaults: {
|
||||
agentBudgetMax: 1_000_000,
|
||||
agentCount: 0,
|
||||
selectedColorIndex: 0,
|
||||
spawnPerPixel: 0.22,
|
||||
|
||||
moveSpeed: 82,
|
||||
turnSpeed: 58,
|
||||
sensorOffsetAngle: 34,
|
||||
sensorOffsetDistance: 38,
|
||||
turnWhenLost: 0.8,
|
||||
|
||||
individualTrailWeight: 0.07,
|
||||
...defaultColorInteractionSettings,
|
||||
|
||||
diffusionRateTrails: 0.22,
|
||||
decayRateTrails: 965,
|
||||
diffusionRateBrush: 0.35,
|
||||
decayRateBrush: 18,
|
||||
brushEffectDuration: 8,
|
||||
|
||||
clarity: 0.62,
|
||||
brushSize: 14,
|
||||
brushCurveResolution: 12,
|
||||
eraserSize: 96,
|
||||
mirrorSegmentCount: 1,
|
||||
|
||||
brushSizeVariation: 0.5,
|
||||
|
||||
startColorHue: 200,
|
||||
|
||||
renderSpeed: 1,
|
||||
simulatedDelayMs: 0,
|
||||
},
|
||||
controls: {
|
||||
agentBudgetMax: {
|
||||
folder: 'Runtime',
|
||||
integer: true,
|
||||
min: 500_000,
|
||||
max: 10_000_000,
|
||||
step: 50_000,
|
||||
},
|
||||
agentCount: {
|
||||
folder: 'Runtime',
|
||||
integer: true,
|
||||
min: 0,
|
||||
max: 1_000_000,
|
||||
step: 1_000,
|
||||
},
|
||||
color1ToColor1: colorInteractionControl('1 -> 1'),
|
||||
color1ToColor2: colorInteractionControl('1 -> 2'),
|
||||
color1ToColor3: colorInteractionControl('1 -> 3'),
|
||||
color2ToColor1: colorInteractionControl('2 -> 1'),
|
||||
color2ToColor2: colorInteractionControl('2 -> 2'),
|
||||
color2ToColor3: colorInteractionControl('2 -> 3'),
|
||||
color3ToColor1: colorInteractionControl('3 -> 1'),
|
||||
color3ToColor2: colorInteractionControl('3 -> 2'),
|
||||
color3ToColor3: colorInteractionControl('3 -> 3'),
|
||||
brushEffectDuration: {
|
||||
folder: 'Diffusion',
|
||||
min: 0.5,
|
||||
max: 20,
|
||||
step: 0.05,
|
||||
},
|
||||
brushSize: {
|
||||
folder: 'Brush',
|
||||
min: 1,
|
||||
max: 60,
|
||||
step: 0.25,
|
||||
},
|
||||
brushSizeVariation: {
|
||||
folder: 'Brush',
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.01,
|
||||
},
|
||||
brushCurveResolution: {
|
||||
folder: 'Brush',
|
||||
integer: true,
|
||||
label: 'curve resolution',
|
||||
min: 1,
|
||||
max: 32,
|
||||
step: 1,
|
||||
},
|
||||
clarity: {
|
||||
folder: 'Render',
|
||||
min: 0.00001,
|
||||
max: 1,
|
||||
step: 0.001,
|
||||
},
|
||||
decayRateBrush: {
|
||||
folder: 'Diffusion',
|
||||
min: 0.1,
|
||||
max: 100,
|
||||
step: 0.1,
|
||||
},
|
||||
decayRateTrails: {
|
||||
folder: 'Diffusion',
|
||||
min: 0.1,
|
||||
max: 5000,
|
||||
step: 1,
|
||||
},
|
||||
diffusionRateBrush: {
|
||||
folder: 'Diffusion',
|
||||
min: 0.001,
|
||||
max: 1,
|
||||
step: 0.001,
|
||||
},
|
||||
diffusionRateTrails: {
|
||||
folder: 'Diffusion',
|
||||
min: 0,
|
||||
max: 2,
|
||||
step: 0.001,
|
||||
},
|
||||
eraserSize: {
|
||||
folder: 'Brush',
|
||||
integer: true,
|
||||
min: 24,
|
||||
max: 240,
|
||||
step: 1,
|
||||
},
|
||||
individualTrailWeight: {
|
||||
folder: 'Agent',
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.001,
|
||||
},
|
||||
mirrorSegmentCount: {
|
||||
folder: 'Brush',
|
||||
integer: true,
|
||||
min: 1,
|
||||
max: 12,
|
||||
step: 1,
|
||||
},
|
||||
moveSpeed: {
|
||||
folder: 'Agent',
|
||||
min: 10,
|
||||
max: 500,
|
||||
step: 1,
|
||||
},
|
||||
renderSpeed: {
|
||||
folder: 'Runtime',
|
||||
integer: true,
|
||||
min: 1,
|
||||
max: 10,
|
||||
step: 1,
|
||||
},
|
||||
selectedColorIndex: {
|
||||
folder: 'Brush',
|
||||
integer: true,
|
||||
min: 0,
|
||||
max: 2,
|
||||
step: 1,
|
||||
},
|
||||
sensorOffsetAngle: {
|
||||
folder: 'Agent',
|
||||
min: 0,
|
||||
max: 90,
|
||||
step: 1,
|
||||
},
|
||||
sensorOffsetDistance: {
|
||||
folder: 'Agent',
|
||||
min: 0,
|
||||
max: 200,
|
||||
step: 1,
|
||||
},
|
||||
simulatedDelayMs: {
|
||||
folder: 'Runtime',
|
||||
integer: true,
|
||||
min: 0,
|
||||
max: 2000,
|
||||
step: 1,
|
||||
},
|
||||
spawnPerPixel: {
|
||||
folder: 'Agent',
|
||||
min: 0.01,
|
||||
max: 1,
|
||||
step: 0.001,
|
||||
},
|
||||
startColorHue: {
|
||||
folder: 'Render',
|
||||
min: 0,
|
||||
max: 360,
|
||||
step: 1,
|
||||
},
|
||||
turnSpeed: {
|
||||
folder: 'Agent',
|
||||
min: 1,
|
||||
max: 200,
|
||||
step: 1,
|
||||
},
|
||||
turnWhenLost: {
|
||||
folder: 'Agent',
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.001,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -1,171 +1,108 @@
|
|||
import type {
|
||||
GardenAudioConfig,
|
||||
GardenAudioVibeProfile,
|
||||
GardenAudioVibeSettings,
|
||||
} from '../audio/garden-audio-config';
|
||||
import type { GameLoopSettings } from '../game-loop/game-loop-settings';
|
||||
import type { AgentSettings } from '../pipelines/agents/agent-settings';
|
||||
import type { BrushSettings } from '../pipelines/brush/brush-settings';
|
||||
import type { DiffusionSettings } from '../pipelines/diffusion/diffusion-settings';
|
||||
import type { RenderSettings } from '../pipelines/render/render-settings';
|
||||
|
||||
export type GardenRuntimeSettings = GameLoopSettings &
|
||||
AgentSettings &
|
||||
BrushSettings &
|
||||
DiffusionSettings &
|
||||
RenderSettings;
|
||||
|
||||
export type AgentColorInteractionSettings = Pick<
|
||||
AgentSettings,
|
||||
| 'color1ToColor1'
|
||||
| 'color1ToColor2'
|
||||
| 'color1ToColor3'
|
||||
| 'color2ToColor1'
|
||||
| 'color2ToColor2'
|
||||
| 'color2ToColor3'
|
||||
| 'color3ToColor1'
|
||||
| 'color3ToColor2'
|
||||
| 'color3ToColor3'
|
||||
>;
|
||||
|
||||
export type GardenVibeSettings = Partial<
|
||||
Pick<
|
||||
GardenRuntimeSettings,
|
||||
| 'agentBudgetMax'
|
||||
| 'brushSize'
|
||||
| 'color1ToColor1'
|
||||
| 'color1ToColor2'
|
||||
| 'color1ToColor3'
|
||||
| 'color2ToColor1'
|
||||
| 'color2ToColor2'
|
||||
| 'color2ToColor3'
|
||||
| 'color3ToColor1'
|
||||
| 'color3ToColor2'
|
||||
| 'color3ToColor3'
|
||||
| 'clarity'
|
||||
| 'decayRateTrails'
|
||||
| 'diffusionRateTrails'
|
||||
| 'individualTrailWeight'
|
||||
| 'moveSpeed'
|
||||
| 'sensorOffsetAngle'
|
||||
| 'sensorOffsetDistance'
|
||||
| 'spawnPerPixel'
|
||||
| 'turnSpeed'
|
||||
>
|
||||
>;
|
||||
|
||||
export interface VibePreset {
|
||||
id: string;
|
||||
name: string;
|
||||
colors: [string, string, string];
|
||||
backgroundColor: string;
|
||||
settings: GardenVibeSettings;
|
||||
audio: GardenAudioVibeProfile;
|
||||
}
|
||||
import type { AgentSettings } from '../pipelines/agents/agent-pipeline';
|
||||
import type { BrushSettings } from '../pipelines/brush/brush-pipeline';
|
||||
import type { DiffusionSettings } from '../pipelines/diffusion/diffusion-pipeline';
|
||||
import type { RenderSettings } from '../pipelines/render/render-pipeline';
|
||||
import type { RgbColor } from '../utils/rgb-color';
|
||||
|
||||
export interface NumberControlConfig {
|
||||
format?: (value: number) => string;
|
||||
folder: string;
|
||||
integer?: boolean;
|
||||
label?: string;
|
||||
max: number;
|
||||
min: number;
|
||||
max?: number;
|
||||
min?: number;
|
||||
options?: Record<string, number>;
|
||||
step?: number;
|
||||
}
|
||||
|
||||
export type RuntimeSettingControlConfig = {
|
||||
[Key in keyof GardenRuntimeSettings]: NumberControlConfig;
|
||||
};
|
||||
export type GardenRuntimeSettings = {
|
||||
adaptiveCapInitial: number;
|
||||
adaptiveCapMin: number;
|
||||
backgroundGrainStrength: number;
|
||||
brushCurveResolution: number;
|
||||
brushCurveMinBrushRadius: number;
|
||||
brushCurveMinSegmentSpacing: number;
|
||||
brushCurveMirrorResolutionExponent: number;
|
||||
brushCurveSegmentBrushRadiusRatio: number;
|
||||
brushEffectDuration: number;
|
||||
brushSmoothingMinSampleDistance: number;
|
||||
eraserClearAlpha: number;
|
||||
eraserClearBlue: number;
|
||||
eraserClearGreen: number;
|
||||
eraserClearRed: number;
|
||||
eraserLineDistanceEpsilon: number;
|
||||
eraserMaskAlphaThreshold: number;
|
||||
eraserSize: number;
|
||||
internalRenderAreaMegapixels: number;
|
||||
mirrorSegmentCount: number;
|
||||
maxAgentCount: number;
|
||||
selectedColorIndex: number;
|
||||
spawnPerPixel: number;
|
||||
strokeAngleJitterRadians: number;
|
||||
} & AgentSettings &
|
||||
BrushSettings &
|
||||
DiffusionSettings &
|
||||
RenderSettings;
|
||||
|
||||
type RuntimeSettingControlConfig = Partial<
|
||||
Record<keyof GardenRuntimeSettings, NumberControlConfig>
|
||||
>;
|
||||
|
||||
type GardenVibeSettings = Pick<
|
||||
GardenRuntimeSettings,
|
||||
| 'backgroundGrainStrength'
|
||||
| 'brushSize'
|
||||
| 'clarity'
|
||||
| 'decayRateTrails'
|
||||
| 'individualTrailWeight'
|
||||
| 'moveSpeed'
|
||||
| 'sensorOffsetDistance'
|
||||
| 'spawnPerPixel'
|
||||
| 'turnSpeed'
|
||||
>;
|
||||
|
||||
type GardenDefaultSettings = Omit<
|
||||
GardenRuntimeSettings,
|
||||
keyof GardenVibeSettings | 'eraserSize' | 'mirrorSegmentCount'
|
||||
>;
|
||||
|
||||
export enum VibeId {
|
||||
AuroraMycelium = 'aurora-mycelium',
|
||||
EmberCircuit = 'ember-circuit',
|
||||
VelvetObservatory = 'velvet-observatory',
|
||||
LichenSignal = 'lichen-signal',
|
||||
UltravioletSiren = 'ultraviolet-siren',
|
||||
TidepoolLantern = 'tidepool-lantern',
|
||||
PaperLanternFog = 'paper-lantern-fog',
|
||||
ChromePollen = 'chrome-pollen',
|
||||
}
|
||||
|
||||
export interface VibePreset {
|
||||
id: VibeId;
|
||||
name: string;
|
||||
colors: [RgbColor, RgbColor, RgbColor];
|
||||
backgroundColor: RgbColor;
|
||||
settings: GardenVibeSettings;
|
||||
audio: GardenAudioVibeSettings;
|
||||
}
|
||||
|
||||
export interface GardenAppConfig {
|
||||
audio: GardenAudioConfig;
|
||||
audioEngine: {
|
||||
energy: {
|
||||
attackSeconds: number;
|
||||
decaySeconds: number;
|
||||
releaseSeconds: number;
|
||||
strokeDecaySeconds: number;
|
||||
};
|
||||
eraser: {
|
||||
canvasWidthRatioForFullSize: number;
|
||||
defaultSizePixels: number;
|
||||
durationSeconds: number;
|
||||
filterPressureWeight: number;
|
||||
filterSizeWeight: number;
|
||||
filterSpeedWeight: number;
|
||||
gainBase: number;
|
||||
gainPressureWeight: number;
|
||||
gainSizeWeight: number;
|
||||
gainSpeedWeight: number;
|
||||
};
|
||||
delay: {
|
||||
erasingActivity: number;
|
||||
};
|
||||
graph: {
|
||||
closeGain: number;
|
||||
closeRampSeconds: number;
|
||||
delayActivityFeedbackWeight: number;
|
||||
delayFeedbackMax: number;
|
||||
delayFeedbackMin: number;
|
||||
delayOutputActivityWeight: number;
|
||||
delayOutputBase: number;
|
||||
delayTimeRampSeconds: number;
|
||||
eventBusGain: number;
|
||||
noiseMax: number;
|
||||
noiseMin: number;
|
||||
unlockBufferLength: number;
|
||||
unlockSampleRate: number;
|
||||
};
|
||||
input: {
|
||||
distanceEnergyBase: number;
|
||||
distanceEnergyScale: number;
|
||||
distanceForFullEnergyPixels: number;
|
||||
fallbackFrameSeconds: number;
|
||||
penMinPressure: number;
|
||||
strokeEnergyBase: number;
|
||||
strokeEnergyPressureWeight: number;
|
||||
strokeEnergySpeedWeight: number;
|
||||
};
|
||||
muteGain: number;
|
||||
muteRampSeconds: number;
|
||||
noiseBurst: {
|
||||
attackSeconds: number;
|
||||
filterQ: number;
|
||||
offsetRandomSeconds: number;
|
||||
scheduleAheadSeconds: number;
|
||||
silentGain: number;
|
||||
};
|
||||
piano: {
|
||||
filterQ: number;
|
||||
gainAttackSeconds: number;
|
||||
lowpassMaxHz: number;
|
||||
lowpassMinHz: number;
|
||||
minDurationSeconds: number;
|
||||
minFadeSeconds: number;
|
||||
minGain: number;
|
||||
pitchSemitonesPerOctave: number;
|
||||
scheduleAheadSeconds: number;
|
||||
sustainBase: number;
|
||||
sustainVelocityRange: number;
|
||||
tailStopExtraSeconds: number;
|
||||
voiceStealFadeSeconds: number;
|
||||
voiceStealStopSeconds: number;
|
||||
};
|
||||
startDelaySeconds: number;
|
||||
vibeChangeStingerMinIntervalSeconds: number;
|
||||
};
|
||||
deltaTime: {
|
||||
fpsExponentialDecayStrength: number;
|
||||
maxDeltaTimeSeconds: number;
|
||||
minDeltaTimeSeconds: number;
|
||||
};
|
||||
export4k: {
|
||||
exportSnapshot: {
|
||||
bytesPerPixel: number;
|
||||
height: number;
|
||||
jsHeapSafetyMultiplier: number;
|
||||
lowMemoryDeviceGiB: number;
|
||||
lowMemoryExportFraction: number;
|
||||
filenameExtension: string;
|
||||
filenamePrefix: string;
|
||||
filenameSuffix: string;
|
||||
mimeType: string;
|
||||
rowAlignmentBytes: number;
|
||||
width: number;
|
||||
};
|
||||
menuHider: {
|
||||
bottomRevealDistancePx: number;
|
||||
|
|
@ -173,6 +110,17 @@ export interface GardenAppConfig {
|
|||
hideDelayMs: number;
|
||||
};
|
||||
pipelines: {
|
||||
common: {
|
||||
noiseChannelSeeds: [number, number, number, number];
|
||||
noiseClearValue: GPUColor;
|
||||
noiseDrawInstanceCount: number;
|
||||
noiseDrawVertexCount: number;
|
||||
noiseHashMultiplier: number;
|
||||
noiseHashX: number;
|
||||
noiseHashY: number;
|
||||
noiseTextureFormat: GPUTextureFormat;
|
||||
noiseTextureSize: number;
|
||||
};
|
||||
brush: {
|
||||
maxLineCount: number;
|
||||
};
|
||||
|
|
@ -180,36 +128,29 @@ export interface GardenAppConfig {
|
|||
minDiffusionRate: number;
|
||||
};
|
||||
eraser: {
|
||||
maxSegmentCount: number;
|
||||
maxTextureLineCount: number;
|
||||
segmentFloatCount: number;
|
||||
workgroupSize: number;
|
||||
};
|
||||
};
|
||||
defaultSettings: GardenDefaultSettings;
|
||||
runtimeSettings: {
|
||||
controls: RuntimeSettingControlConfig;
|
||||
defaults: GardenRuntimeSettings;
|
||||
};
|
||||
simulation: {
|
||||
budget: {
|
||||
adaptiveCapDecreaseAgentsPerSecond: number;
|
||||
adaptiveCapMin: number;
|
||||
fpsHeadroom: number;
|
||||
fpsSmoothingNew: number;
|
||||
fpsSmoothingRetain: number;
|
||||
};
|
||||
brushEffectFramesPerSecond: number;
|
||||
globalAgentCap: number;
|
||||
clearColor: GPUColor;
|
||||
initialAgentCount: number;
|
||||
sourceActiveFramesAfterWrite: number;
|
||||
intro: {
|
||||
angleJitterRadians: number;
|
||||
angleEaseEnd: number;
|
||||
angleEaseStart: number;
|
||||
circleMaxSideRatio: number;
|
||||
circleMinSideRatio: number;
|
||||
drawHintClass: string;
|
||||
drawHintDelayMs: number;
|
||||
durationSeconds: number;
|
||||
entryJitterSideRatio: number;
|
||||
fontScaleDown: number;
|
||||
fontFamily: string;
|
||||
initialFontHeightRatio: number;
|
||||
initialFontWidthRatio: number;
|
||||
letterSpacingEm: number;
|
||||
|
|
@ -221,7 +162,12 @@ export interface GardenAppConfig {
|
|||
minEntryJitterPx: number;
|
||||
minFontSizePx: number;
|
||||
minTargetJitterPx: number;
|
||||
pathEasing: 'easeOutQuad' | 'linear';
|
||||
pathProgressEpsilon: number;
|
||||
radialJitterRatio: number;
|
||||
radialStartEpsilon: number;
|
||||
resizeMinimumRemainingSeconds: number;
|
||||
resizeSettleMs: number;
|
||||
targetDelayDistanceMultiplier: number;
|
||||
targetDelayMax: number;
|
||||
targetDelayRandomMultiplier: number;
|
||||
|
|
@ -235,22 +181,16 @@ export interface GardenAppConfig {
|
|||
};
|
||||
introMoveSpeedBaseMultiplier: number;
|
||||
introMoveSpeedProgressMultiplier: number;
|
||||
maxMirrorSegmentCount: number;
|
||||
stroke: {
|
||||
angleJitterRadians: number;
|
||||
densityMultiplier: number;
|
||||
maxAgentCount: number;
|
||||
minAgentCount: number;
|
||||
};
|
||||
};
|
||||
storage: {
|
||||
audioMutedKey: string;
|
||||
audioVolumeKey: string;
|
||||
vibeKey: string;
|
||||
};
|
||||
telemetry: {
|
||||
enabled: boolean;
|
||||
intervalMs: number;
|
||||
};
|
||||
toolbar: {
|
||||
eraser: {
|
||||
controlScaleMax: number;
|
||||
|
|
@ -262,23 +202,50 @@ export interface GardenAppConfig {
|
|||
};
|
||||
mirror: {
|
||||
default: number;
|
||||
fallbackSegmentName: string;
|
||||
max: number;
|
||||
min: number;
|
||||
names: Record<number, string>;
|
||||
offLabel: string;
|
||||
step: number;
|
||||
};
|
||||
contrast: {
|
||||
backgroundOpacityMax: number;
|
||||
brightLuminanceThreshold: number;
|
||||
brightWeight: number;
|
||||
bytesPerSample: number;
|
||||
contrastOffset: number;
|
||||
linearChannelBreakpoint: number;
|
||||
linearChannelDivisor: number;
|
||||
linearChannelGamma: number;
|
||||
linearChannelOffset: number;
|
||||
linearChannelScale: number;
|
||||
lowContrastThreshold: number;
|
||||
lowContrastWeight: number;
|
||||
luminanceBase: number;
|
||||
luminanceBlueWeight: number;
|
||||
luminanceGreenWeight: number;
|
||||
luminanceRange: number;
|
||||
luminanceRedWeight: number;
|
||||
sampleColumns: number;
|
||||
sampleIntervalMs: number;
|
||||
sampleRows: number;
|
||||
whiteContrastNumerator: number;
|
||||
};
|
||||
volume: {
|
||||
default: number;
|
||||
max: number;
|
||||
min: number;
|
||||
step: number;
|
||||
};
|
||||
};
|
||||
tuningPane: {
|
||||
expandedDepth: number;
|
||||
showFpsOverlay: boolean;
|
||||
startHidden: boolean;
|
||||
title: string;
|
||||
};
|
||||
vibes: {
|
||||
defaultVibeId: string;
|
||||
defaultVibeId: VibeId;
|
||||
presets: Array<VibePreset>;
|
||||
};
|
||||
}
|
||||
|
||||
export type GardenAudioEngineConfig = GardenAppConfig['audioEngine'];
|
||||
export type GardenSimulationConfig = GardenAppConfig['simulation'];
|
||||
export type GardenStorageConfig = GardenAppConfig['storage'];
|
||||
|
|
|
|||
|
|
@ -1,204 +1,255 @@
|
|||
import type {
|
||||
GardenAudioChord,
|
||||
GardenAudioVibeProfile,
|
||||
} from '../audio/garden-audio-config';
|
||||
import { createColorInteractionSettings } from './color-interactions';
|
||||
import type { VibePreset } from './types';
|
||||
import { defaultGardenAudioVibeSettings } from '../audio/garden-audio-config';
|
||||
import { VibeId, type VibePreset } from './types';
|
||||
|
||||
const majorProgression: Array<GardenAudioChord> = [
|
||||
{ rootOffset: 0, quality: 'major' },
|
||||
{ rootOffset: 9, quality: 'minor' },
|
||||
{ rootOffset: 5, quality: 'major' },
|
||||
{ rootOffset: 7, quality: 'major' },
|
||||
];
|
||||
|
||||
const minorProgression: Array<GardenAudioChord> = [
|
||||
{ rootOffset: 0, quality: 'minor' },
|
||||
{ rootOffset: 8, quality: 'major' },
|
||||
{ rootOffset: 3, quality: 'major' },
|
||||
{ rootOffset: 10, quality: 'major' },
|
||||
];
|
||||
|
||||
const majorPentatonic = [0, 2, 4, 7, 9];
|
||||
const minorPentatonic = [0, 3, 5, 7, 10];
|
||||
|
||||
export const defaultVibeId = 'candy-rain';
|
||||
export const defaultVibeId = VibeId.AuroraMycelium;
|
||||
|
||||
export const vibePresets: Array<VibePreset> = [
|
||||
{
|
||||
id: 'candy-rain',
|
||||
name: 'Candy Rain',
|
||||
colors: ['#ff5da2', '#36d7d0', '#ffd84d'],
|
||||
backgroundColor: '#10151f',
|
||||
id: VibeId.AuroraMycelium,
|
||||
name: 'Aurora Mycelium',
|
||||
colors: [
|
||||
[78, 255, 176],
|
||||
[154, 99, 255],
|
||||
[169, 238, 255],
|
||||
],
|
||||
backgroundColor: [6, 13, 22],
|
||||
settings: {
|
||||
agentBudgetMax: 1_000_000,
|
||||
brushSize: 14,
|
||||
clarity: 0.62,
|
||||
decayRateTrails: 965,
|
||||
diffusionRateTrails: 0.22,
|
||||
individualTrailWeight: 0.07,
|
||||
moveSpeed: 82,
|
||||
sensorOffsetAngle: 34,
|
||||
sensorOffsetDistance: 38,
|
||||
spawnPerPixel: 0.22,
|
||||
turnSpeed: 58,
|
||||
...createColorInteractionSettings('candy-rain'),
|
||||
backgroundGrainStrength: 0.016,
|
||||
brushSize: 20,
|
||||
clarity: 0.52,
|
||||
decayRateTrails: 988,
|
||||
individualTrailWeight: 0.085,
|
||||
moveSpeed: 54,
|
||||
sensorOffsetDistance: 72,
|
||||
spawnPerPixel: 0.13,
|
||||
turnSpeed: 35,
|
||||
},
|
||||
audio: {
|
||||
rootMidi: 57,
|
||||
scale: majorPentatonic,
|
||||
brightness: 1.04,
|
||||
delayTimeMultiplier: 0.92,
|
||||
progression: majorProgression,
|
||||
...defaultGardenAudioVibeSettings,
|
||||
idleIntensity: 0.12,
|
||||
bpm: 60,
|
||||
rampUpIntensity: 0.7,
|
||||
rampUpTime: 0.14,
|
||||
noteLength: 0.86,
|
||||
notePitchOffset: -2,
|
||||
brightness: 0.84,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'sunlit-moss',
|
||||
name: 'Sunlit Moss',
|
||||
colors: ['#83d483', '#f6d76b', '#5ec1a1'],
|
||||
backgroundColor: '#172016',
|
||||
id: VibeId.EmberCircuit,
|
||||
name: 'Ember Circuit',
|
||||
colors: [
|
||||
[255, 95, 38],
|
||||
[255, 43, 132],
|
||||
[43, 219, 255],
|
||||
],
|
||||
backgroundColor: [17, 10, 8],
|
||||
settings: {
|
||||
agentBudgetMax: 1_000_000,
|
||||
brushSize: 16,
|
||||
clarity: 0.68,
|
||||
decayRateTrails: 975,
|
||||
diffusionRateTrails: 0.18,
|
||||
individualTrailWeight: 0.06,
|
||||
moveSpeed: 70,
|
||||
sensorOffsetAngle: 28,
|
||||
sensorOffsetDistance: 46,
|
||||
spawnPerPixel: 0.18,
|
||||
turnSpeed: 44,
|
||||
...createColorInteractionSettings('sunlit-moss'),
|
||||
backgroundGrainStrength: 0.03,
|
||||
brushSize: 8,
|
||||
clarity: 0.82,
|
||||
decayRateTrails: 918,
|
||||
individualTrailWeight: 0.04,
|
||||
moveSpeed: 150,
|
||||
sensorOffsetDistance: 24,
|
||||
spawnPerPixel: 0.31,
|
||||
turnSpeed: 130,
|
||||
},
|
||||
audio: {
|
||||
rootMidi: 53,
|
||||
scale: majorPentatonic,
|
||||
brightness: 0.92,
|
||||
delayTimeMultiplier: 1.08,
|
||||
progression: [
|
||||
{ rootOffset: 0, quality: 'major' },
|
||||
{ rootOffset: 7, quality: 'major' },
|
||||
{ rootOffset: 9, quality: 'minor' },
|
||||
{ rootOffset: 5, quality: 'major' },
|
||||
],
|
||||
...defaultGardenAudioVibeSettings,
|
||||
idleIntensity: 0.03,
|
||||
bpm: 124,
|
||||
rampUpIntensity: 1.35,
|
||||
rampUpTime: 0.04,
|
||||
noteLength: 0.18,
|
||||
notePitchOffset: 7,
|
||||
brightness: 1.34,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'coral-tide',
|
||||
name: 'Coral Tide',
|
||||
colors: ['#ff7f6e', '#40b8ff', '#f4f0a6'],
|
||||
backgroundColor: '#0f1822',
|
||||
id: VibeId.VelvetObservatory,
|
||||
name: 'Velvet Observatory',
|
||||
colors: [
|
||||
[72, 98, 255],
|
||||
[255, 89, 176],
|
||||
[235, 236, 255],
|
||||
],
|
||||
backgroundColor: [7, 8, 20],
|
||||
settings: {
|
||||
agentBudgetMax: 1_000_000,
|
||||
brushSize: 13,
|
||||
clarity: 0.58,
|
||||
decayRateTrails: 955,
|
||||
diffusionRateTrails: 0.28,
|
||||
individualTrailWeight: 0.055,
|
||||
moveSpeed: 90,
|
||||
sensorOffsetAngle: 36,
|
||||
sensorOffsetDistance: 35,
|
||||
spawnPerPixel: 0.25,
|
||||
turnSpeed: 62,
|
||||
...createColorInteractionSettings('coral-tide'),
|
||||
backgroundGrainStrength: 0.01,
|
||||
brushSize: 24,
|
||||
clarity: 0.45,
|
||||
decayRateTrails: 992,
|
||||
individualTrailWeight: 0.095,
|
||||
moveSpeed: 45,
|
||||
sensorOffsetDistance: 86,
|
||||
spawnPerPixel: 0.1,
|
||||
turnSpeed: 24,
|
||||
},
|
||||
audio: {
|
||||
rootMidi: 50,
|
||||
scale: minorPentatonic,
|
||||
brightness: 1,
|
||||
delayTimeMultiplier: 1.12,
|
||||
progression: minorProgression,
|
||||
...defaultGardenAudioVibeSettings,
|
||||
idleIntensity: 0.14,
|
||||
bpm: 56,
|
||||
rampUpIntensity: 0.6,
|
||||
rampUpTime: 0.16,
|
||||
noteLength: 1.15,
|
||||
notePitchOffset: -5,
|
||||
brightness: 0.72,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'moon-orchid',
|
||||
name: 'Moon Orchid',
|
||||
colors: ['#c993ff', '#7dd8ff', '#f0f4ff'],
|
||||
backgroundColor: '#14121d',
|
||||
id: VibeId.LichenSignal,
|
||||
name: 'Lichen Signal',
|
||||
colors: [
|
||||
[174, 205, 91],
|
||||
[71, 162, 126],
|
||||
[229, 117, 71],
|
||||
],
|
||||
backgroundColor: [18, 24, 17],
|
||||
settings: {
|
||||
agentBudgetMax: 1_000_000,
|
||||
brushSize: 12,
|
||||
clarity: 0.64,
|
||||
decayRateTrails: 968,
|
||||
diffusionRateTrails: 0.2,
|
||||
backgroundGrainStrength: 0.028,
|
||||
brushSize: 17,
|
||||
clarity: 0.66,
|
||||
decayRateTrails: 974,
|
||||
individualTrailWeight: 0.065,
|
||||
moveSpeed: 76,
|
||||
sensorOffsetAngle: 32,
|
||||
sensorOffsetDistance: 42,
|
||||
spawnPerPixel: 0.2,
|
||||
turnSpeed: 52,
|
||||
...createColorInteractionSettings('moon-orchid'),
|
||||
},
|
||||
audio: {
|
||||
rootMidi: 49,
|
||||
scale: minorPentatonic,
|
||||
brightness: 0.9,
|
||||
delayTimeMultiplier: 1.24,
|
||||
progression: minorProgression,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'peach-neon',
|
||||
name: 'Peach Neon',
|
||||
colors: ['#ff9b73', '#5bf0a9', '#6ea8ff'],
|
||||
backgroundColor: '#191716',
|
||||
settings: {
|
||||
agentBudgetMax: 1_000_000,
|
||||
brushSize: 15,
|
||||
clarity: 0.55,
|
||||
decayRateTrails: 948,
|
||||
diffusionRateTrails: 0.32,
|
||||
individualTrailWeight: 0.05,
|
||||
moveSpeed: 96,
|
||||
sensorOffsetAngle: 40,
|
||||
sensorOffsetDistance: 32,
|
||||
spawnPerPixel: 0.24,
|
||||
turnSpeed: 70,
|
||||
...createColorInteractionSettings('peach-neon'),
|
||||
},
|
||||
audio: {
|
||||
rootMidi: 56,
|
||||
scale: majorPentatonic,
|
||||
brightness: 1.08,
|
||||
delayTimeMultiplier: 0.86,
|
||||
progression: majorProgression,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'frost-bloom',
|
||||
name: 'Frost Bloom',
|
||||
colors: ['#b4f7ff', '#9ec8ff', '#ffb8d2'],
|
||||
backgroundColor: '#101820',
|
||||
settings: {
|
||||
agentBudgetMax: 1_000_000,
|
||||
brushSize: 18,
|
||||
clarity: 0.7,
|
||||
decayRateTrails: 982,
|
||||
diffusionRateTrails: 0.14,
|
||||
individualTrailWeight: 0.075,
|
||||
moveSpeed: 62,
|
||||
sensorOffsetAngle: 26,
|
||||
moveSpeed: 68,
|
||||
sensorOffsetDistance: 52,
|
||||
spawnPerPixel: 0.16,
|
||||
turnSpeed: 40,
|
||||
...createColorInteractionSettings('frost-bloom'),
|
||||
spawnPerPixel: 0.19,
|
||||
turnSpeed: 38,
|
||||
},
|
||||
audio: {
|
||||
rootMidi: 62,
|
||||
scale: majorPentatonic,
|
||||
brightness: 0.88,
|
||||
delayTimeMultiplier: 1.32,
|
||||
progression: [
|
||||
{ rootOffset: 0, quality: 'major' },
|
||||
{ rootOffset: 5, quality: 'major' },
|
||||
{ rootOffset: 9, quality: 'minor' },
|
||||
{ rootOffset: 7, quality: 'major' },
|
||||
],
|
||||
...defaultGardenAudioVibeSettings,
|
||||
idleIntensity: 0.1,
|
||||
bpm: 68,
|
||||
rampUpIntensity: 0.8,
|
||||
rampUpTime: 0.1,
|
||||
noteLength: 0.62,
|
||||
notePitchOffset: -3,
|
||||
brightness: 0.82,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: VibeId.UltravioletSiren,
|
||||
name: 'Ultraviolet Siren',
|
||||
colors: [
|
||||
[184, 75, 255],
|
||||
[0, 224, 255],
|
||||
[214, 255, 72],
|
||||
],
|
||||
backgroundColor: [13, 9, 31],
|
||||
settings: {
|
||||
backgroundGrainStrength: 0.02,
|
||||
brushSize: 11,
|
||||
clarity: 0.72,
|
||||
decayRateTrails: 946,
|
||||
individualTrailWeight: 0.052,
|
||||
moveSpeed: 118,
|
||||
sensorOffsetDistance: 30,
|
||||
spawnPerPixel: 0.28,
|
||||
turnSpeed: 96,
|
||||
},
|
||||
audio: {
|
||||
...defaultGardenAudioVibeSettings,
|
||||
idleIntensity: 0.04,
|
||||
bpm: 112,
|
||||
rampUpIntensity: 1.2,
|
||||
rampUpTime: 0.05,
|
||||
noteLength: 0.25,
|
||||
notePitchOffset: 5,
|
||||
brightness: 1.22,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: VibeId.TidepoolLantern,
|
||||
name: 'Tidepool Lantern',
|
||||
colors: [
|
||||
[30, 219, 194],
|
||||
[61, 118, 255],
|
||||
[255, 191, 91],
|
||||
],
|
||||
backgroundColor: [5, 20, 28],
|
||||
settings: {
|
||||
backgroundGrainStrength: 0.018,
|
||||
brushSize: 15,
|
||||
clarity: 0.6,
|
||||
decayRateTrails: 963,
|
||||
individualTrailWeight: 0.058,
|
||||
moveSpeed: 88,
|
||||
sensorOffsetDistance: 44,
|
||||
spawnPerPixel: 0.22,
|
||||
turnSpeed: 60,
|
||||
},
|
||||
audio: {
|
||||
...defaultGardenAudioVibeSettings,
|
||||
idleIntensity: 0.08,
|
||||
bpm: 82,
|
||||
rampUpIntensity: 0.95,
|
||||
rampUpTime: 0.08,
|
||||
noteLength: 0.48,
|
||||
notePitchOffset: 0,
|
||||
brightness: 0.98,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: VibeId.PaperLanternFog,
|
||||
name: 'Paper Lantern Fog',
|
||||
colors: [
|
||||
[255, 174, 104],
|
||||
[242, 102, 107],
|
||||
[132, 211, 185],
|
||||
],
|
||||
backgroundColor: [31, 23, 20],
|
||||
settings: {
|
||||
backgroundGrainStrength: 0.036,
|
||||
brushSize: 22,
|
||||
clarity: 0.5,
|
||||
decayRateTrails: 984,
|
||||
individualTrailWeight: 0.08,
|
||||
moveSpeed: 56,
|
||||
sensorOffsetDistance: 64,
|
||||
spawnPerPixel: 0.14,
|
||||
turnSpeed: 32,
|
||||
},
|
||||
audio: {
|
||||
...defaultGardenAudioVibeSettings,
|
||||
idleIntensity: 0.13,
|
||||
bpm: 64,
|
||||
rampUpIntensity: 0.72,
|
||||
rampUpTime: 0.12,
|
||||
noteLength: 0.9,
|
||||
notePitchOffset: -4,
|
||||
brightness: 0.76,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: VibeId.ChromePollen,
|
||||
name: 'Chrome Pollen',
|
||||
colors: [
|
||||
[235, 255, 238],
|
||||
[255, 214, 48],
|
||||
[77, 240, 157],
|
||||
],
|
||||
backgroundColor: [9, 13, 12],
|
||||
settings: {
|
||||
backgroundGrainStrength: 0.012,
|
||||
brushSize: 10,
|
||||
clarity: 0.9,
|
||||
decayRateTrails: 935,
|
||||
individualTrailWeight: 0.045,
|
||||
moveSpeed: 104,
|
||||
sensorOffsetDistance: 36,
|
||||
spawnPerPixel: 0.24,
|
||||
turnSpeed: 78,
|
||||
},
|
||||
audio: {
|
||||
...defaultGardenAudioVibeSettings,
|
||||
idleIntensity: 0.05,
|
||||
bpm: 96,
|
||||
rampUpIntensity: 1.05,
|
||||
rampUpTime: 0.07,
|
||||
noteLength: 0.3,
|
||||
notePitchOffset: 3,
|
||||
brightness: 1.18,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const audioVibes = Object.fromEntries(
|
||||
vibePresets.map((vibe) => [vibe.id, vibe.audio])
|
||||
) as Record<string, GardenAudioVibeProfile>;
|
||||
|
|
|
|||
10
src/consts.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export const ENABLED_FLAG_VALUE = '1';
|
||||
export const DISABLED_FLAG_VALUE = '0';
|
||||
|
||||
export const DEFAULT_AUDIO_VOLUME = 0.5;
|
||||
|
||||
export const APP_STORAGE_KEYS = {
|
||||
audioMuted: 'fleeting-garden:audio-muted',
|
||||
audioVolume: 'fleeting-garden:audio-volume',
|
||||
vibe: 'fleeting-garden:vibe',
|
||||
} as const;
|
||||
|
|
@ -1,81 +1,166 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.hoisted(() => {
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: vi.fn(() => null),
|
||||
setItem: vi.fn(),
|
||||
},
|
||||
});
|
||||
});
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { appConfig } from '../config';
|
||||
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
|
||||
import { type AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
|
||||
import { AGENT_FLOAT_COUNT } from '../pipelines/agents/agent-limits';
|
||||
import { settings } from '../settings';
|
||||
import { AgentPopulation } from './agent-population';
|
||||
import { type FramePerformance } from './frame-performance';
|
||||
|
||||
const originalAgentBudgetMax = settings.agentBudgetMax;
|
||||
const originalBrushSize = settings.brushSize;
|
||||
const originalSelectedColorIndex = settings.selectedColorIndex;
|
||||
const originalSpawnPerPixel = settings.spawnPerPixel;
|
||||
|
||||
const createPopulation = () => {
|
||||
const pipeline = {
|
||||
maxAgentCount: 10_000_000,
|
||||
writeAgents: vi.fn(),
|
||||
resizeAgents: vi.fn(),
|
||||
compactAgents: vi.fn(),
|
||||
} as unknown as AgentGenerationPipeline;
|
||||
|
||||
return new AgentPopulation(pipeline);
|
||||
const originalSettings = {
|
||||
brushSize: settings.brushSize,
|
||||
maxAgentCount: settings.maxAgentCount,
|
||||
selectedColorIndex: settings.selectedColorIndex,
|
||||
spawnPerPixel: settings.spawnPerPixel,
|
||||
strokeAngleJitterRadians: settings.strokeAngleJitterRadians,
|
||||
};
|
||||
|
||||
const setPopulationActiveCount = (population: AgentPopulation, activeCount: number) => {
|
||||
Object.assign(population as unknown as Record<string, number>, {
|
||||
activeCount,
|
||||
});
|
||||
class RecordingAgentGenerationPipeline {
|
||||
public readonly writtenAgentCounts: Array<number> = [];
|
||||
public readonly writtenAgentOffsets: Array<number> = [];
|
||||
public readonly writtenBatches: Array<Float32Array> = [];
|
||||
public readonly maxSupportedAgentCount = 1_000_000;
|
||||
public maxAgentCount = 1_000_000;
|
||||
private compactResolver: ((compactedAgentCount: number) => void) | null = null;
|
||||
|
||||
public ensureMaxAgentCount(requestedMaxAgentCount: number): number {
|
||||
this.maxAgentCount = Math.max(this.maxAgentCount, requestedMaxAgentCount);
|
||||
return this.maxAgentCount;
|
||||
}
|
||||
|
||||
public writeAgents(agentOffset: number, data: Float32Array): void {
|
||||
this.writtenAgentOffsets.push(agentOffset);
|
||||
this.writtenAgentCounts.push(data.length / AGENT_FLOAT_COUNT);
|
||||
this.writtenBatches.push(data.slice());
|
||||
}
|
||||
|
||||
public compactAgents(): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
this.compactResolver = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
public finishCompaction(compactedAgentCount: number): void {
|
||||
this.compactResolver?.(compactedAgentCount);
|
||||
this.compactResolver = null;
|
||||
}
|
||||
}
|
||||
|
||||
const framePerformance = {
|
||||
adaptiveCapDecreaseAgents: 0,
|
||||
adaptiveCapInitial: 1_000_000,
|
||||
adaptiveCapMin: 0,
|
||||
hasAdaptiveCapHeadroom: true,
|
||||
} as FramePerformance;
|
||||
|
||||
const createPopulation = (): {
|
||||
pipeline: RecordingAgentGenerationPipeline;
|
||||
population: AgentPopulation;
|
||||
} => {
|
||||
const pipeline = new RecordingAgentGenerationPipeline();
|
||||
const population = new AgentPopulation(
|
||||
pipeline as unknown as AgentGenerationPipeline,
|
||||
0,
|
||||
() => 1,
|
||||
framePerformance
|
||||
);
|
||||
population.beginStroke();
|
||||
return { pipeline, population };
|
||||
};
|
||||
|
||||
describe('AgentPopulation adaptive budget', () => {
|
||||
const setSpawnRate = (agentsPerPixel: number): void => {
|
||||
settings.spawnPerPixel = agentsPerPixel / appConfig.simulation.stroke.densityMultiplier;
|
||||
};
|
||||
|
||||
describe('AgentPopulation stroke spawning', () => {
|
||||
beforeEach(() => {
|
||||
settings.agentBudgetMax = 1_000_000;
|
||||
settings.brushSize = 1;
|
||||
settings.brushSize = 0;
|
||||
settings.maxAgentCount = 1_000_000;
|
||||
settings.selectedColorIndex = 0;
|
||||
settings.spawnPerPixel = 1;
|
||||
settings.strokeAngleJitterRadians = 0;
|
||||
setSpawnRate(1);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
settings.agentBudgetMax = originalAgentBudgetMax;
|
||||
settings.brushSize = originalBrushSize;
|
||||
settings.selectedColorIndex = originalSelectedColorIndex;
|
||||
settings.spawnPerPixel = originalSpawnPerPixel;
|
||||
Object.assign(settings, originalSettings);
|
||||
});
|
||||
|
||||
it('expands beyond the 1M start cap only when new agents arrive under healthy FPS', () => {
|
||||
const population = createPopulation();
|
||||
setPopulationActiveCount(population, 1_000_000);
|
||||
it('spawns the same count for the same stroke length regardless of segmentation', () => {
|
||||
const segmented = createPopulation();
|
||||
for (let x = 0; x < 10; x++) {
|
||||
segmented.population.spawnStrokeAgents(
|
||||
vec2.fromValues(x, 0),
|
||||
vec2.fromValues(x + 1, 0)
|
||||
);
|
||||
}
|
||||
|
||||
population.growBudget(1 / 60, 60, 60);
|
||||
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(16, 0));
|
||||
|
||||
expect(settings.agentBudgetMax).toBeGreaterThan(1_000_000);
|
||||
expect(population.activeAgentCount).toBeGreaterThan(1_000_000);
|
||||
expect(settings.agentBudgetMax).toBeLessThanOrEqual(
|
||||
appConfig.simulation.globalAgentCap
|
||||
const singleSegment = createPopulation();
|
||||
singleSegment.population.spawnStrokeAgents(
|
||||
vec2.fromValues(0, 0),
|
||||
vec2.fromValues(10, 0)
|
||||
);
|
||||
|
||||
expect(segmented.population.activeAgentCount).toBe(10);
|
||||
expect(singleSegment.population.activeAgentCount).toBe(10);
|
||||
});
|
||||
|
||||
it('decreases the cap and active count slowly when FPS falls below the threshold', () => {
|
||||
const population = createPopulation();
|
||||
setPopulationActiveCount(population, 1_000_000);
|
||||
it('carries fractional spawn budget within a stroke', () => {
|
||||
setSpawnRate(0.5);
|
||||
const { population } = createPopulation();
|
||||
|
||||
population.growBudget(10, 50, 60);
|
||||
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(1, 0));
|
||||
expect(population.activeAgentCount).toBe(0);
|
||||
|
||||
expect(settings.agentBudgetMax).toBe(appConfig.simulation.budget.adaptiveCapMin);
|
||||
expect(population.activeAgentCount).toBe(
|
||||
appConfig.simulation.budget.adaptiveCapMin
|
||||
population.spawnStrokeAgents(vec2.fromValues(1, 0), vec2.fromValues(2, 0));
|
||||
expect(population.activeAgentCount).toBe(1);
|
||||
|
||||
population.spawnStrokeAgents(vec2.fromValues(2, 0), vec2.fromValues(3, 0));
|
||||
expect(population.activeAgentCount).toBe(1);
|
||||
|
||||
population.spawnStrokeAgents(vec2.fromValues(3, 0), vec2.fromValues(4, 0));
|
||||
expect(population.activeAgentCount).toBe(2);
|
||||
});
|
||||
|
||||
it('chunks long stroke writes without clipping length-linear spawn counts', () => {
|
||||
const { pipeline, population } = createPopulation();
|
||||
const batchCapacity = appConfig.simulation.stroke.maxAgentCount;
|
||||
const expectedAgentCount = batchCapacity + 10;
|
||||
|
||||
population.spawnStrokeAgents(
|
||||
vec2.fromValues(0, 0),
|
||||
vec2.fromValues(expectedAgentCount, 0)
|
||||
);
|
||||
|
||||
expect(population.activeAgentCount).toBe(expectedAgentCount);
|
||||
expect(pipeline.writtenAgentCounts).toEqual([batchCapacity, 10]);
|
||||
});
|
||||
|
||||
it('spawns agents in the movement direction', () => {
|
||||
const { pipeline, population } = createPopulation();
|
||||
|
||||
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(3, 0));
|
||||
|
||||
expect(population.activeAgentCount).toBe(3);
|
||||
expect(pipeline.writtenBatches[0][2]).toBe(0);
|
||||
});
|
||||
|
||||
it('queues stroke writes while async compaction is in flight', async () => {
|
||||
const { pipeline, population } = createPopulation();
|
||||
|
||||
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(10, 0));
|
||||
population.requestCompactionAfterErase();
|
||||
population.compactAfterErase(false);
|
||||
|
||||
population.spawnStrokeAgents(vec2.fromValues(10, 0), vec2.fromValues(15, 0));
|
||||
expect(population.activeAgentCount).toBe(10);
|
||||
expect(pipeline.writtenAgentCounts).toEqual([10]);
|
||||
|
||||
pipeline.finishCompaction(6);
|
||||
await population.waitForCompaction();
|
||||
|
||||
expect(population.activeAgentCount).toBe(11);
|
||||
expect(pipeline.writtenAgentOffsets).toEqual([0, 6]);
|
||||
expect(pipeline.writtenAgentCounts).toEqual([10, 5]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,64 +1,79 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
import { appConfig } from '../config';
|
||||
import { AGENT_FLOAT_COUNT } from '../pipelines/agents/agent-generation/agent';
|
||||
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
|
||||
import { AGENT_FLOAT_COUNT, writeAgentValues } from '../pipelines/agents/agent-limits';
|
||||
import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline';
|
||||
import { settings } from '../settings';
|
||||
import type { FramePerformance } from './frame-performance';
|
||||
import { createIntroTitleAgents } from './intro-title-agents';
|
||||
|
||||
export const GLOBAL_AGENT_CAP = appConfig.simulation.globalAgentCap;
|
||||
|
||||
const INITIAL_AGENT_COUNT = appConfig.simulation.initialAgentCount;
|
||||
const MIN_STROKE_AGENT_COUNT = appConfig.simulation.stroke.minAgentCount;
|
||||
const MAX_STROKE_AGENT_COUNT = appConfig.simulation.stroke.maxAgentCount;
|
||||
const STROKE_AGENT_DENSITY_MULTIPLIER = appConfig.simulation.stroke.densityMultiplier;
|
||||
const ADAPTIVE_CAP_MIN = appConfig.simulation.budget.adaptiveCapMin;
|
||||
const ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND =
|
||||
appConfig.simulation.budget.adaptiveCapDecreaseAgentsPerSecond;
|
||||
|
||||
export class AgentPopulation {
|
||||
private activeCount = 0;
|
||||
// Current performance-aware limit; new agents above it replace old agents.
|
||||
private adaptiveCap: number;
|
||||
// Next active agent slot to overwrite when new agents exceed the current cap.
|
||||
private replacementCursor = 0;
|
||||
private canExpandAdaptiveCap = true;
|
||||
private shouldCompactAfterErase = false;
|
||||
private isCompacting = false;
|
||||
private pendingCompaction: Promise<void> | null = null;
|
||||
private readonly queuedAgentBatches: Array<Float32Array> = [];
|
||||
private pendingStrokeAgentCount = 0;
|
||||
private readonly strokeAgentData = new Float32Array(
|
||||
MAX_STROKE_AGENT_COUNT * AGENT_FLOAT_COUNT
|
||||
appConfig.simulation.stroke.maxAgentCount * AGENT_FLOAT_COUNT
|
||||
);
|
||||
|
||||
public constructor(private readonly pipeline: AgentGenerationPipeline) {}
|
||||
public constructor(
|
||||
private readonly pipeline: AgentGenerationPipeline,
|
||||
private readonly introSeed: number,
|
||||
private readonly getCanvasPixelRatio: () => number,
|
||||
private readonly framePerformance: FramePerformance
|
||||
) {
|
||||
this.adaptiveCap = this.clampAndEnsureAdaptiveCap(
|
||||
this.framePerformance.adaptiveCapInitial
|
||||
);
|
||||
}
|
||||
|
||||
public get activeAgentCount(): number {
|
||||
return this.activeCount;
|
||||
}
|
||||
|
||||
public get maxAgentCount(): number {
|
||||
return this.pipeline.maxAgentCount;
|
||||
public initializeIntroAgents(canvasSize: vec2): void {
|
||||
this.replaceIntroAgents(canvasSize, 0);
|
||||
}
|
||||
|
||||
public initializeIntroAgents(canvasSize: vec2): void {
|
||||
settings.agentBudgetMax = this.clampAdaptiveCap(settings.agentBudgetMax);
|
||||
const introAgentCount = Math.min(settings.agentBudgetMax, INITIAL_AGENT_COUNT);
|
||||
this.writeAgentBatch(
|
||||
createIntroTitleAgents({
|
||||
count: introAgentCount,
|
||||
width: canvasSize[0],
|
||||
height: canvasSize[1],
|
||||
})
|
||||
public replaceIntroAgents(canvasSize: vec2, progress: number): void {
|
||||
this.adaptiveCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
|
||||
const introAgentCount = Math.min(
|
||||
this.adaptiveCap,
|
||||
appConfig.simulation.initialAgentCount
|
||||
);
|
||||
const data = createIntroTitleAgents({
|
||||
count: introAgentCount,
|
||||
width: canvasSize[0],
|
||||
height: canvasSize[1],
|
||||
progress,
|
||||
seed: this.introSeed,
|
||||
});
|
||||
|
||||
if (data.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pipeline.writeAgents(0, data);
|
||||
this.activeCount = data.length / AGENT_FLOAT_COUNT;
|
||||
this.replacementCursor = 0;
|
||||
}
|
||||
|
||||
public onVibeChanged(): void {
|
||||
settings.agentBudgetMax = this.clampAdaptiveCap(settings.agentBudgetMax);
|
||||
this.pendingStrokeAgentCount = 0;
|
||||
this.adaptiveCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
|
||||
this.trimActiveCountToBudget();
|
||||
}
|
||||
|
||||
public growBudget(
|
||||
deltaTime: number,
|
||||
smoothedFps: number,
|
||||
refreshTargetFps: number
|
||||
): void {
|
||||
this.updateAdaptiveCap(deltaTime, smoothedFps, refreshTargetFps);
|
||||
public beginStroke(): void {
|
||||
this.pendingStrokeAgentCount = 0;
|
||||
}
|
||||
|
||||
public resizeAgents(scale: vec2): void {
|
||||
|
|
@ -69,7 +84,7 @@ export class AgentPopulation {
|
|||
this.shouldCompactAfterErase = true;
|
||||
}
|
||||
|
||||
public async compactAfterErase(isSwipeActive: boolean): Promise<void> {
|
||||
public compactAfterErase(isSwipeActive: boolean): void {
|
||||
if (!this.shouldCompactAfterErase || this.isCompacting || isSwipeActive) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -80,47 +95,133 @@ export class AgentPopulation {
|
|||
}
|
||||
|
||||
this.isCompacting = true;
|
||||
try {
|
||||
const compactedAgentCount = await this.pipeline.compactAgents(this.activeCount);
|
||||
this.activeCount = compactedAgentCount;
|
||||
this.replacementCursor =
|
||||
compactedAgentCount === 0 ? 0 : this.replacementCursor % compactedAgentCount;
|
||||
} finally {
|
||||
this.isCompacting = false;
|
||||
this.pendingCompaction = this.pipeline
|
||||
.compactAgents(this.activeCount)
|
||||
.then((compactedAgentCount) => {
|
||||
const finiteCompactedAgentCount = Number.isFinite(compactedAgentCount)
|
||||
? Math.max(0, Math.floor(compactedAgentCount))
|
||||
: 0;
|
||||
this.activeCount = Math.min(this.activeCount, finiteCompactedAgentCount);
|
||||
this.clampReplacementCursor();
|
||||
this.trimActiveCountToBudget();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.warn('Could not compact agents after erase.', error);
|
||||
})
|
||||
.finally(() => {
|
||||
this.isCompacting = false;
|
||||
this.pendingCompaction = null;
|
||||
this.flushQueuedAgentBatches();
|
||||
});
|
||||
}
|
||||
|
||||
public async waitForCompaction(): Promise<void> {
|
||||
await this.pendingCompaction;
|
||||
}
|
||||
|
||||
public updateAdaptiveCap(): void {
|
||||
const previousCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
|
||||
this.canExpandAdaptiveCap = this.framePerformance.hasAdaptiveCapHeadroom;
|
||||
|
||||
if (this.canExpandAdaptiveCap) {
|
||||
this.adaptiveCap = previousCap;
|
||||
this.trimActiveCountToBudget();
|
||||
return;
|
||||
}
|
||||
|
||||
const decrease = this.framePerformance.adaptiveCapDecreaseAgents;
|
||||
const responsiveCap = Math.min(
|
||||
previousCap,
|
||||
this.clampAndEnsureAdaptiveCap(this.activeCount)
|
||||
);
|
||||
const nextCap = this.clampAndEnsureAdaptiveCap(responsiveCap - decrease);
|
||||
this.adaptiveCap = nextCap;
|
||||
this.trimActiveCountToBudget(decrease);
|
||||
}
|
||||
|
||||
public spawnStrokeAgents(from: vec2, to: vec2): void {
|
||||
const length = Math.max(1, vec2.dist(from, to));
|
||||
const count = Math.max(
|
||||
MIN_STROKE_AGENT_COUNT,
|
||||
Math.min(
|
||||
MAX_STROKE_AGENT_COUNT,
|
||||
Math.ceil(length * settings.spawnPerPixel * STROKE_AGENT_DENSITY_MULTIPLIER)
|
||||
)
|
||||
);
|
||||
const direction = vec2.sub(vec2.create(), to, from);
|
||||
const baseAngle = Math.atan2(direction[1], direction[0]);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const t = count === 1 ? 1 : i / (count - 1);
|
||||
const x = from[0] + (to[0] - from[0]) * t;
|
||||
const y = from[1] + (to[1] - from[1]) * t;
|
||||
const angle =
|
||||
(Number.isFinite(baseAngle) ? baseAngle : Math.random() * Math.PI * 2) +
|
||||
(Math.random() - 0.5) * appConfig.simulation.stroke.angleJitterRadians;
|
||||
const base = i * AGENT_FLOAT_COUNT;
|
||||
this.strokeAgentData[base] = x + (Math.random() - 0.5) * settings.brushSize;
|
||||
this.strokeAgentData[base + 1] = y + (Math.random() - 0.5) * settings.brushSize;
|
||||
this.strokeAgentData[base + 2] = angle;
|
||||
this.strokeAgentData[base + 3] = settings.selectedColorIndex;
|
||||
this.strokeAgentData[base + 4] = -1;
|
||||
this.strokeAgentData[base + 5] = -1;
|
||||
this.strokeAgentData[base + 6] = angle;
|
||||
this.strokeAgentData[base + 7] = 0;
|
||||
const deltaX = to[0] - from[0];
|
||||
const deltaY = to[1] - from[1];
|
||||
const length = Math.hypot(deltaX, deltaY);
|
||||
const spawnRate = getStrokeSpawnRate();
|
||||
if (!Number.isFinite(length) || length <= 0 || spawnRate <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.writeAgentBatch(this.strokeAgentData.subarray(0, count * AGENT_FLOAT_COUNT));
|
||||
const expectedAgentCount = length * spawnRate + this.pendingStrokeAgentCount;
|
||||
if (!Number.isFinite(expectedAgentCount)) {
|
||||
this.pendingStrokeAgentCount = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const count = Math.floor(expectedAgentCount);
|
||||
this.pendingStrokeAgentCount = expectedAgentCount - count;
|
||||
|
||||
if (count <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseAngle = Math.atan2(deltaY, deltaX);
|
||||
const spread = settings.brushSize * getSafePixelRatio(this.getCanvasPixelRatio());
|
||||
const batchCapacity = this.strokeAgentData.length / AGENT_FLOAT_COUNT;
|
||||
if (batchCapacity <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let written = 0; written < count; written += batchCapacity) {
|
||||
const batchCount = Math.min(batchCapacity, count - written);
|
||||
this.populateStrokeAgentBatch({
|
||||
baseAngle,
|
||||
batchCount,
|
||||
from,
|
||||
spread,
|
||||
to,
|
||||
totalCount: count,
|
||||
written,
|
||||
});
|
||||
this.writeAgentBatch(
|
||||
this.strokeAgentData.subarray(0, batchCount * AGENT_FLOAT_COUNT)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private populateStrokeAgentBatch({
|
||||
baseAngle,
|
||||
batchCount,
|
||||
from,
|
||||
spread,
|
||||
to,
|
||||
totalCount,
|
||||
written,
|
||||
}: {
|
||||
baseAngle: number;
|
||||
batchCount: number;
|
||||
from: vec2;
|
||||
spread: number;
|
||||
to: vec2;
|
||||
totalCount: number;
|
||||
written: number;
|
||||
}): void {
|
||||
for (let i = 0; i < batchCount; i++) {
|
||||
const agentIndex = written + i;
|
||||
const t = totalCount === 1 ? 0.5 : agentIndex / (totalCount - 1);
|
||||
const x = from[0] + (to[0] - from[0]) * t;
|
||||
const y = from[1] + (to[1] - from[1]) * t;
|
||||
const angle = baseAngle + (Math.random() - 0.5) * settings.strokeAngleJitterRadians;
|
||||
const positionX = x + (Math.random() - 0.5) * spread;
|
||||
const positionY = y + (Math.random() - 0.5) * spread;
|
||||
|
||||
writeAgentValues(this.strokeAgentData, i, {
|
||||
positionX,
|
||||
positionY,
|
||||
angle,
|
||||
colorIndex: settings.selectedColorIndex,
|
||||
targetPositionX: -1,
|
||||
targetPositionY: -1,
|
||||
targetAngle: angle,
|
||||
introDelay: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private writeAgentBatch(data: Float32Array): void {
|
||||
|
|
@ -128,10 +229,16 @@ export class AgentPopulation {
|
|||
return;
|
||||
}
|
||||
|
||||
if (this.isCompacting) {
|
||||
this.queuedAgentBatches.push(data.slice());
|
||||
return;
|
||||
}
|
||||
|
||||
const count = data.length / AGENT_FLOAT_COUNT;
|
||||
this.adaptiveCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
|
||||
this.expandAdaptiveCapForPendingAgents(count);
|
||||
|
||||
const available = Math.max(0, settings.agentBudgetMax - this.activeCount);
|
||||
const available = Math.max(0, this.adaptiveCap - this.activeCount);
|
||||
const appendCount = Math.min(count, available);
|
||||
|
||||
if (appendCount > 0) {
|
||||
|
|
@ -163,59 +270,63 @@ export class AgentPopulation {
|
|||
}
|
||||
}
|
||||
|
||||
private updateAdaptiveCap(
|
||||
deltaTime: number,
|
||||
smoothedFps: number,
|
||||
refreshTargetFps: number
|
||||
): void {
|
||||
const previousCap = this.clampAdaptiveCap(settings.agentBudgetMax);
|
||||
this.canExpandAdaptiveCap =
|
||||
refreshTargetFps <= 0 ||
|
||||
smoothedFps >= refreshTargetFps * appConfig.simulation.budget.fpsHeadroom;
|
||||
|
||||
if (this.canExpandAdaptiveCap) {
|
||||
settings.agentBudgetMax = previousCap;
|
||||
this.trimActiveCountToBudget();
|
||||
return;
|
||||
}
|
||||
|
||||
const decrease = Math.max(
|
||||
1,
|
||||
Math.ceil(ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND * deltaTime)
|
||||
);
|
||||
const nextCap = this.clampAdaptiveCap(previousCap - decrease);
|
||||
settings.agentBudgetMax = nextCap;
|
||||
this.trimActiveCountToBudget(decrease);
|
||||
private flushQueuedAgentBatches(): void {
|
||||
const batches = this.queuedAgentBatches.splice(0);
|
||||
batches.forEach((batch) => this.writeAgentBatch(batch));
|
||||
}
|
||||
|
||||
private expandAdaptiveCapForPendingAgents(requestedAgentCount: number): void {
|
||||
const available = Math.max(0, settings.agentBudgetMax - this.activeCount);
|
||||
const available = Math.max(0, this.adaptiveCap - this.activeCount);
|
||||
if (requestedAgentCount <= available || !this.canExpandAdaptiveCap) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentCap = this.clampAdaptiveCap(settings.agentBudgetMax);
|
||||
const currentCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
|
||||
const pendingAgentCount = requestedAgentCount - available;
|
||||
settings.agentBudgetMax = this.clampAdaptiveCap(currentCap + pendingAgentCount);
|
||||
this.adaptiveCap = this.clampAndEnsureAdaptiveCap(currentCap + pendingAgentCount);
|
||||
}
|
||||
|
||||
private trimActiveCountToBudget(maxDecrease = Number.POSITIVE_INFINITY): void {
|
||||
if (this.activeCount <= settings.agentBudgetMax) {
|
||||
if (this.activeCount <= this.adaptiveCap) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeCount = Math.max(
|
||||
settings.agentBudgetMax,
|
||||
this.adaptiveCap,
|
||||
this.activeCount - Math.max(1, Math.ceil(maxDecrease))
|
||||
);
|
||||
this.clampReplacementCursor();
|
||||
}
|
||||
|
||||
private clampReplacementCursor(): void {
|
||||
this.replacementCursor =
|
||||
this.activeCount === 0 ? 0 : this.replacementCursor % this.activeCount;
|
||||
}
|
||||
|
||||
private clampAdaptiveCap(value: number): number {
|
||||
const pipelineCap = Math.max(0, Math.floor(this.pipeline.maxAgentCount));
|
||||
const minCap = Math.min(ADAPTIVE_CAP_MIN, pipelineCap);
|
||||
private clampAndEnsureAdaptiveCap(value: number): number {
|
||||
const runtimeMaxCap =
|
||||
settings.maxAgentCount === Number.POSITIVE_INFINITY
|
||||
? Number.POSITIVE_INFINITY
|
||||
: Number.isFinite(settings.maxAgentCount)
|
||||
? Math.max(0, Math.floor(settings.maxAgentCount))
|
||||
: Math.max(0, Math.floor(this.pipeline.maxAgentCount));
|
||||
const maxCap = Math.min(this.pipeline.maxSupportedAgentCount, runtimeMaxCap);
|
||||
const minCap = Math.min(this.framePerformance.adaptiveCapMin, maxCap);
|
||||
const finiteValue = Number.isFinite(value) ? value : minCap;
|
||||
return Math.min(pipelineCap, Math.max(minCap, Math.round(finiteValue)));
|
||||
const nextCap = Math.min(maxCap, Math.max(minCap, Math.round(finiteValue)));
|
||||
return Math.min(
|
||||
nextCap,
|
||||
this.pipeline.ensureMaxAgentCount(nextCap, this.activeCount)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const getStrokeSpawnRate = (): number => {
|
||||
const spawnPerPixel = Number.isFinite(settings.spawnPerPixel)
|
||||
? settings.spawnPerPixel
|
||||
: 0;
|
||||
const densityMultiplier = Number.isFinite(appConfig.simulation.stroke.densityMultiplier)
|
||||
? appConfig.simulation.stroke.densityMultiplier
|
||||
: 0;
|
||||
return Math.max(0, spawnPerPixel * densityMultiplier);
|
||||
};
|
||||
|
|
|
|||
150
src/game-loop/brush-stroke-smoother.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
import { appConfig } from '../config';
|
||||
import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline';
|
||||
import { settings } from '../settings';
|
||||
import { type StrokeSegment } from './game-loop-types';
|
||||
|
||||
interface BrushStrokeSmootherOptions {
|
||||
getCanvasPixelRatio: () => number;
|
||||
getMirrorSegmentCount: () => number;
|
||||
}
|
||||
|
||||
export class BrushStrokeSmoother {
|
||||
private readonly strokePoints: Array<vec2> = [];
|
||||
private lastBrushPosition: vec2 | null = null;
|
||||
|
||||
public constructor(private readonly options: BrushStrokeSmootherOptions) {}
|
||||
|
||||
public addSample(position: vec2): Array<StrokeSegment> {
|
||||
const previousSample = this.strokePoints[this.strokePoints.length - 1];
|
||||
if (
|
||||
previousSample !== undefined &&
|
||||
vec2.squaredDistance(previousSample, position) <=
|
||||
getBrushSmoothingDistanceSquared(this.options.getCanvasPixelRatio())
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
this.strokePoints.push(vec2.clone(position));
|
||||
|
||||
if (this.strokePoints.length > 3) {
|
||||
this.strokePoints.shift();
|
||||
}
|
||||
|
||||
if (this.strokePoints.length === 1) {
|
||||
this.lastBrushPosition = vec2.clone(position);
|
||||
return [{ from: position, to: position }];
|
||||
}
|
||||
|
||||
if (this.strokePoints.length === 2) {
|
||||
const [start, end] = this.strokePoints;
|
||||
const midpoint = getMidpoint(start, end);
|
||||
this.lastBrushPosition = midpoint;
|
||||
return [{ from: start, to: midpoint }];
|
||||
}
|
||||
|
||||
const [start, control, end] = this.strokePoints;
|
||||
const curveStart = getMidpoint(start, control);
|
||||
const curveEnd = getMidpoint(control, end);
|
||||
this.lastBrushPosition = curveEnd;
|
||||
return this.getQuadraticSegments(curveStart, control, curveEnd);
|
||||
}
|
||||
|
||||
public finish(): Array<StrokeSegment> {
|
||||
if (this.strokePoints.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const finalSample = this.strokePoints[this.strokePoints.length - 1];
|
||||
if (
|
||||
this.lastBrushPosition !== null &&
|
||||
vec2.squaredDistance(this.lastBrushPosition, finalSample) >
|
||||
getBrushSmoothingDistanceSquared(this.options.getCanvasPixelRatio())
|
||||
) {
|
||||
return [{ from: this.lastBrushPosition, to: finalSample }];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this.strokePoints.length = 0;
|
||||
this.lastBrushPosition = null;
|
||||
}
|
||||
|
||||
public scale(scale: vec2): void {
|
||||
this.strokePoints.forEach((point) => {
|
||||
vec2.mul(point, point, scale);
|
||||
});
|
||||
|
||||
if (this.lastBrushPosition !== null) {
|
||||
vec2.mul(this.lastBrushPosition, this.lastBrushPosition, scale);
|
||||
}
|
||||
}
|
||||
|
||||
private getQuadraticSegments(
|
||||
start: vec2,
|
||||
control: vec2,
|
||||
end: vec2
|
||||
): Array<StrokeSegment> {
|
||||
const curveLength = vec2.distance(start, control) + vec2.distance(control, end);
|
||||
const canvasPixelRatio = getSafePixelRatio(this.options.getCanvasPixelRatio());
|
||||
const brushRadius = Math.max(
|
||||
settings.brushCurveMinBrushRadius * canvasPixelRatio,
|
||||
(settings.brushSize * canvasPixelRatio) / 2
|
||||
);
|
||||
const segmentSpacing = Math.max(
|
||||
settings.brushCurveMinSegmentSpacing * canvasPixelRatio,
|
||||
brushRadius * settings.brushCurveSegmentBrushRadiusRatio
|
||||
);
|
||||
const mirrorSegmentCount = Math.max(1, this.options.getMirrorSegmentCount());
|
||||
const curveResolution = getBrushCurveResolution();
|
||||
const maxCurveSegments = Math.max(
|
||||
1,
|
||||
Math.floor(
|
||||
curveResolution /
|
||||
Math.max(1, mirrorSegmentCount ** settings.brushCurveMirrorResolutionExponent)
|
||||
)
|
||||
);
|
||||
const segmentCount = Math.min(
|
||||
maxCurveSegments,
|
||||
Math.max(1, Math.ceil(curveLength / segmentSpacing))
|
||||
);
|
||||
|
||||
let previousPoint = start;
|
||||
const segments: Array<StrokeSegment> = [];
|
||||
for (let i = 1; i <= segmentCount; i++) {
|
||||
const point = getQuadraticPoint(start, control, end, i / segmentCount);
|
||||
segments.push({ from: previousPoint, to: point });
|
||||
previousPoint = point;
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
}
|
||||
|
||||
const getMidpoint = (from: vec2, to: vec2): vec2 =>
|
||||
vec2.fromValues((from[0] + to[0]) / 2, (from[1] + to[1]) / 2);
|
||||
|
||||
const getQuadraticPoint = (start: vec2, control: vec2, end: vec2, t: number): vec2 => {
|
||||
const inverseT = 1 - t;
|
||||
return vec2.fromValues(
|
||||
inverseT * inverseT * start[0] + 2 * inverseT * t * control[0] + t * t * end[0],
|
||||
inverseT * inverseT * start[1] + 2 * inverseT * t * control[1] + t * t * end[1]
|
||||
);
|
||||
};
|
||||
|
||||
const getBrushCurveResolution = (): number => {
|
||||
const resolution = Number.isFinite(settings.brushCurveResolution)
|
||||
? settings.brushCurveResolution
|
||||
: appConfig.defaultSettings.brushCurveResolution;
|
||||
return Math.max(1, Math.floor(resolution));
|
||||
};
|
||||
|
||||
const getBrushSmoothingDistanceSquared = (pixelRatio?: number): number => {
|
||||
const distance = Number.isFinite(settings.brushSmoothingMinSampleDistance)
|
||||
? settings.brushSmoothingMinSampleDistance
|
||||
: appConfig.defaultSettings.brushSmoothingMinSampleDistance;
|
||||
return Math.max(0, distance * getSafePixelRatio(pixelRatio)) ** 2;
|
||||
};
|
||||
|
|
@ -4,6 +4,7 @@ export class EraserPreview {
|
|||
private previewClientPosition: { x: number; y: number } | null = null;
|
||||
private isErasing = false;
|
||||
private isPointerHoveringCanvas = false;
|
||||
private isSwipeActive = false;
|
||||
private previousSize: number | null = null;
|
||||
private previousLeft = '';
|
||||
private previousTop = '';
|
||||
|
|
@ -11,19 +12,36 @@ export class EraserPreview {
|
|||
|
||||
public constructor(
|
||||
private readonly canvas: HTMLCanvasElement,
|
||||
private readonly element: HTMLElement
|
||||
private readonly element: HTMLElement,
|
||||
private readonly getIsSwipeActive: () => boolean
|
||||
) {}
|
||||
|
||||
public setEraseMode(isErasing: boolean, isSwipeActive: boolean): void {
|
||||
public attach(): void {
|
||||
this.canvas.addEventListener('pointerenter', this.onPointerEnter);
|
||||
this.canvas.addEventListener('pointerleave', this.onPointerLeave);
|
||||
this.canvas.addEventListener('pointerdown', this.onPointerDown);
|
||||
this.canvas.addEventListener('pointermove', this.onPointerMove);
|
||||
this.canvas.addEventListener('pointerup', this.onPointerUp);
|
||||
this.canvas.addEventListener('pointercancel', this.onPointerUp);
|
||||
}
|
||||
|
||||
public detach(): void {
|
||||
this.canvas.removeEventListener('pointerenter', this.onPointerEnter);
|
||||
this.canvas.removeEventListener('pointerleave', this.onPointerLeave);
|
||||
this.canvas.removeEventListener('pointerdown', this.onPointerDown);
|
||||
this.canvas.removeEventListener('pointermove', this.onPointerMove);
|
||||
this.canvas.removeEventListener('pointerup', this.onPointerUp);
|
||||
this.canvas.removeEventListener('pointercancel', this.onPointerUp);
|
||||
}
|
||||
|
||||
public setEraseMode(isErasing: boolean): void {
|
||||
this.isErasing = isErasing;
|
||||
this.update(undefined, isSwipeActive);
|
||||
this.update();
|
||||
}
|
||||
|
||||
public setPointerHoveringCanvas(isHovering: boolean): void {
|
||||
this.isPointerHoveringCanvas = isHovering;
|
||||
}
|
||||
public update(event?: PointerEvent): void {
|
||||
this.isSwipeActive = this.getIsSwipeActive();
|
||||
|
||||
public update(event?: PointerEvent, isSwipeActive = false): void {
|
||||
if (event) {
|
||||
this.previewClientPosition = {
|
||||
x: event.clientX,
|
||||
|
|
@ -39,7 +57,7 @@ export class EraserPreview {
|
|||
if (
|
||||
!this.isErasing ||
|
||||
this.previewClientPosition === null ||
|
||||
(!this.isPointerHoveringCanvas && !isSwipeActive)
|
||||
(!this.isPointerHoveringCanvas && !this.isSwipeActive)
|
||||
) {
|
||||
this.setVisible(false);
|
||||
return;
|
||||
|
|
@ -59,7 +77,16 @@ export class EraserPreview {
|
|||
this.setVisible(true);
|
||||
}
|
||||
|
||||
public isPointerInsideCanvas(event: PointerEvent): boolean {
|
||||
private setVisible(isVisible: boolean): void {
|
||||
if (this.isVisible === isVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isVisible = isVisible;
|
||||
this.element.classList.toggle('visible', isVisible);
|
||||
}
|
||||
|
||||
private isPointerInsideCanvas(event: PointerEvent): boolean {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
return (
|
||||
event.clientX >= rect.left &&
|
||||
|
|
@ -69,12 +96,27 @@ export class EraserPreview {
|
|||
);
|
||||
}
|
||||
|
||||
private setVisible(isVisible: boolean): void {
|
||||
if (this.isVisible === isVisible) {
|
||||
return;
|
||||
}
|
||||
private readonly onPointerDown = (event: PointerEvent) => {
|
||||
this.isPointerHoveringCanvas = true;
|
||||
this.update(event);
|
||||
};
|
||||
|
||||
this.isVisible = isVisible;
|
||||
this.element.classList.toggle('visible', isVisible);
|
||||
}
|
||||
private readonly onPointerMove = (event: PointerEvent) => {
|
||||
this.update(event);
|
||||
};
|
||||
|
||||
private readonly onPointerUp = (event: PointerEvent) => {
|
||||
this.isPointerHoveringCanvas = this.isPointerInsideCanvas(event);
|
||||
this.update(event);
|
||||
};
|
||||
|
||||
private readonly onPointerEnter = (event: PointerEvent) => {
|
||||
this.isPointerHoveringCanvas = true;
|
||||
this.update(event);
|
||||
};
|
||||
|
||||
private readonly onPointerLeave = () => {
|
||||
this.isPointerHoveringCanvas = false;
|
||||
this.update();
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,87 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
estimateExport4KMemory,
|
||||
formatByteSize,
|
||||
getAspectFitExport4KDimensions,
|
||||
getExport4KPreflightError,
|
||||
} from './export-4k';
|
||||
|
||||
const generousLimits = {
|
||||
maxBufferSize: Number.MAX_SAFE_INTEGER,
|
||||
maxTextureDimension2D: Number.MAX_SAFE_INTEGER,
|
||||
};
|
||||
|
||||
describe('4K export preflight', () => {
|
||||
it('fits export dimensions inside 4K while preserving source aspect ratio', () => {
|
||||
expect(getAspectFitExport4KDimensions(3840, 2160)).toEqual({
|
||||
width: 3840,
|
||||
height: 2160,
|
||||
});
|
||||
expect(getAspectFitExport4KDimensions(800, 600)).toEqual({
|
||||
width: 2880,
|
||||
height: 2160,
|
||||
});
|
||||
expect(getAspectFitExport4KDimensions(600, 800)).toEqual({
|
||||
width: 1620,
|
||||
height: 2160,
|
||||
});
|
||||
expect(getAspectFitExport4KDimensions(1000, 1000)).toEqual({
|
||||
width: 2160,
|
||||
height: 2160,
|
||||
});
|
||||
});
|
||||
|
||||
it('estimates padded readback and temporary memory for the export', () => {
|
||||
const estimate = estimateExport4KMemory();
|
||||
|
||||
expect(estimate.width).toBe(3840);
|
||||
expect(estimate.height).toBe(2160);
|
||||
expect(estimate.bytesPerRow % 256).toBe(0);
|
||||
expect(estimate.estimatedPeakBytes).toBeGreaterThan(estimate.textureBytes);
|
||||
expect(formatByteSize(estimate.estimatedPeakBytes)).toMatch(/MiB$/);
|
||||
});
|
||||
|
||||
it('rejects GPUs that cannot allocate the export texture', () => {
|
||||
const error = getExport4KPreflightError({
|
||||
limits: {
|
||||
maxBufferSize: Number.MAX_SAFE_INTEGER,
|
||||
maxTextureDimension2D: 2048,
|
||||
},
|
||||
});
|
||||
|
||||
expect(error?.code).toBe('export-4k-texture-too-large');
|
||||
});
|
||||
|
||||
it('rejects GPUs that cannot allocate the readback buffer', () => {
|
||||
const estimate = estimateExport4KMemory();
|
||||
const error = getExport4KPreflightError({
|
||||
limits: {
|
||||
maxBufferSize: estimate.readbackBufferBytes - 1,
|
||||
maxTextureDimension2D: Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
estimate,
|
||||
});
|
||||
|
||||
expect(error?.code).toBe('export-4k-readback-too-large');
|
||||
});
|
||||
|
||||
it('rejects browser-reported low-memory devices', () => {
|
||||
const error = getExport4KPreflightError({
|
||||
limits: generousLimits,
|
||||
memoryInfo: {
|
||||
deviceMemoryBytes: 2 * 1024 ** 3,
|
||||
},
|
||||
});
|
||||
|
||||
expect(error?.code).toBe('export-4k-low-device-memory');
|
||||
});
|
||||
|
||||
it('allows export when memory hints are unavailable', () => {
|
||||
expect(
|
||||
getExport4KPreflightError({
|
||||
limits: generousLimits,
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,222 +0,0 @@
|
|||
import { appConfig } from '../config';
|
||||
import { RuntimeError } from '../utils/error-handler';
|
||||
|
||||
const EXPORT_4K_WIDTH = appConfig.export4k.width;
|
||||
const EXPORT_4K_HEIGHT = appConfig.export4k.height;
|
||||
|
||||
const BYTES_PER_PIXEL = appConfig.export4k.bytesPerPixel;
|
||||
const ROW_ALIGNMENT_BYTES = appConfig.export4k.rowAlignmentBytes;
|
||||
const GIBIBYTE = 1024 ** 3;
|
||||
const LOW_MEMORY_DEVICE_GIB = appConfig.export4k.lowMemoryDeviceGiB;
|
||||
const LOW_MEMORY_EXPORT_FRACTION = appConfig.export4k.lowMemoryExportFraction;
|
||||
const JS_HEAP_SAFETY_MULTIPLIER = appConfig.export4k.jsHeapSafetyMultiplier;
|
||||
|
||||
interface Export4KMemoryEstimate {
|
||||
width: number;
|
||||
height: number;
|
||||
bytesPerPixel: number;
|
||||
unpaddedBytesPerRow: number;
|
||||
bytesPerRow: number;
|
||||
textureBytes: number;
|
||||
readbackBufferBytes: number;
|
||||
pixelBytes: number;
|
||||
canvasBytes: number;
|
||||
encoderSafetyBytes: number;
|
||||
estimatedJsHeapBytes: number;
|
||||
estimatedPeakBytes: number;
|
||||
}
|
||||
|
||||
interface Export4KDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface BrowserMemoryInfo {
|
||||
deviceMemoryBytes?: number;
|
||||
jsHeapSizeLimitBytes?: number;
|
||||
usedJsHeapSizeBytes?: number;
|
||||
}
|
||||
|
||||
interface Export4KPreflightOptions {
|
||||
limits: Pick<GPUSupportedLimits, 'maxBufferSize' | 'maxTextureDimension2D'>;
|
||||
memoryInfo?: BrowserMemoryInfo;
|
||||
estimate?: Export4KMemoryEstimate;
|
||||
}
|
||||
|
||||
const alignTo = (value: number, alignment: number): number =>
|
||||
Math.ceil(value / alignment) * alignment;
|
||||
|
||||
const getPositiveFiniteNumber = (value: unknown): number | undefined =>
|
||||
typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined;
|
||||
|
||||
export const formatByteSize = (bytes: number): string =>
|
||||
`${Math.ceil(bytes / 1024 / 1024)} MiB`;
|
||||
|
||||
export const getAspectFitExport4KDimensions = (
|
||||
sourceWidth: number,
|
||||
sourceHeight: number,
|
||||
maxWidth = EXPORT_4K_WIDTH,
|
||||
maxHeight = EXPORT_4K_HEIGHT
|
||||
): Export4KDimensions => {
|
||||
if (
|
||||
!Number.isFinite(sourceWidth) ||
|
||||
!Number.isFinite(sourceHeight) ||
|
||||
sourceWidth <= 0 ||
|
||||
sourceHeight <= 0
|
||||
) {
|
||||
return { width: maxWidth, height: maxHeight };
|
||||
}
|
||||
|
||||
const scale = Math.min(maxWidth / sourceWidth, maxHeight / sourceHeight);
|
||||
|
||||
return {
|
||||
width: Math.min(maxWidth, Math.max(1, Math.round(sourceWidth * scale))),
|
||||
height: Math.min(maxHeight, Math.max(1, Math.round(sourceHeight * scale))),
|
||||
};
|
||||
};
|
||||
|
||||
export const estimateExport4KMemory = (
|
||||
width = EXPORT_4K_WIDTH,
|
||||
height = EXPORT_4K_HEIGHT
|
||||
): Export4KMemoryEstimate => {
|
||||
const unpaddedBytesPerRow = width * BYTES_PER_PIXEL;
|
||||
const bytesPerRow = alignTo(unpaddedBytesPerRow, ROW_ALIGNMENT_BYTES);
|
||||
const textureBytes = unpaddedBytesPerRow * height;
|
||||
const readbackBufferBytes = bytesPerRow * height;
|
||||
const pixelBytes = textureBytes;
|
||||
const canvasBytes = textureBytes;
|
||||
const encoderSafetyBytes = textureBytes * 2;
|
||||
const estimatedJsHeapBytes = pixelBytes + canvasBytes + encoderSafetyBytes;
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
bytesPerPixel: BYTES_PER_PIXEL,
|
||||
unpaddedBytesPerRow,
|
||||
bytesPerRow,
|
||||
textureBytes,
|
||||
readbackBufferBytes,
|
||||
pixelBytes,
|
||||
canvasBytes,
|
||||
encoderSafetyBytes,
|
||||
estimatedJsHeapBytes,
|
||||
estimatedPeakBytes: textureBytes + readbackBufferBytes + estimatedJsHeapBytes,
|
||||
};
|
||||
};
|
||||
|
||||
export const getBrowserExportMemoryInfo = (): BrowserMemoryInfo => {
|
||||
const navigatorWithMemory =
|
||||
typeof navigator === 'undefined'
|
||||
? undefined
|
||||
: (navigator as Navigator & { deviceMemory?: number });
|
||||
const performanceWithMemory =
|
||||
typeof performance === 'undefined'
|
||||
? undefined
|
||||
: (performance as Performance & {
|
||||
memory?: {
|
||||
jsHeapSizeLimit?: number;
|
||||
usedJSHeapSize?: number;
|
||||
};
|
||||
});
|
||||
|
||||
const deviceMemoryGib = getPositiveFiniteNumber(navigatorWithMemory?.deviceMemory);
|
||||
const jsHeapSizeLimitBytes = getPositiveFiniteNumber(
|
||||
performanceWithMemory?.memory?.jsHeapSizeLimit
|
||||
);
|
||||
const usedJsHeapSizeBytes = getPositiveFiniteNumber(
|
||||
performanceWithMemory?.memory?.usedJSHeapSize
|
||||
);
|
||||
|
||||
return {
|
||||
...(deviceMemoryGib === undefined
|
||||
? {}
|
||||
: { deviceMemoryBytes: deviceMemoryGib * GIBIBYTE }),
|
||||
...(jsHeapSizeLimitBytes === undefined ? {} : { jsHeapSizeLimitBytes }),
|
||||
...(usedJsHeapSizeBytes === undefined ? {} : { usedJsHeapSizeBytes }),
|
||||
};
|
||||
};
|
||||
|
||||
export const getExport4KPreflightError = ({
|
||||
limits,
|
||||
memoryInfo = {},
|
||||
estimate = estimateExport4KMemory(),
|
||||
}: Export4KPreflightOptions): RuntimeError | null => {
|
||||
if (
|
||||
estimate.width > limits.maxTextureDimension2D ||
|
||||
estimate.height > limits.maxTextureDimension2D
|
||||
) {
|
||||
return new RuntimeError(
|
||||
'export-4k-texture-too-large',
|
||||
'This GPU cannot create a 3840x2160 export texture.',
|
||||
{
|
||||
details: {
|
||||
exportWidth: estimate.width,
|
||||
exportHeight: estimate.height,
|
||||
maxTextureDimension2D: limits.maxTextureDimension2D,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (estimate.readbackBufferBytes > limits.maxBufferSize) {
|
||||
return new RuntimeError(
|
||||
'export-4k-readback-too-large',
|
||||
'This GPU cannot allocate the 4K export readback buffer.',
|
||||
{
|
||||
details: {
|
||||
readbackBufferBytes: estimate.readbackBufferBytes,
|
||||
maxBufferSize: limits.maxBufferSize,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
memoryInfo.deviceMemoryBytes !== undefined &&
|
||||
memoryInfo.deviceMemoryBytes <= LOW_MEMORY_DEVICE_GIB * GIBIBYTE &&
|
||||
estimate.estimatedPeakBytes >
|
||||
memoryInfo.deviceMemoryBytes * LOW_MEMORY_EXPORT_FRACTION
|
||||
) {
|
||||
return new RuntimeError(
|
||||
'export-4k-low-device-memory',
|
||||
`4K upscale export needs about ${formatByteSize(
|
||||
estimate.estimatedPeakBytes
|
||||
)} of temporary memory, which is not safe on this low-memory device.`,
|
||||
{
|
||||
details: {
|
||||
deviceMemoryBytes: memoryInfo.deviceMemoryBytes,
|
||||
estimatedPeakBytes: estimate.estimatedPeakBytes,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
memoryInfo.jsHeapSizeLimitBytes !== undefined &&
|
||||
memoryInfo.usedJsHeapSizeBytes !== undefined
|
||||
) {
|
||||
const availableJsHeapBytes =
|
||||
memoryInfo.jsHeapSizeLimitBytes - memoryInfo.usedJsHeapSizeBytes;
|
||||
if (
|
||||
availableJsHeapBytes <
|
||||
estimate.estimatedJsHeapBytes * JS_HEAP_SAFETY_MULTIPLIER
|
||||
) {
|
||||
return new RuntimeError(
|
||||
'export-4k-low-js-heap',
|
||||
`4K upscale export needs about ${formatByteSize(
|
||||
estimate.estimatedJsHeapBytes
|
||||
)} of JavaScript heap, and this browser does not report enough free heap.`,
|
||||
{
|
||||
details: {
|
||||
availableJsHeapBytes,
|
||||
estimatedJsHeapBytes: estimate.estimatedJsHeapBytes,
|
||||
jsHeapSizeLimitBytes: memoryInfo.jsHeapSizeLimitBytes,
|
||||
usedJsHeapSizeBytes: memoryInfo.usedJsHeapSizeBytes,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
|
@ -1,68 +1,56 @@
|
|||
import { appConfig } from '../config';
|
||||
import { RenderPipeline } from '../pipelines/render/render-pipeline';
|
||||
import {
|
||||
estimateExport4KMemory,
|
||||
getAspectFitExport4KDimensions,
|
||||
getBrowserExportMemoryInfo,
|
||||
getExport4KPreflightError,
|
||||
} from './export-4k';
|
||||
import type { VibeId } from '../vibes';
|
||||
|
||||
interface Export4KRendererOptions {
|
||||
interface ExportSnapshotRendererOptions {
|
||||
device: GPUDevice;
|
||||
renderPipeline: RenderPipeline;
|
||||
canvasFormat: GPUTextureFormat;
|
||||
statusElement: HTMLElement;
|
||||
seed: string;
|
||||
getSourceSize: () => { width: number; height: number };
|
||||
getColorTextureView: () => GPUTextureView;
|
||||
getSourceTextureView: () => GPUTextureView;
|
||||
getVibeId: () => string;
|
||||
getSourceActive?: () => boolean;
|
||||
getVibeId: () => VibeId;
|
||||
}
|
||||
|
||||
export class Export4KRenderer {
|
||||
interface SnapshotLayout {
|
||||
width: number;
|
||||
height: number;
|
||||
unpaddedBytesPerRow: number;
|
||||
bytesPerRow: number;
|
||||
readbackBufferBytes: number;
|
||||
}
|
||||
|
||||
export class ExportSnapshotRenderer {
|
||||
private isExporting = false;
|
||||
|
||||
public constructor(private readonly options: Export4KRendererOptions) {}
|
||||
public constructor(private readonly options: ExportSnapshotRendererOptions) {}
|
||||
|
||||
public async export(): Promise<void> {
|
||||
if (this.isExporting) {
|
||||
this.statusElement.textContent = '4K upscale already rendering...';
|
||||
this.statusElement.textContent = 'Snapshot already saving...';
|
||||
return;
|
||||
}
|
||||
|
||||
this.isExporting = true;
|
||||
this.statusElement.textContent = 'Rendering 4K upscale...';
|
||||
this.statusElement.textContent = 'Saving snapshot...';
|
||||
|
||||
try {
|
||||
const sourceSize = this.options.getSourceSize();
|
||||
const exportDimensions = getAspectFitExport4KDimensions(
|
||||
sourceSize.width,
|
||||
sourceSize.height
|
||||
);
|
||||
const estimate = estimateExport4KMemory(
|
||||
exportDimensions.width,
|
||||
exportDimensions.height
|
||||
);
|
||||
const preflightError = getExport4KPreflightError({
|
||||
limits: this.device.limits,
|
||||
memoryInfo: getBrowserExportMemoryInfo(),
|
||||
estimate,
|
||||
});
|
||||
if (preflightError) {
|
||||
this.statusElement.textContent = '4K upscale unavailable';
|
||||
throw preflightError;
|
||||
}
|
||||
|
||||
await this.renderExport(estimate);
|
||||
await this.renderSnapshot(getSnapshotLayout(sourceSize.width, sourceSize.height));
|
||||
this.statusElement.textContent = '';
|
||||
} catch (error) {
|
||||
this.statusElement.textContent = 'Snapshot failed';
|
||||
throw error;
|
||||
} finally {
|
||||
this.isExporting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async renderExport(
|
||||
estimate: ReturnType<typeof estimateExport4KMemory>
|
||||
): Promise<void> {
|
||||
const { width, height, unpaddedBytesPerRow, bytesPerRow } = estimate;
|
||||
const format = navigator.gpu.getPreferredCanvasFormat();
|
||||
private async renderSnapshot(layout: SnapshotLayout): Promise<void> {
|
||||
const { width, height, unpaddedBytesPerRow, bytesPerRow } = layout;
|
||||
let texture: GPUTexture | null = null;
|
||||
let output: GPUBuffer | null = null;
|
||||
let isOutputMapped = false;
|
||||
|
|
@ -70,14 +58,11 @@ export class Export4KRenderer {
|
|||
try {
|
||||
texture = this.device.createTexture({
|
||||
size: { width, height },
|
||||
format,
|
||||
usage:
|
||||
GPUTextureUsage.RENDER_ATTACHMENT |
|
||||
GPUTextureUsage.COPY_SRC |
|
||||
GPUTextureUsage.TEXTURE_BINDING,
|
||||
format: this.options.canvasFormat,
|
||||
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
|
||||
});
|
||||
output = this.device.createBuffer({
|
||||
size: estimate.readbackBufferBytes,
|
||||
size: layout.readbackBufferBytes,
|
||||
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
|
||||
});
|
||||
|
||||
|
|
@ -86,7 +71,8 @@ export class Export4KRenderer {
|
|||
commandEncoder,
|
||||
this.options.getColorTextureView(),
|
||||
this.options.getSourceTextureView(),
|
||||
texture.createView()
|
||||
texture.createView(),
|
||||
this.options.getSourceActive?.() ?? true
|
||||
);
|
||||
commandEncoder.copyTextureToBuffer(
|
||||
{ texture },
|
||||
|
|
@ -97,13 +83,13 @@ export class Export4KRenderer {
|
|||
|
||||
await output.mapAsync(GPUMapMode.READ);
|
||||
isOutputMapped = true;
|
||||
const pixels = readExportPixels({
|
||||
const pixels = readSnapshotPixels({
|
||||
mapped: new Uint8Array(output.getMappedRange()),
|
||||
width,
|
||||
height,
|
||||
unpaddedBytesPerRow,
|
||||
bytesPerRow,
|
||||
isBgra: format === 'bgra8unorm',
|
||||
isBgra: this.options.canvasFormat === 'bgra8unorm',
|
||||
});
|
||||
output.unmap();
|
||||
isOutputMapped = false;
|
||||
|
|
@ -113,9 +99,6 @@ export class Export4KRenderer {
|
|||
texture = null;
|
||||
|
||||
await this.downloadPixels(pixels, width, height);
|
||||
} catch (error) {
|
||||
this.statusElement.textContent = '4K upscale failed';
|
||||
throw error;
|
||||
} finally {
|
||||
if (output && isOutputMapped) {
|
||||
output.unmap();
|
||||
|
|
@ -135,15 +118,18 @@ export class Export4KRenderer {
|
|||
if (!context) {
|
||||
throw new Error('Could not create export canvas');
|
||||
}
|
||||
|
||||
context.putImageData(new ImageData(pixels, width, height), 0, 0);
|
||||
const blob = await canvas.convertToBlob({ type: 'image/png' });
|
||||
const blob = await canvas.convertToBlob({
|
||||
type: appConfig.exportSnapshot.mimeType,
|
||||
});
|
||||
const link = document.createElement('a');
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
try {
|
||||
link.href = objectUrl;
|
||||
link.download = `fleeting-garden_${this.options.getVibeId()}_${
|
||||
link.download = `${appConfig.exportSnapshot.filenamePrefix}_${this.options.getVibeId()}_${
|
||||
this.options.seed
|
||||
}_${width}x${height}-upscale.png`;
|
||||
}_${width}x${height}${appConfig.exportSnapshot.filenameSuffix}.${appConfig.exportSnapshot.filenameExtension}`;
|
||||
link.click();
|
||||
} finally {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
|
|
@ -159,7 +145,31 @@ export class Export4KRenderer {
|
|||
}
|
||||
}
|
||||
|
||||
const readExportPixels = ({
|
||||
const alignTo = (value: number, alignment: number): number =>
|
||||
Math.ceil(value / alignment) * alignment;
|
||||
|
||||
const getSnapshotDimension = (value: number): number =>
|
||||
Number.isFinite(value) && value > 0 ? Math.max(1, Math.floor(value)) : 1;
|
||||
|
||||
const getSnapshotLayout = (sourceWidth: number, sourceHeight: number): SnapshotLayout => {
|
||||
const width = getSnapshotDimension(sourceWidth);
|
||||
const height = getSnapshotDimension(sourceHeight);
|
||||
const unpaddedBytesPerRow = width * appConfig.exportSnapshot.bytesPerPixel;
|
||||
const bytesPerRow = alignTo(
|
||||
unpaddedBytesPerRow,
|
||||
appConfig.exportSnapshot.rowAlignmentBytes
|
||||
);
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
unpaddedBytesPerRow,
|
||||
bytesPerRow,
|
||||
readbackBufferBytes: bytesPerRow * height,
|
||||
};
|
||||
};
|
||||
|
||||
const readSnapshotPixels = ({
|
||||
mapped,
|
||||
width,
|
||||
height,
|
||||
|
|
@ -181,8 +191,8 @@ const readExportPixels = ({
|
|||
const sourceOffset = y * bytesPerRow;
|
||||
const targetOffset = y * unpaddedBytesPerRow;
|
||||
for (let x = 0; x < width; x++) {
|
||||
const source = sourceOffset + x * 4;
|
||||
const target = targetOffset + x * 4;
|
||||
const source = sourceOffset + x * appConfig.exportSnapshot.bytesPerPixel;
|
||||
const target = targetOffset + x * appConfig.exportSnapshot.bytesPerPixel;
|
||||
pixels[target] = isBgra ? mapped[source + 2] : mapped[source];
|
||||
pixels[target + 1] = mapped[source + 1];
|
||||
pixels[target + 2] = isBgra ? mapped[source] : mapped[source + 2];
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { FramePerformance } from './frame-performance';
|
||||
|
||||
describe('FramePerformance refresh target', () => {
|
||||
it('uses 60 FPS as the fixed adaptive budget target', () => {
|
||||
const performance = new FramePerformance();
|
||||
|
||||
[123, 126, 130, 121, 60, 30].forEach((fps) => performance.update(1 / fps));
|
||||
|
||||
expect(performance.refreshTargetFps).toBe(60);
|
||||
});
|
||||
|
||||
it('keeps latest and smoothed FPS separate from the fixed target', () => {
|
||||
const performance = new FramePerformance();
|
||||
|
||||
performance.update(1 / 120);
|
||||
|
||||
expect(performance.latestFps).toBe(120);
|
||||
expect(performance.smoothedFps).toBeGreaterThan(60);
|
||||
expect(performance.refreshTargetFps).toBe(60);
|
||||
});
|
||||
|
||||
it('snaps the display refresh estimate to a stable screen frequency', () => {
|
||||
const performance = new FramePerformance();
|
||||
|
||||
[123, 126, 130, 121, 124, 127, 125, 122].forEach((fps) =>
|
||||
performance.update(1 / fps)
|
||||
);
|
||||
|
||||
expect(performance.refreshTargetFps).toBe(60);
|
||||
expect(performance.displayRefreshFps).toBe(120);
|
||||
});
|
||||
|
||||
it('ignores a single startup spike before settling the display refresh estimate', () => {
|
||||
const performance = new FramePerformance();
|
||||
|
||||
performance.update(1 / 240);
|
||||
|
||||
expect(performance.displayRefreshFps).toBe(60);
|
||||
|
||||
Array.from({ length: 8 }).forEach(() => performance.update(1 / 120));
|
||||
|
||||
expect(performance.refreshTargetFps).toBe(60);
|
||||
expect(performance.displayRefreshFps).toBe(120);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,138 +1,61 @@
|
|||
import { appConfig } from '../config';
|
||||
|
||||
interface TelemetrySnapshot {
|
||||
frameCpuStartedAt: number;
|
||||
encodeCpuMs: number;
|
||||
activeAgentCount: number;
|
||||
agentBudgetMax: number;
|
||||
canvas: HTMLCanvasElement;
|
||||
devicePixelRatio: number;
|
||||
renderSpeed: number;
|
||||
}
|
||||
|
||||
const COMMON_DISPLAY_REFRESH_RATES = [
|
||||
50, 60, 72, 75, 90, 100, 120, 144, 165, 180, 240,
|
||||
] as const;
|
||||
const DISPLAY_REFRESH_CONFIRMATION_FRAMES = 8;
|
||||
const DISPLAY_REFRESH_SNAP_TOLERANCE = 0.15;
|
||||
import { settings } from '../settings';
|
||||
|
||||
export class FramePerformance {
|
||||
public latestFps = 60;
|
||||
public smoothedFps = 60;
|
||||
public displayRefreshFps = 60;
|
||||
public readonly refreshTargetFps = 60;
|
||||
private readonly adaptiveRefreshTargetFps = 60;
|
||||
private readonly initialFps = this.adaptiveRefreshTargetFps;
|
||||
public smoothedFps = this.initialFps;
|
||||
|
||||
private lastTelemetryAt = 0;
|
||||
private hasConfirmedDisplayRefreshFps = false;
|
||||
private pendingDisplayRefreshFps = 0;
|
||||
private pendingDisplayRefreshFrameCount = 0;
|
||||
public measuredFps = 0;
|
||||
public frameDeltaSeconds = 0;
|
||||
public measuredFrameTimeMs = 0;
|
||||
|
||||
public markCpuStart(): number {
|
||||
return appConfig.telemetry.enabled ? performance.now() : 0;
|
||||
private readonly adaptiveCapDecreaseAgentsPerSecond = 200_000;
|
||||
private readonly frameGapResetSeconds = 1;
|
||||
private readonly fpsHeadroom = 0.9;
|
||||
private readonly fpsSmoothingNew = 0.06;
|
||||
private readonly fpsSmoothingRetain = 1 - this.fpsSmoothingNew;
|
||||
private previousFrameTime: DOMHighResTimeStamp | null = null;
|
||||
|
||||
public get adaptiveCapInitial(): number {
|
||||
return settings.adaptiveCapInitial;
|
||||
}
|
||||
|
||||
public measureSince(startedAt: number): number {
|
||||
return appConfig.telemetry.enabled ? performance.now() - startedAt : 0;
|
||||
public get adaptiveCapMin(): number {
|
||||
return settings.adaptiveCapMin;
|
||||
}
|
||||
|
||||
public update(deltaTime: number): void {
|
||||
const fps = 1 / Math.max(deltaTime, appConfig.deltaTime.minDeltaTimeSeconds);
|
||||
this.latestFps = fps;
|
||||
this.updateDisplayRefreshEstimate(fps);
|
||||
public get hasAdaptiveCapHeadroom(): boolean {
|
||||
return this.smoothedFps >= this.adaptiveRefreshTargetFps * this.fpsHeadroom;
|
||||
}
|
||||
|
||||
public get adaptiveCapDecreaseAgents(): number {
|
||||
return Math.max(
|
||||
1,
|
||||
Math.ceil(this.adaptiveCapDecreaseAgentsPerSecond * this.frameDeltaSeconds)
|
||||
);
|
||||
}
|
||||
|
||||
public update(time: DOMHighResTimeStamp): void {
|
||||
const previous = this.previousFrameTime;
|
||||
this.previousFrameTime = time;
|
||||
if (previous === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaSeconds = (time - previous) / 1000;
|
||||
if (deltaSeconds <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fps = 1 / deltaSeconds;
|
||||
this.frameDeltaSeconds = deltaSeconds;
|
||||
this.measuredFrameTimeMs = deltaSeconds * 1000;
|
||||
this.measuredFps = fps;
|
||||
if (deltaSeconds > this.frameGapResetSeconds) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.smoothedFps =
|
||||
this.smoothedFps * appConfig.simulation.budget.fpsSmoothingRetain +
|
||||
fps * appConfig.simulation.budget.fpsSmoothingNew;
|
||||
}
|
||||
|
||||
public renderTelemetry({
|
||||
frameCpuStartedAt,
|
||||
encodeCpuMs,
|
||||
activeAgentCount,
|
||||
agentBudgetMax,
|
||||
canvas,
|
||||
devicePixelRatio,
|
||||
renderSpeed,
|
||||
}: TelemetrySnapshot): void {
|
||||
if (!appConfig.telemetry.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = performance.now();
|
||||
if (now - this.lastTelemetryAt < appConfig.telemetry.intervalMs) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastTelemetryAt = now;
|
||||
console.debug('Fleeting Garden telemetry', {
|
||||
fps: Math.round(this.latestFps),
|
||||
smoothedFps: Math.round(this.smoothedFps),
|
||||
refreshTargetFps: Math.round(this.refreshTargetFps),
|
||||
displayRefreshFps: Math.round(this.displayRefreshFps),
|
||||
activeAgentCount,
|
||||
agentBudgetMax,
|
||||
canvasWidth: canvas.width,
|
||||
canvasHeight: canvas.height,
|
||||
dpr: devicePixelRatio,
|
||||
renderSpeed,
|
||||
frameCpuMs: now - frameCpuStartedAt,
|
||||
encodeCpuMs,
|
||||
});
|
||||
}
|
||||
|
||||
private updateDisplayRefreshEstimate(fps: number): void {
|
||||
const displayRefreshFps = this.snapDisplayRefreshRate(fps);
|
||||
if (displayRefreshFps === null) {
|
||||
this.resetPendingDisplayRefreshEstimate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
this.hasConfirmedDisplayRefreshFps &&
|
||||
displayRefreshFps < this.displayRefreshFps
|
||||
) {
|
||||
this.resetPendingDisplayRefreshEstimate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (displayRefreshFps !== this.pendingDisplayRefreshFps) {
|
||||
this.pendingDisplayRefreshFps = displayRefreshFps;
|
||||
this.pendingDisplayRefreshFrameCount = 1;
|
||||
} else {
|
||||
this.pendingDisplayRefreshFrameCount += 1;
|
||||
}
|
||||
|
||||
if (this.pendingDisplayRefreshFrameCount < DISPLAY_REFRESH_CONFIRMATION_FRAMES) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.displayRefreshFps = displayRefreshFps;
|
||||
this.hasConfirmedDisplayRefreshFps = true;
|
||||
this.resetPendingDisplayRefreshEstimate();
|
||||
}
|
||||
|
||||
private snapDisplayRefreshRate(fps: number): number | null {
|
||||
if (!Number.isFinite(fps) || fps <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let nearestRefreshRate: number = COMMON_DISPLAY_REFRESH_RATES[0];
|
||||
let nearestDifference = Math.abs(fps - nearestRefreshRate);
|
||||
|
||||
COMMON_DISPLAY_REFRESH_RATES.forEach((refreshRate) => {
|
||||
const difference = Math.abs(fps - refreshRate);
|
||||
if (difference < nearestDifference) {
|
||||
nearestRefreshRate = refreshRate;
|
||||
nearestDifference = difference;
|
||||
}
|
||||
});
|
||||
|
||||
return nearestDifference / nearestRefreshRate <= DISPLAY_REFRESH_SNAP_TOLERANCE
|
||||
? nearestRefreshRate
|
||||
: null;
|
||||
}
|
||||
|
||||
private resetPendingDisplayRefreshEstimate(): void {
|
||||
this.pendingDisplayRefreshFps = 0;
|
||||
this.pendingDisplayRefreshFrameCount = 0;
|
||||
this.smoothedFps * this.fpsSmoothingRetain + fps * this.fpsSmoothingNew;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const gameLoopSource = readFileSync(
|
||||
join(process.cwd(), 'src/game-loop/game-loop.ts'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const getStartDrawingHandlerSource = () => {
|
||||
const start = gameLoopSource.indexOf('onStartDrawing:');
|
||||
const end = gameLoopSource.indexOf('onEraseGestureEnded:', start);
|
||||
|
||||
if (start < 0 || end < 0) {
|
||||
throw new Error('Could not find the pointer drawing intro handler');
|
||||
}
|
||||
|
||||
return gameLoopSource.slice(start, end);
|
||||
};
|
||||
|
||||
describe('GameLoop intro drawing policy', () => {
|
||||
it('allows drawing to start without completing the intro sequence', () => {
|
||||
const handlerSource = getStartDrawingHandlerSource();
|
||||
|
||||
expect(handlerSource).toContain('this.introPrompt.markStartedDrawing()');
|
||||
expect(handlerSource).not.toContain('this.introPrompt.complete(');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const simulationFrameSource = readFileSync(
|
||||
join(process.cwd(), 'src/game-loop/simulation-frame.ts'),
|
||||
'utf8'
|
||||
);
|
||||
const simulationTexturesSource = readFileSync(
|
||||
join(process.cwd(), 'src/game-loop/simulation-textures.ts'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const getRenderStepSource = () => {
|
||||
const start = simulationFrameSource.indexOf('for (let i = 0; i < renderSpeed; i++)');
|
||||
const end = simulationFrameSource.indexOf(' public clearSwipes', start);
|
||||
|
||||
if (start < 0 || end < 0) {
|
||||
throw new Error('Could not find the render-speed simulation loop');
|
||||
}
|
||||
|
||||
return simulationFrameSource.slice(start, end);
|
||||
};
|
||||
|
||||
describe('GameLoop ping-pong texture flow', () => {
|
||||
it('copies only the trail map and swaps source/influence references after diffusion', () => {
|
||||
const renderStepSource = getRenderStepSource();
|
||||
|
||||
expect(renderStepSource.match(/copyPipeline\.execute/g)).toHaveLength(1);
|
||||
expect(renderStepSource).toMatch(
|
||||
/this\.pipelines\.copyPipeline\.execute\([\s\S]*this\.textures\.trailMapA\.getTextureView\(\)[\s\S]*this\.textures\.trailMapB\.getTextureView\(\)[\s\S]*\);/
|
||||
);
|
||||
expect(renderStepSource).toMatch(
|
||||
/this\.pipelines\.diffusionPipeline\.execute\([\s\S]*this\.textures\.sourceMapA\.getTextureView\(\)[\s\S]*this\.textures\.sourceMapB\.getTextureView\(\)[\s\S]*\);[\s\S]*this\.pipelines\.brushEffectDiffusionPipeline\.execute\([\s\S]*this\.textures\.influenceMapA\.getTextureView\(\)[\s\S]*this\.textures\.influenceMapB\.getTextureView\(\)[\s\S]*\);[\s\S]*this\.device\.queue\.submit\(\[commandEncoder\.finish\(\)\]\);[\s\S]*this\.textures\.swapSourceMaps\(\);[\s\S]*this\.textures\.swapInfluenceMaps\(\);/
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps ping-pong texture references mutable and swaps A/B identities', () => {
|
||||
expect(simulationTexturesSource).toContain('public sourceMapA: ResizableTexture;');
|
||||
expect(simulationTexturesSource).toContain('public sourceMapB: ResizableTexture;');
|
||||
expect(simulationTexturesSource).toContain('public influenceMapA: ResizableTexture;');
|
||||
expect(simulationTexturesSource).toContain('public influenceMapB: ResizableTexture;');
|
||||
expect(simulationTexturesSource).toContain(
|
||||
'[this.sourceMapA, this.sourceMapB] = [this.sourceMapB, this.sourceMapA];'
|
||||
);
|
||||
expect(simulationTexturesSource).toContain(
|
||||
'[this.influenceMapA, this.influenceMapB] = [this.influenceMapB, this.influenceMapA];'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -5,15 +5,14 @@ import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/ag
|
|||
import { AgentPipeline } from '../pipelines/agents/agent-pipeline';
|
||||
import { BrushPipeline } from '../pipelines/brush/brush-pipeline';
|
||||
import { CommonState } from '../pipelines/common-state/common-state';
|
||||
import { CopyPipeline } from '../pipelines/copy/copy-pipeline';
|
||||
import { DiffusionPipeline } from '../pipelines/diffusion/diffusion-pipeline';
|
||||
import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline';
|
||||
import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline';
|
||||
import { RenderPipeline } from '../pipelines/render/render-pipeline';
|
||||
import { settings } from '../settings';
|
||||
import { initializeContext } from '../utils/graphics/initialize-context';
|
||||
import { GLOBAL_AGENT_CAP } from './agent-population';
|
||||
import { RenderInputs } from './game-loop-types';
|
||||
import { CanvasReadbackRequest, RenderInputs } from './game-loop-types';
|
||||
import { GpuProfiler } from './gpu-profiler';
|
||||
import { SimulationFrameRenderer } from './simulation-frame';
|
||||
import { SimulationTextures } from './simulation-textures';
|
||||
|
||||
|
|
@ -22,109 +21,121 @@ interface FrameParameters extends RenderInputs {
|
|||
deltaTime: number;
|
||||
canvasSize: vec2;
|
||||
activeAgentCount: number;
|
||||
canvasPixelRatio: number;
|
||||
introProgress: number;
|
||||
selectedColorIndex: number;
|
||||
isErasing: boolean;
|
||||
cameraCenter: [number, number];
|
||||
cameraZoom: number;
|
||||
eraserPixelSize: number;
|
||||
}
|
||||
|
||||
export class GameLoopResources {
|
||||
public readonly textures: SimulationTextures;
|
||||
public readonly commonState: CommonState;
|
||||
public readonly copyPipeline: CopyPipeline;
|
||||
public readonly agentGenerationPipeline: AgentGenerationPipeline;
|
||||
public readonly agentPipeline: AgentPipeline;
|
||||
public readonly brushPipeline: BrushPipeline;
|
||||
public readonly eraserAgentPipeline: EraserAgentPipeline;
|
||||
public readonly eraserTexturePipeline: EraserTexturePipeline;
|
||||
public readonly diffusionPipeline: DiffusionPipeline;
|
||||
public readonly brushEffectDiffusionPipeline: DiffusionPipeline;
|
||||
public readonly renderPipeline: RenderPipeline;
|
||||
public readonly gpuProfiler: GpuProfiler | null;
|
||||
|
||||
private readonly frameRenderer: SimulationFrameRenderer;
|
||||
|
||||
public constructor(
|
||||
canvas: HTMLCanvasElement,
|
||||
private readonly device: GPUDevice,
|
||||
canvasSize: vec2
|
||||
private readonly canvasFormat: GPUTextureFormat,
|
||||
canvasSize: vec2,
|
||||
initialAgentCapacity: number
|
||||
) {
|
||||
const context = initializeContext({ device, canvas });
|
||||
const context = initializeContext({ device, canvas, format: canvasFormat });
|
||||
|
||||
this.textures = new SimulationTextures(this.device, canvasSize);
|
||||
this.copyPipeline = new CopyPipeline(this.device);
|
||||
|
||||
this.commonState = new CommonState(this.device);
|
||||
this.commonState.setParameters({
|
||||
canvasSize,
|
||||
time: 0,
|
||||
deltaTime: 0,
|
||||
});
|
||||
|
||||
this.agentGenerationPipeline = new AgentGenerationPipeline(
|
||||
this.device,
|
||||
this.commonState,
|
||||
GLOBAL_AGENT_CAP
|
||||
Math.min(settings.maxAgentCount, initialAgentCapacity)
|
||||
);
|
||||
|
||||
this.agentPipeline = new AgentPipeline(
|
||||
this.device,
|
||||
this.commonState,
|
||||
this.agentGenerationPipeline.agentsBuffer
|
||||
() => this.agentGenerationPipeline.agentsBuffer
|
||||
);
|
||||
this.brushPipeline = new BrushPipeline(this.device, this.commonState);
|
||||
this.eraserAgentPipeline = new EraserAgentPipeline(
|
||||
this.device,
|
||||
this.commonState,
|
||||
this.agentGenerationPipeline.agentsBuffer
|
||||
() => this.agentGenerationPipeline.agentsBuffer
|
||||
);
|
||||
this.eraserTexturePipeline = new EraserTexturePipeline(this.device, this.commonState);
|
||||
this.diffusionPipeline = new DiffusionPipeline(this.device, this.commonState);
|
||||
this.brushEffectDiffusionPipeline = new DiffusionPipeline(
|
||||
this.diffusionPipeline = new DiffusionPipeline(this.device);
|
||||
this.renderPipeline = new RenderPipeline(
|
||||
context,
|
||||
this.device,
|
||||
this.commonState
|
||||
this.commonState,
|
||||
this.canvasFormat
|
||||
);
|
||||
this.gpuProfiler = GpuProfiler.create(
|
||||
this.device,
|
||||
() => appConfig.tuningPane.showFpsOverlay
|
||||
);
|
||||
this.renderPipeline = new RenderPipeline(context, this.device, this.commonState);
|
||||
|
||||
this.frameRenderer = new SimulationFrameRenderer(this.device, this.textures, {
|
||||
copyPipeline: this.copyPipeline,
|
||||
agentPipeline: this.agentPipeline,
|
||||
brushPipeline: this.brushPipeline,
|
||||
eraserAgentPipeline: this.eraserAgentPipeline,
|
||||
eraserTexturePipeline: this.eraserTexturePipeline,
|
||||
diffusionPipeline: this.diffusionPipeline,
|
||||
brushEffectDiffusionPipeline: this.brushEffectDiffusionPipeline,
|
||||
renderPipeline: this.renderPipeline,
|
||||
});
|
||||
this.frameRenderer = new SimulationFrameRenderer(
|
||||
this.device,
|
||||
this.textures,
|
||||
{
|
||||
agentPipeline: this.agentPipeline,
|
||||
brushPipeline: this.brushPipeline,
|
||||
eraserAgentPipeline: this.eraserAgentPipeline,
|
||||
eraserTexturePipeline: this.eraserTexturePipeline,
|
||||
diffusionPipeline: this.diffusionPipeline,
|
||||
renderPipeline: this.renderPipeline,
|
||||
},
|
||||
this.gpuProfiler
|
||||
);
|
||||
}
|
||||
|
||||
public resizeSimulationTo(nextSize: vec2): vec2 | null {
|
||||
return this.textures.resizeTo(nextSize);
|
||||
}
|
||||
|
||||
public clearSimulation(): void {
|
||||
this.textures.clear();
|
||||
this.frameRenderer.resetSourceMapActivity();
|
||||
}
|
||||
|
||||
public get isSourceMapActive(): boolean {
|
||||
return this.frameRenderer.isSourceMapActive;
|
||||
}
|
||||
|
||||
public get gpuPassTimeMs(): number | undefined {
|
||||
return this.gpuProfiler?.latestTotalPassMs;
|
||||
}
|
||||
|
||||
public setFrameParameters({
|
||||
time,
|
||||
deltaTime,
|
||||
canvasSize,
|
||||
activeAgentCount,
|
||||
canvasPixelRatio,
|
||||
introProgress,
|
||||
selectedColorIndex,
|
||||
isErasing,
|
||||
channelColors,
|
||||
backgroundColor,
|
||||
cameraCenter,
|
||||
cameraZoom,
|
||||
eraserPixelSize,
|
||||
}: FrameParameters): void {
|
||||
this.commonState.setParameters({
|
||||
canvasSize,
|
||||
time,
|
||||
deltaTime,
|
||||
});
|
||||
this.agentPipeline.setParameters({
|
||||
...settings,
|
||||
deltaTime,
|
||||
time,
|
||||
agentCount: activeAgentCount,
|
||||
moveSpeed:
|
||||
settings.moveSpeed *
|
||||
|
|
@ -136,57 +147,48 @@ export class GameLoopResources {
|
|||
});
|
||||
this.brushPipeline.setParameters({
|
||||
...settings,
|
||||
pixelRatio: canvasPixelRatio,
|
||||
selectedColorIndex,
|
||||
isErasing,
|
||||
});
|
||||
this.diffusionPipeline.setParameters(settings);
|
||||
this.renderPipeline.setParameters({
|
||||
...settings,
|
||||
channelColors,
|
||||
backgroundColor,
|
||||
cameraCenter,
|
||||
cameraZoom,
|
||||
});
|
||||
this.eraserAgentPipeline.setParameters({
|
||||
agentCount: activeAgentCount,
|
||||
eraserSize: eraserPixelSize,
|
||||
eraserMaskAlphaThreshold: settings.eraserMaskAlphaThreshold,
|
||||
maskSize: canvasSize,
|
||||
});
|
||||
this.eraserTexturePipeline.setParameters({
|
||||
eraserSize: eraserPixelSize,
|
||||
eraserLineDistanceEpsilon: settings.eraserLineDistanceEpsilon,
|
||||
eraserClearRed: settings.eraserClearRed,
|
||||
eraserClearGreen: settings.eraserClearGreen,
|
||||
eraserClearBlue: settings.eraserClearBlue,
|
||||
eraserClearAlpha: settings.eraserClearAlpha,
|
||||
});
|
||||
this.setBrushEffectDiffusionParameters();
|
||||
}
|
||||
|
||||
public executeFrame(renderSpeed: number, isErasing: boolean): void {
|
||||
this.frameRenderer.execute(renderSpeed, isErasing);
|
||||
}
|
||||
|
||||
public clearSwipes(): void {
|
||||
this.frameRenderer.clearSwipes();
|
||||
public executeFrame(
|
||||
isErasing: boolean,
|
||||
canvasReadbackRequest?: CanvasReadbackRequest | null
|
||||
): void {
|
||||
this.frameRenderer.execute(isErasing, canvasReadbackRequest);
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.copyPipeline.destroy();
|
||||
this.agentGenerationPipeline.destroy();
|
||||
this.agentPipeline.destroy();
|
||||
this.brushPipeline.destroy();
|
||||
this.eraserAgentPipeline.destroy();
|
||||
this.eraserTexturePipeline.destroy();
|
||||
this.diffusionPipeline.destroy();
|
||||
this.brushEffectDiffusionPipeline.destroy();
|
||||
this.renderPipeline.destroy();
|
||||
this.gpuProfiler?.destroy();
|
||||
this.commonState.destroy();
|
||||
this.textures.destroy();
|
||||
}
|
||||
|
||||
private setBrushEffectDiffusionParameters(): void {
|
||||
const framesToOneE = Math.max(
|
||||
1,
|
||||
settings.brushEffectDuration * appConfig.simulation.brushEffectFramesPerSecond
|
||||
);
|
||||
this.brushEffectDiffusionPipeline.setParameters({
|
||||
...settings,
|
||||
decayRateTrails: Math.exp(-1 / framesToOneE) * 1000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +0,0 @@
|
|||
export interface GameLoopSettings {
|
||||
agentBudgetMax: number;
|
||||
agentCount: number;
|
||||
renderSpeed: number;
|
||||
simulatedDelayMs: number;
|
||||
selectedColorIndex: number;
|
||||
spawnPerPixel: number;
|
||||
|
||||
startColorHue: number;
|
||||
}
|
||||
|
|
@ -1,17 +1,26 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
import type { RgbColor } from '../utils/rgb-color';
|
||||
|
||||
export interface GardenUi {
|
||||
prompt: HTMLElement;
|
||||
eraserPreview: HTMLElement;
|
||||
exportStatus: HTMLElement;
|
||||
grainOverlay: HTMLElement;
|
||||
prompt: HTMLElement;
|
||||
toolbar: HTMLElement;
|
||||
}
|
||||
|
||||
export interface RenderInputs {
|
||||
channelColors: Array<[number, number, number]>;
|
||||
backgroundColor: [number, number, number];
|
||||
channelColors: [RgbColor, RgbColor, RgbColor];
|
||||
backgroundColor: RgbColor;
|
||||
}
|
||||
|
||||
export interface StrokeSegment {
|
||||
from: vec2;
|
||||
to: vec2;
|
||||
}
|
||||
|
||||
export interface CanvasReadbackRequest {
|
||||
encode(commandEncoder: GPUCommandEncoder, texture: GPUTexture): void;
|
||||
afterSubmit(): void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,126 +1,146 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
import { GardenAudio } from '../audio/garden-audio';
|
||||
import { gardenAudioConfig } from '../audio/garden-audio-config';
|
||||
import { appConfig } from '../config';
|
||||
import { activeVibe, settings } from '../settings';
|
||||
import { DeltaTimeCalculator } from '../utils/delta-time-calculator';
|
||||
import { sleep } from '../utils/sleep';
|
||||
import { rgbColorToCss, type RgbColor } from '../utils/rgb-color';
|
||||
import { AgentPopulation } from './agent-population';
|
||||
import { EraserPreview } from './eraser-preview';
|
||||
import { Export4KRenderer } from './export-4k-renderer';
|
||||
import { ExportSnapshotRenderer } from './export-snapshot-renderer';
|
||||
import { FramePerformance } from './frame-performance';
|
||||
import { GameLoopResources } from './game-loop-resources';
|
||||
import { GardenUi } from './game-loop-types';
|
||||
import { getInternalRenderSize } from './internal-render-size';
|
||||
import { IntroPrompt } from './intro-prompt';
|
||||
import { PerfStatsOverlay } from './perf-stats-overlay';
|
||||
import { GardenPointerInput } from './pointer-input';
|
||||
import { RenderInputCache } from './render-input-cache';
|
||||
import { PipelineStrokeOutput } from './stroke-output';
|
||||
import { ToolbarContrastMonitor } from './toolbar-contrast-monitor';
|
||||
|
||||
export default class GameLoop {
|
||||
private static readonly MAX_MIRROR_SEGMENT_COUNT =
|
||||
appConfig.simulation.maxMirrorSegmentCount;
|
||||
private static readonly DEV_STATS_INTERVAL_MS = 250;
|
||||
|
||||
private readonly resources: GameLoopResources;
|
||||
private readonly audio = new GardenAudio(
|
||||
gardenAudioConfig,
|
||||
appConfig.audioEngine,
|
||||
appConfig.simulation.maxMirrorSegmentCount
|
||||
);
|
||||
private readonly renderInputs = new RenderInputCache();
|
||||
private readonly audio = new GardenAudio(appConfig.audio);
|
||||
private readonly introPrompt: IntroPrompt;
|
||||
private readonly eraserPreview: EraserPreview;
|
||||
private readonly pointerInput: GardenPointerInput;
|
||||
private readonly agentPopulation: AgentPopulation;
|
||||
private readonly export4KRenderer: Export4KRenderer;
|
||||
private readonly exportSnapshotRenderer: ExportSnapshotRenderer;
|
||||
private readonly framePerformance = new FramePerformance();
|
||||
private readonly devStatsElement: HTMLDivElement | null;
|
||||
private readonly seed = Math.floor(Math.random() * 0xffffffff).toString(16);
|
||||
private perfStatsOverlay: PerfStatsOverlay | null = null;
|
||||
private readonly toolbarContrastMonitor: ToolbarContrastMonitor;
|
||||
private readonly seedValue = Math.floor(Math.random() * 0xffffffff);
|
||||
private readonly seed = this.seedValue.toString(16);
|
||||
private readonly resizeListener = this.resize.bind(this);
|
||||
private readonly keydownListener: (event: KeyboardEvent) => void;
|
||||
private readonly _canvasSize: vec2 = vec2.create();
|
||||
|
||||
private lastDevStatsUpdateAt = 0;
|
||||
private isStatsOverlayPinned = false;
|
||||
private pendingIntroResizeAt: DOMHighResTimeStamp | null = null;
|
||||
private previousAccentColor = '';
|
||||
private previousGrainStrength = Number.NaN;
|
||||
private hasFinished = false;
|
||||
private animationFrameId: number | null = null;
|
||||
private destroyPromise: Promise<void> | null = null;
|
||||
private readonly finished = Promise.withResolvers<void>();
|
||||
|
||||
public constructor(
|
||||
private readonly canvas: HTMLCanvasElement,
|
||||
device: GPUDevice,
|
||||
private readonly device: GPUDevice,
|
||||
private readonly canvasFormat: GPUTextureFormat,
|
||||
private readonly deltaTimeCalculator: DeltaTimeCalculator,
|
||||
ui: GardenUi
|
||||
private readonly ui: GardenUi
|
||||
) {
|
||||
this.resize();
|
||||
this.devStatsElement = this.createDevStatsElement();
|
||||
this.syncDevStatsVisibility();
|
||||
this.resources = new GameLoopResources(canvas, device, this.canvasSize);
|
||||
this.resources = new GameLoopResources(
|
||||
canvas,
|
||||
device,
|
||||
this.canvasFormat,
|
||||
this.canvasSize,
|
||||
this.framePerformance.adaptiveCapInitial
|
||||
);
|
||||
this.introPrompt = new IntroPrompt(ui.prompt);
|
||||
this.eraserPreview = new EraserPreview(canvas, ui.eraserPreview);
|
||||
this.agentPopulation = new AgentPopulation(this.resources.agentGenerationPipeline);
|
||||
this.toolbarContrastMonitor = new ToolbarContrastMonitor(
|
||||
canvas,
|
||||
ui.toolbar,
|
||||
device,
|
||||
this.canvasFormat
|
||||
);
|
||||
this.agentPopulation = new AgentPopulation(
|
||||
this.resources.agentGenerationPipeline,
|
||||
this.seedValue,
|
||||
() => this.canvasPixelRatio,
|
||||
this.framePerformance
|
||||
);
|
||||
this.agentPopulation.initializeIntroAgents(this.canvasSize);
|
||||
this.pointerInput = new GardenPointerInput({
|
||||
canvas,
|
||||
audio: this.audio,
|
||||
brushPipeline: this.resources.brushPipeline,
|
||||
eraserAgentPipeline: this.resources.eraserAgentPipeline,
|
||||
eraserTexturePipeline: this.resources.eraserTexturePipeline,
|
||||
eraserPreview: this.eraserPreview,
|
||||
getCanvasSize: () => this.canvasSize,
|
||||
getDevicePixelRatio: () => this.devicePixelRatio,
|
||||
strokeOutput: new PipelineStrokeOutput(
|
||||
this.resources.brushPipeline,
|
||||
this.resources.eraserAgentPipeline,
|
||||
this.resources.eraserTexturePipeline
|
||||
),
|
||||
getCanvasPixelRatio: () => this.canvasPixelRatio,
|
||||
getMirrorSegmentCount: () => this.mirrorSegmentCount,
|
||||
onStartDrawing: () => this.introPrompt.markStartedDrawing(),
|
||||
onStartDrawing: () => {
|
||||
this.introPrompt.markStartedDrawing();
|
||||
this.agentPopulation.beginStroke();
|
||||
},
|
||||
onEraseGestureEnded: () => this.agentPopulation.requestCompactionAfterErase(),
|
||||
spawnStrokeAgents: (from, to) => this.agentPopulation.spawnStrokeAgents(from, to),
|
||||
});
|
||||
this.export4KRenderer = new Export4KRenderer({
|
||||
this.eraserPreview = new EraserPreview(
|
||||
canvas,
|
||||
ui.eraserPreview,
|
||||
() => this.pointerInput.isSwipeActive
|
||||
);
|
||||
this.exportSnapshotRenderer = new ExportSnapshotRenderer({
|
||||
device,
|
||||
renderPipeline: this.resources.renderPipeline,
|
||||
canvasFormat: this.canvasFormat,
|
||||
statusElement: ui.exportStatus,
|
||||
seed: this.seed,
|
||||
getSourceSize: () => ({
|
||||
width: this.canvas.width,
|
||||
height: this.canvas.height,
|
||||
}),
|
||||
getSourceSize: () => {
|
||||
const size = this.resources.textures.trailMapA.getSize();
|
||||
return {
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
};
|
||||
},
|
||||
getColorTextureView: () => this.resources.textures.trailMapA.getTextureView(),
|
||||
getSourceTextureView: () => this.resources.textures.sourceMapA.getTextureView(),
|
||||
getSourceActive: () => this.resources.isSourceMapActive,
|
||||
getVibeId: () => activeVibe.id,
|
||||
});
|
||||
this.keydownListener = (event: KeyboardEvent) => {
|
||||
this.audio.start(activeVibe, { userGesture: event.isTrusted });
|
||||
this.introPrompt.complete();
|
||||
};
|
||||
|
||||
window.addEventListener('resize', this.resizeListener);
|
||||
window.addEventListener('keydown', this.keydownListener, { once: true });
|
||||
this.eraserPreview.attach();
|
||||
this.syncPerfStatsOverlay();
|
||||
}
|
||||
|
||||
public attachPointerInput(): void {
|
||||
this.pointerInput.attach();
|
||||
}
|
||||
|
||||
public setEraseMode(isErasing: boolean): void {
|
||||
this.pointerInput.setEraseMode(isErasing);
|
||||
this.eraserPreview.setEraseMode(isErasing);
|
||||
}
|
||||
|
||||
public updateEraserPreview(event?: PointerEvent): void {
|
||||
this.pointerInput.updateEraserPreview(event);
|
||||
this.eraserPreview.update(event);
|
||||
}
|
||||
|
||||
public onVibeChanged(): void {
|
||||
this.agentPopulation.onVibeChanged();
|
||||
this.renderInputs.invalidate();
|
||||
this.syncPerfStatsOverlay();
|
||||
}
|
||||
|
||||
public setAudioMuted(isMuted: boolean): void {
|
||||
this.audio.setMuted(isMuted);
|
||||
}
|
||||
|
||||
public setStatsOverlayPinned(isPinned: boolean): void {
|
||||
const wasVisible = this.shouldShowDevStats;
|
||||
this.isStatsOverlayPinned = isPinned;
|
||||
this.syncDevStatsVisibility();
|
||||
|
||||
if (!wasVisible && this.shouldShowDevStats) {
|
||||
this.lastDevStatsUpdateAt = Number.NEGATIVE_INFINITY;
|
||||
this.updateDevStats(performance.now());
|
||||
}
|
||||
public setAudioVolume(volume: number): void {
|
||||
this.audio.setMasterVolume(volume);
|
||||
}
|
||||
|
||||
public startAudio(userGesture = false): void {
|
||||
|
|
@ -132,162 +152,144 @@ export default class GameLoop {
|
|||
}
|
||||
|
||||
public async start(): Promise<void> {
|
||||
requestAnimationFrame(this.render);
|
||||
if (this.animationFrameId === null && !this.hasFinished) {
|
||||
this.animationFrameId = requestAnimationFrame(this.render);
|
||||
}
|
||||
return this.finished.promise;
|
||||
}
|
||||
|
||||
public get maxAgentCount(): number {
|
||||
return this.agentPopulation.maxAgentCount;
|
||||
}
|
||||
|
||||
public async export4K(): Promise<void> {
|
||||
return this.export4KRenderer.export();
|
||||
public async exportSnapshot(): Promise<void> {
|
||||
return this.exportSnapshotRenderer.export();
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
this.destroyPromise ??= this.dispose();
|
||||
return this.destroyPromise;
|
||||
}
|
||||
|
||||
private async dispose(): Promise<void> {
|
||||
this.hasFinished = true;
|
||||
await this.finished.promise;
|
||||
if (this.animationFrameId !== null) {
|
||||
cancelAnimationFrame(this.animationFrameId);
|
||||
this.animationFrameId = null;
|
||||
}
|
||||
this.finished.resolve();
|
||||
|
||||
window.removeEventListener('resize', this.resizeListener);
|
||||
window.removeEventListener('keydown', this.keydownListener);
|
||||
this.pointerInput.detach();
|
||||
this.devStatsElement?.remove();
|
||||
this.eraserPreview.detach();
|
||||
this.perfStatsOverlay?.destroy();
|
||||
this.perfStatsOverlay = null;
|
||||
this.toolbarContrastMonitor.destroy();
|
||||
this.introPrompt.destroy();
|
||||
await this.agentPopulation.waitForCompaction();
|
||||
this.resources.destroy();
|
||||
await this.audio.destroy();
|
||||
}
|
||||
|
||||
private readonly render = async (time: DOMHighResTimeStamp) => {
|
||||
private readonly render = (time: DOMHighResTimeStamp) => {
|
||||
this.animationFrameId = null;
|
||||
if (this.hasFinished) {
|
||||
this.finished.resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const frameCpuStartedAt = this.framePerformance.markCpuStart();
|
||||
const deltaTime = this.deltaTimeCalculator.calculateDeltaTimeInSeconds(time);
|
||||
this.framePerformance.update(deltaTime);
|
||||
this.agentPopulation.growBudget(
|
||||
deltaTime,
|
||||
this.framePerformance.smoothedFps,
|
||||
this.framePerformance.refreshTargetFps
|
||||
);
|
||||
this.introPrompt.update();
|
||||
this.framePerformance.update(time);
|
||||
this.agentPopulation.updateAdaptiveCap();
|
||||
this.introPrompt.update(this.pendingIntroResizeAt === null ? deltaTime : 0);
|
||||
this.resize();
|
||||
this.resizeSimulationToCanvas();
|
||||
this.resizeSimulationToCanvas(time);
|
||||
this.regenerateIntroAfterSettledResize(time);
|
||||
|
||||
const scaledTime = time * settings.renderSpeed;
|
||||
const { channelColors, backgroundColor } = this.renderInputs.get();
|
||||
const channelColors = activeVibe.colors;
|
||||
const backgroundColor = activeVibe.backgroundColor;
|
||||
const introProgress = this.introPrompt.progress;
|
||||
const cameraZoom = 1;
|
||||
const cameraCenter: [number, number] = [
|
||||
this.canvas.width / 2,
|
||||
this.canvas.height / 2,
|
||||
];
|
||||
const eraserPixelSize = settings.eraserSize * this.devicePixelRatio;
|
||||
const canvasPixelRatio = this.canvasPixelRatio;
|
||||
const eraserPixelSize = settings.eraserSize * canvasPixelRatio;
|
||||
const isErasing = this.pointerInput.isEraseMode;
|
||||
const accentColor = channelColors[settings.selectedColorIndex] ?? channelColors[0];
|
||||
this.renderInputs.updateAccentColor(accentColor);
|
||||
this.updateAccentColor(accentColor);
|
||||
this.updateGrainOverlay(settings.backgroundGrainStrength);
|
||||
this.audio.update({
|
||||
vibe: activeVibe,
|
||||
selectedColorIndex: settings.selectedColorIndex,
|
||||
isErasing,
|
||||
mirrorSegmentCount: this.mirrorSegmentCount,
|
||||
});
|
||||
|
||||
this.resources.setFrameParameters({
|
||||
time: scaledTime,
|
||||
time,
|
||||
deltaTime,
|
||||
canvasSize: this.canvasSize,
|
||||
activeAgentCount: this.agentPopulation.activeAgentCount,
|
||||
canvasPixelRatio,
|
||||
introProgress,
|
||||
selectedColorIndex: settings.selectedColorIndex,
|
||||
isErasing,
|
||||
channelColors,
|
||||
backgroundColor,
|
||||
cameraCenter,
|
||||
cameraZoom,
|
||||
eraserPixelSize,
|
||||
});
|
||||
|
||||
const encodeCpuStartedAt = this.framePerformance.markCpuStart();
|
||||
this.resources.executeFrame(settings.renderSpeed, isErasing);
|
||||
const encodeCpuMs = this.framePerformance.measureSince(encodeCpuStartedAt);
|
||||
this.resources.executeFrame(
|
||||
isErasing,
|
||||
this.toolbarContrastMonitor.takeReadbackRequest(time)
|
||||
);
|
||||
|
||||
this.pointerInput.clearSwipesIfIdle();
|
||||
await this.agentPopulation.compactAfterErase(this.pointerInput.isSwipeActive);
|
||||
|
||||
this.framePerformance.renderTelemetry({
|
||||
frameCpuStartedAt,
|
||||
encodeCpuMs,
|
||||
activeAgentCount: this.agentPopulation.activeAgentCount,
|
||||
agentBudgetMax: settings.agentBudgetMax,
|
||||
canvas: this.canvas,
|
||||
devicePixelRatio: this.devicePixelRatio,
|
||||
renderSpeed: settings.renderSpeed,
|
||||
this.agentPopulation.compactAfterErase(this.pointerInput.isSwipeActive);
|
||||
this.perfStatsOverlay?.update({
|
||||
time,
|
||||
fps: this.framePerformance.measuredFps,
|
||||
agentCount: this.agentPopulation.activeAgentCount,
|
||||
frameTimeMs: this.framePerformance.measuredFrameTimeMs,
|
||||
gpuPassTimeMs: this.resources.gpuPassTimeMs,
|
||||
renderWidth: this.canvas.width,
|
||||
renderHeight: this.canvas.height,
|
||||
});
|
||||
this.updateDevStats(time);
|
||||
|
||||
if (settings.simulatedDelayMs > 0) {
|
||||
await sleep(settings.simulatedDelayMs);
|
||||
}
|
||||
|
||||
requestAnimationFrame(this.render);
|
||||
this.animationFrameId = requestAnimationFrame(this.render);
|
||||
};
|
||||
|
||||
private createDevStatsElement(): HTMLDivElement | null {
|
||||
const container = this.canvas.parentElement;
|
||||
if (!container) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const element = document.createElement('div');
|
||||
element.className = 'dev-stats-overlay';
|
||||
element.setAttribute('aria-hidden', 'true');
|
||||
container.appendChild(element);
|
||||
return element;
|
||||
}
|
||||
|
||||
private updateDevStats(time: DOMHighResTimeStamp): void {
|
||||
if (
|
||||
!this.devStatsElement ||
|
||||
!this.shouldShowDevStats ||
|
||||
time - this.lastDevStatsUpdateAt < GameLoop.DEV_STATS_INTERVAL_MS
|
||||
) {
|
||||
private syncPerfStatsOverlay(): void {
|
||||
if (appConfig.tuningPane.showFpsOverlay) {
|
||||
this.perfStatsOverlay ??= new PerfStatsOverlay(
|
||||
this.canvas.parentElement ?? document.body
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastDevStatsUpdateAt = time;
|
||||
const displayRefreshFps = Math.round(this.framePerformance.displayRefreshFps);
|
||||
this.devStatsElement.textContent = [
|
||||
`FPS ${this.framePerformance.smoothedFps.toFixed(1)} / ${displayRefreshFps}`,
|
||||
`Agents ${this.formatDevStatNumber(this.agentPopulation.activeAgentCount)}`,
|
||||
`Cap ${this.formatDevStatNumber(settings.agentBudgetMax)}`,
|
||||
].join('\n');
|
||||
this.perfStatsOverlay?.destroy();
|
||||
this.perfStatsOverlay = null;
|
||||
}
|
||||
|
||||
private syncDevStatsVisibility(): void {
|
||||
if (!this.devStatsElement) {
|
||||
private updateAccentColor(color: RgbColor): void {
|
||||
const accentColor = rgbColorToCss(color);
|
||||
if (this.previousAccentColor === accentColor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isVisible = this.shouldShowDevStats;
|
||||
this.devStatsElement.hidden = !isVisible;
|
||||
this.devStatsElement.setAttribute('aria-hidden', String(!isVisible));
|
||||
this.previousAccentColor = accentColor;
|
||||
document.documentElement.style.setProperty('--accent-color', accentColor);
|
||||
}
|
||||
|
||||
private formatDevStatNumber(value: number): string {
|
||||
return Math.max(0, Math.round(value)).toLocaleString('en-US');
|
||||
private updateGrainOverlay(strength: number): void {
|
||||
const safeStrength = Number.isFinite(strength) ? Math.max(0, strength) : 0;
|
||||
if (Object.is(this.previousGrainStrength, safeStrength)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.previousGrainStrength = safeStrength;
|
||||
this.grainOverlay.hidden = safeStrength <= 0;
|
||||
this.grainOverlay.style.setProperty('--garden-grain-strength', String(safeStrength));
|
||||
}
|
||||
|
||||
private resize(): void {
|
||||
const width = Math.max(
|
||||
1,
|
||||
Math.floor(this.canvas.clientWidth * this.devicePixelRatio)
|
||||
);
|
||||
const height = Math.max(
|
||||
1,
|
||||
Math.floor(this.canvas.clientHeight * this.devicePixelRatio)
|
||||
);
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const { width, height } = getInternalRenderSize({
|
||||
clientHeight: rect.height || this.canvas.clientHeight,
|
||||
clientWidth: rect.width || this.canvas.clientWidth,
|
||||
maxTextureDimension: this.device.limits.maxTextureDimension2D,
|
||||
targetAreaMegapixels: settings.internalRenderAreaMegapixels,
|
||||
});
|
||||
|
||||
if (this.canvas.width === width && this.canvas.height === height) {
|
||||
return;
|
||||
|
|
@ -297,7 +299,7 @@ export default class GameLoop {
|
|||
this.canvas.height = height;
|
||||
}
|
||||
|
||||
private resizeSimulationToCanvas(): void {
|
||||
private resizeSimulationToCanvas(time: DOMHighResTimeStamp): void {
|
||||
const scale = this.resources.resizeSimulationTo(this.canvasSize);
|
||||
if (!scale) {
|
||||
return;
|
||||
|
|
@ -305,25 +307,58 @@ export default class GameLoop {
|
|||
|
||||
this.agentPopulation.resizeAgents(scale);
|
||||
this.pointerInput.scaleLastPointerPosition(scale);
|
||||
|
||||
if (this.introPrompt.shouldRegenerateTitleOnResize) {
|
||||
this.pendingIntroResizeAt = time;
|
||||
}
|
||||
}
|
||||
|
||||
private regenerateIntroAfterSettledResize(time: DOMHighResTimeStamp): void {
|
||||
if (this.pendingIntroResizeAt === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.introPrompt.shouldRegenerateTitleOnResize) {
|
||||
this.pendingIntroResizeAt = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (time - this.pendingIntroResizeAt < appConfig.simulation.intro.resizeSettleMs) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.introPrompt.rewindToLeaveRemainingTime(
|
||||
appConfig.simulation.intro.resizeMinimumRemainingSeconds
|
||||
);
|
||||
this.resources.clearSimulation();
|
||||
this.agentPopulation.replaceIntroAgents(this.canvasSize, this.introPrompt.progress);
|
||||
this.pendingIntroResizeAt = null;
|
||||
}
|
||||
|
||||
private get canvasSize(): vec2 {
|
||||
return vec2.fromValues(this.canvas.width, this.canvas.height);
|
||||
vec2.set(this._canvasSize, this.canvas.width, this.canvas.height);
|
||||
return this._canvasSize;
|
||||
}
|
||||
|
||||
private get devicePixelRatio(): number {
|
||||
const ratio = window.devicePixelRatio;
|
||||
private get canvasPixelRatio(): number {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const xScale = rect.width > 0 ? this.canvas.width / rect.width : 1;
|
||||
const yScale = rect.height > 0 ? this.canvas.height / rect.height : xScale;
|
||||
const ratio = (xScale + yScale) / 2;
|
||||
return Number.isFinite(ratio) && ratio > 0 ? ratio : 1;
|
||||
}
|
||||
|
||||
private get mirrorSegmentCount(): number {
|
||||
const count = Number.isFinite(settings.mirrorSegmentCount)
|
||||
? settings.mirrorSegmentCount
|
||||
: 1;
|
||||
return Math.min(GameLoop.MAX_MIRROR_SEGMENT_COUNT, Math.max(1, Math.round(count)));
|
||||
: appConfig.toolbar.mirror.min;
|
||||
return Math.min(
|
||||
appConfig.toolbar.mirror.max,
|
||||
Math.max(appConfig.toolbar.mirror.min, Math.round(count))
|
||||
);
|
||||
}
|
||||
|
||||
private get shouldShowDevStats(): boolean {
|
||||
return import.meta.env.DEV || this.isStatsOverlayPinned;
|
||||
private get grainOverlay(): HTMLElement {
|
||||
return this.ui.grainOverlay;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
173
src/game-loop/gpu-profiler.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
const PASS_NAMES = [
|
||||
'brush',
|
||||
'eraserTexture',
|
||||
'eraserAgent',
|
||||
'agent',
|
||||
'trailDiffusion',
|
||||
'render',
|
||||
'sourceDiffusion',
|
||||
] as const;
|
||||
|
||||
export type GpuPassName = (typeof PASS_NAMES)[number];
|
||||
|
||||
interface GpuProfilerSample {
|
||||
frame: number;
|
||||
passes: Partial<Record<GpuPassName, number>>;
|
||||
totalPassMs: number;
|
||||
}
|
||||
|
||||
interface ActivePass {
|
||||
endQueryIndex: number;
|
||||
name: GpuPassName;
|
||||
startQueryIndex: number;
|
||||
}
|
||||
|
||||
interface ReadbackSlot {
|
||||
buffer: GPUBuffer;
|
||||
state: 'idle' | 'encoding' | 'mapping';
|
||||
}
|
||||
|
||||
const MAX_QUERY_COUNT = PASS_NAMES.length * 2;
|
||||
const QUERY_BYTES = BigUint64Array.BYTES_PER_ELEMENT;
|
||||
const READBACK_SLOT_COUNT = 4;
|
||||
|
||||
export class GpuProfiler {
|
||||
private readonly querySet: GPUQuerySet;
|
||||
private readonly resolveBuffer: GPUBuffer;
|
||||
private readonly readbackSlots: Array<ReadbackSlot>;
|
||||
private readonly isEnabled: () => boolean;
|
||||
private activePasses: Array<ActivePass> = [];
|
||||
private nextQueryIndex = 0;
|
||||
private frame = 0;
|
||||
private latestSample: GpuProfilerSample | null = null;
|
||||
|
||||
public static create(device: GPUDevice, isEnabled: () => boolean): GpuProfiler | null {
|
||||
if (!device.features.has('timestamp-query')) {
|
||||
return null;
|
||||
}
|
||||
return new GpuProfiler(device, isEnabled);
|
||||
}
|
||||
|
||||
private constructor(device: GPUDevice, isEnabled: () => boolean) {
|
||||
this.isEnabled = isEnabled;
|
||||
this.querySet = device.createQuerySet({
|
||||
type: 'timestamp',
|
||||
count: MAX_QUERY_COUNT,
|
||||
});
|
||||
this.resolveBuffer = device.createBuffer({
|
||||
size: MAX_QUERY_COUNT * QUERY_BYTES,
|
||||
usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC,
|
||||
});
|
||||
this.readbackSlots = Array.from({ length: READBACK_SLOT_COUNT }, () => ({
|
||||
buffer: device.createBuffer({
|
||||
size: MAX_QUERY_COUNT * QUERY_BYTES,
|
||||
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
|
||||
}),
|
||||
state: 'idle' as const,
|
||||
}));
|
||||
}
|
||||
|
||||
public beginFrame(): void {
|
||||
this.frame += 1;
|
||||
this.activePasses = [];
|
||||
this.nextQueryIndex = 0;
|
||||
}
|
||||
|
||||
public timestampWrites(
|
||||
name: GpuPassName
|
||||
): (GPUComputePassTimestampWrites & GPURenderPassTimestampWrites) | undefined {
|
||||
if (!this.isEnabled()) {
|
||||
return undefined;
|
||||
}
|
||||
if (this.nextQueryIndex + 1 >= MAX_QUERY_COUNT) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const startQueryIndex = this.nextQueryIndex;
|
||||
const endQueryIndex = this.nextQueryIndex + 1;
|
||||
this.nextQueryIndex += 2;
|
||||
this.activePasses.push({
|
||||
endQueryIndex,
|
||||
name,
|
||||
startQueryIndex,
|
||||
});
|
||||
|
||||
return {
|
||||
querySet: this.querySet,
|
||||
beginningOfPassWriteIndex: startQueryIndex,
|
||||
endOfPassWriteIndex: endQueryIndex,
|
||||
};
|
||||
}
|
||||
|
||||
public resolve(commandEncoder: GPUCommandEncoder): (() => void) | null {
|
||||
const queryCount = this.nextQueryIndex;
|
||||
if (queryCount === 0 || this.activePasses.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const slot = this.readbackSlots.find((candidate) => candidate.state === 'idle');
|
||||
if (!slot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const byteLength = queryCount * QUERY_BYTES;
|
||||
const passes = this.activePasses.slice();
|
||||
const frame = this.frame;
|
||||
slot.state = 'encoding';
|
||||
commandEncoder.resolveQuerySet(this.querySet, 0, queryCount, this.resolveBuffer, 0);
|
||||
commandEncoder.copyBufferToBuffer(this.resolveBuffer, 0, slot.buffer, 0, byteLength);
|
||||
|
||||
return () => {
|
||||
slot.state = 'mapping';
|
||||
void slot.buffer
|
||||
.mapAsync(GPUMapMode.READ, 0, byteLength)
|
||||
.then(() => {
|
||||
this.publishSample(frame, passes, slot.buffer.getMappedRange(0, byteLength));
|
||||
slot.buffer.unmap();
|
||||
slot.state = 'idle';
|
||||
})
|
||||
.catch(() => {
|
||||
slot.state = 'idle';
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.querySet.destroy();
|
||||
this.resolveBuffer.destroy();
|
||||
this.readbackSlots.forEach((slot) => {
|
||||
slot.buffer.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
public get latestTotalPassMs(): number | undefined {
|
||||
return this.latestSample?.totalPassMs;
|
||||
}
|
||||
|
||||
private publishSample(
|
||||
frame: number,
|
||||
passes: Array<ActivePass>,
|
||||
mappedRange: ArrayBuffer
|
||||
): void {
|
||||
const timestamps = new BigUint64Array(mappedRange);
|
||||
const sample: GpuProfilerSample = {
|
||||
frame,
|
||||
passes: {},
|
||||
totalPassMs: 0,
|
||||
};
|
||||
|
||||
passes.forEach(({ endQueryIndex, name, startQueryIndex }) => {
|
||||
const start = timestamps[startQueryIndex];
|
||||
const end = timestamps[endQueryIndex];
|
||||
if (end < start) {
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsedMs = Number(end - start) / 1_000_000;
|
||||
sample.passes[name] = elapsedMs;
|
||||
sample.totalPassMs += elapsedMs;
|
||||
});
|
||||
|
||||
this.latestSample = sample;
|
||||
}
|
||||
}
|
||||