Rework #1
|
|
@ -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,20 +25,38 @@ jobs:
|
|||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
run: |
|
||||
npm ci
|
||||
npx playwright install --with-deps chromium
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint -- --check || true
|
||||
- name: Test
|
||||
run: |
|
||||
npm run lint:check
|
||||
npm run format:check
|
||||
npm run typecheck
|
||||
npm run typecheck:e2e
|
||||
npm test
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
- name: Test E2E
|
||||
run: |
|
||||
npm run test:e2e
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
run: |
|
||||
npm run build
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: |
|
||||
playwright-report/
|
||||
test-results/
|
||||
retention-days: 7
|
||||
|
||||
- name: Copy build to host pages mount
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
apt update && apt install -y rsync
|
||||
mkdir -p /pages
|
||||
rsync -a --delete dist/ /pages/fleeting-garden
|
||||
rsync -a --delete dist/ /pages/fleeting
|
||||
|
|
|
|||
44
.gitignore
vendored
|
|
@ -1,45 +1,5 @@
|
|||
# Dependency directory
|
||||
node_modules
|
||||
modules/
|
||||
ts-node--*/
|
||||
rss.xml
|
||||
|
||||
dist
|
||||
|
||||
# Logs
|
||||
logs
|
||||
test-results
|
||||
.DS_Store
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.ssh
|
||||
*.ppk
|
||||
v8-compile-cache-0/
|
||||
Thumbs.db
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (http://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
bin
|
||||
ts-node
|
||||
|
||||
# Personal Scripts
|
||||
*.bat
|
||||
*.ssh
|
||||
*.sh
|
||||
!system.min.js
|
||||
|
||||
# Editors
|
||||
.vscode
|
||||
.markdownlint.json
|
||||
|
||||
# Build Files
|
||||
temp
|
||||
*.js
|
||||
*.map
|
||||
!webpack.*
|
||||
|
|
|
|||
2
.nvmrc
|
|
@ -1 +1 @@
|
|||
22
|
||||
22.13.0
|
||||
|
|
|
|||
|
|
@ -6,5 +6,5 @@
|
|||
"endOfLine": "lf",
|
||||
"plugins": ["@ianvs/prettier-plugin-sort-imports"],
|
||||
"importOrder": ["<BUILTIN_MODULES>", "<THIRD_PARTY_MODULES>", "", "^[./]"],
|
||||
"importOrderTypeScriptVersion": "5.0.0"
|
||||
"importOrderTypeScriptVersion": "6.0.3"
|
||||
}
|
||||
|
|
|
|||
21
README.md
|
|
@ -1,15 +1,14 @@
|
|||
# Just a bunch of blobs
|
||||
# Fleeting Garden
|
||||
|
||||
[](https://github.com/schmelczer/webgpu/actions/workflows/deploy.yml)
|
||||
Fleeting Garden is a single-player WebGPU drawing garden. Pick a vibe palette,
|
||||
draw persistent coloured paths, spawn agents from those strokes, erase locally,
|
||||
and export the scene as an internal render buffer snapshot.
|
||||
|
||||
## todo
|
||||
Check out the [agent logic](./src/pipelines/agents/agent.wgsl).
|
||||
|
||||
- add info page description
|
||||
- add share link
|
||||
- settings page
|
||||
add reset link
|
||||
- shareable settings
|
||||
- graceful error messages when no support
|
||||
- fix up generation id automatically
|
||||
## Testing
|
||||
|
||||
Check out the [agent's logic](./src/pipelines/agents/agent.wgsl).
|
||||
- `npm test` runs the Vitest unit suite.
|
||||
- `npm run test:e2e` runs the Playwright Chromium smoke test. The Playwright
|
||||
config builds the production bundle before serving it.
|
||||
- `npx playwright install chromium` installs the local browser binary when needed.
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="#000000" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 21v-4a4 4 0 1 1 4 4h-4" />
|
||||
<path d="M21 3a16 16 0 0 0 -12.8 10.2" />
|
||||
<path d="M21 3a16 16 0 0 1 -10.2 12.8" />
|
||||
<path d="M10.6 9a9 9 0 0 1 4.4 4.4" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 332 B |
10
assets/icons/download.svg
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12 3v11m0 0 4-4m-4 4-4-4M5 17v3h14v-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 248 B |
|
|
@ -1,4 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="#FFFFFF" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<line x1="12" y1="8" x2="12.01" y2="8" />
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 344 B After Width: | Height: | Size: 349 B |
|
|
@ -1,7 +1,7 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="#FFFFFF" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M4 8v-2a2 2 0 0 1 2 -2h2" />
|
||||
<path d="M4 16v2a2 2 0 0 0 2 2h2" />
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v2" />
|
||||
<path d="M16 20h2a2 2 0 0 0 2 -2v-2" />
|
||||
</svg>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 376 B After Width: | Height: | Size: 382 B |
|
|
@ -1,7 +1,7 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="#FFFFFF" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M15 19v-2a2 2 0 0 1 2 -2h2" />
|
||||
<path d="M15 5v2a2 2 0 0 0 2 2h2" />
|
||||
<path d="M5 15h2a2 2 0 0 1 2 2v2" />
|
||||
<path d="M5 9h2a2 2 0 0 0 2 -2v-2" />
|
||||
</svg>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 376 B After Width: | Height: | Size: 382 B |
|
|
@ -1,4 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="#FFFFFF" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M20 11a8.1 8.1 0 0 0 -15.5 -2m-.5 -4v4h4" />
|
||||
<path d="M4 13a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4" />
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 326 B After Width: | Height: | Size: 331 B |
|
|
@ -1,4 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="#FFFFFF" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M14 6m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
||||
<path d="M4 6l8 0" />
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 536 B After Width: | Height: | Size: 541 B |
3
assets/icons/sound.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 9.25v5.5h4.1L13 20V4L7.1 9.25H3Zm13.3-2.8-1.4 1.4A5.1 5.1 0 0 1 16.5 12a5.1 5.1 0 0 1-1.6 4.15l1.4 1.4A7.1 7.1 0 0 0 18.5 12a7.1 7.1 0 0 0-2.2-5.55Zm2.85-2.85-1.42 1.42A9.55 9.55 0 0 1 20.5 12a9.55 9.55 0 0 1-2.77 6.98l1.42 1.42A11.55 11.55 0 0 0 22.5 12a11.55 11.55 0 0 0-3.35-8.4Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 391 B |
4
definitions.d.ts
vendored
|
|
@ -1,4 +0,0 @@
|
|||
declare module '*.wgsl?raw' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
265
e2e/app.spec.ts
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
import { test as base, expect, type Page } from '@playwright/test';
|
||||
|
||||
const canvasName = 'Interactive generative garden canvas';
|
||||
|
||||
interface BrowserDiagnostics {
|
||||
browserFailures: Array<string>;
|
||||
consoleErrors: Array<string>;
|
||||
}
|
||||
|
||||
const isLocalUrl = (url: string) => {
|
||||
const { hostname } = new URL(url);
|
||||
return hostname === '127.0.0.1' || hostname === 'localhost';
|
||||
};
|
||||
|
||||
const collectLocalBrowserFailures = (page: Page) => {
|
||||
const failures: Array<string> = [];
|
||||
|
||||
page.on('requestfailed', (request) => {
|
||||
if (!isLocalUrl(request.url())) {
|
||||
return;
|
||||
}
|
||||
|
||||
const failure = request.failure();
|
||||
failures.push(`${request.method()} ${request.url()} ${failure?.errorText}`);
|
||||
});
|
||||
page.on('response', (response) => {
|
||||
if (response.status() < 400 || !isLocalUrl(response.url())) {
|
||||
return;
|
||||
}
|
||||
|
||||
failures.push(`${response.status()} ${response.url()}`);
|
||||
});
|
||||
|
||||
return failures;
|
||||
};
|
||||
|
||||
const test = base.extend<{ browserDiagnostics: BrowserDiagnostics }>({
|
||||
browserDiagnostics: [
|
||||
async ({ page }, use) => {
|
||||
const browserFailures = collectLocalBrowserFailures(page);
|
||||
const consoleErrors: Array<string> = [];
|
||||
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') {
|
||||
consoleErrors.push(message.text());
|
||||
}
|
||||
});
|
||||
|
||||
await use({ browserFailures, consoleErrors });
|
||||
|
||||
expect(consoleErrors).toEqual([]);
|
||||
expect(browserFailures).toEqual([]);
|
||||
},
|
||||
{ auto: true },
|
||||
],
|
||||
});
|
||||
|
||||
const disableWebGpu = async (page: Page) => {
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, 'gpu', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
test('starts the WebGPU garden and accepts drawing input', async ({ page }) => {
|
||||
await page.addInitScript((expectedCanvasName) => {
|
||||
const captureState = { count: 0 };
|
||||
Object.defineProperty(window, '__fleetingGardenPointerCaptures', {
|
||||
configurable: true,
|
||||
value: captureState,
|
||||
});
|
||||
|
||||
const originalSetPointerCapture = Element.prototype.setPointerCapture;
|
||||
Element.prototype.setPointerCapture = function setPointerCapture(pointerId) {
|
||||
if (
|
||||
this instanceof HTMLCanvasElement &&
|
||||
this.getAttribute('aria-label') === expectedCanvasName
|
||||
) {
|
||||
captureState.count += 1;
|
||||
}
|
||||
|
||||
return originalSetPointerCapture.call(this, pointerId);
|
||||
};
|
||||
}, canvasName);
|
||||
|
||||
await page.goto('/');
|
||||
const startButton = page.getByRole('button', { exact: true, name: 'Start' });
|
||||
await expect(startButton).toBeVisible();
|
||||
await expect(startButton).toBeEnabled({ timeout: 30_000 });
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(page.locator('body')).not.toHaveClass(/is-loading/, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible();
|
||||
|
||||
const canvas = page.getByRole('img', { name: canvasName });
|
||||
await expect(canvas).toBeVisible();
|
||||
const canvasSize = await canvas.evaluate((element) => {
|
||||
const canvasElement = element as HTMLCanvasElement;
|
||||
return {
|
||||
height: canvasElement.height,
|
||||
width: canvasElement.width,
|
||||
};
|
||||
});
|
||||
expect(canvasSize.width).toBeGreaterThan(0);
|
||||
expect(canvasSize.height).toBeGreaterThan(0);
|
||||
|
||||
const box = await canvas.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
if (!box) {
|
||||
return;
|
||||
}
|
||||
|
||||
await page.mouse.move(box.x + box.width * 0.2, box.y + box.height * 0.5);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width * 0.8, box.y + box.height * 0.5, {
|
||||
steps: 16,
|
||||
});
|
||||
await page.mouse.up();
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(
|
||||
window as unknown as {
|
||||
__fleetingGardenPointerCaptures?: { count: number };
|
||||
}
|
||||
).__fleetingGardenPointerCaptures?.count ?? 0
|
||||
)
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(
|
||||
window as unknown as {
|
||||
__fleetingGardenBrushPasses?: number;
|
||||
}
|
||||
).__fleetingGardenBrushPasses ?? 0
|
||||
)
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('shows a clear fallback when WebGPU is unavailable', async ({ page }) => {
|
||||
await disableWebGpu(page);
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page).toHaveTitle('Fleeting Garden');
|
||||
await expect(page.getByRole('img', { name: canvasName })).toBeVisible();
|
||||
await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible();
|
||||
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
|
||||
|
||||
const fallback = page.getByRole('alert');
|
||||
await expect(fallback).toContainText('Fleeting Garden needs WebGPU');
|
||||
await expect(fallback).toContainText('webgpu-unsupported');
|
||||
});
|
||||
|
||||
test('syncs the selected vibe with the URI', async ({ page }) => {
|
||||
await disableWebGpu(page);
|
||||
await page.goto('/?vibe=Aurora%20Mycelium');
|
||||
|
||||
await expect(page).toHaveURL(/vibe=aurora-mycelium/);
|
||||
|
||||
await page.getByRole('button', { name: 'Next vibe' }).click();
|
||||
await expect(page).toHaveURL(/vibe=velvet-observatory/);
|
||||
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/vibe=aurora-mycelium/);
|
||||
});
|
||||
|
||||
test('keeps audio focus outlines scoped to the active control', async ({ page }) => {
|
||||
await disableWebGpu(page);
|
||||
await page.goto('/');
|
||||
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
|
||||
|
||||
const audioControl = page.locator('.audio-control');
|
||||
const soundButton = page.getByRole('button', { name: /audio/i });
|
||||
const volumeSlider = page.getByRole('slider', { name: 'Master volume' });
|
||||
|
||||
await soundButton.click();
|
||||
await expect(audioControl).toHaveCSS('outline-style', 'none');
|
||||
await expect(soundButton).toHaveCSS('outline-style', 'none');
|
||||
|
||||
await page.mouse.click(10, 10);
|
||||
for (let tabIndex = 0; tabIndex < 12; tabIndex += 1) {
|
||||
await page.keyboard.press('Tab');
|
||||
const activeClass = await page.evaluate(() =>
|
||||
String(document.activeElement?.className ?? '')
|
||||
);
|
||||
if (activeClass.includes('sound')) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await expect(soundButton).toBeFocused();
|
||||
await expect(soundButton).toHaveCSS('outline-style', 'solid');
|
||||
await expect(soundButton).toHaveCSS('outline-offset', '-4px');
|
||||
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(volumeSlider).toBeFocused();
|
||||
await expect(volumeSlider).toHaveCSS('outline-style', 'solid');
|
||||
await expect(volumeSlider).toHaveCSS('outline-offset', '-4px');
|
||||
});
|
||||
|
||||
test('keeps the config overlay scrollable and dismissible on mobile', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 390, height: 640 });
|
||||
await page.goto('/');
|
||||
|
||||
const startButton = page.getByRole('button', { exact: true, name: 'Start' });
|
||||
await expect(startButton).toBeEnabled({ timeout: 30_000 });
|
||||
await startButton.click();
|
||||
await expect(page.locator('body')).not.toHaveClass(/is-loading/, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const settingsButton = page.getByRole('button', { name: 'Show config overlay' });
|
||||
await settingsButton.click();
|
||||
|
||||
const pane = page.locator('.config-pane');
|
||||
const closeButton = page.locator('.config-pane-close');
|
||||
await expect(pane).toBeVisible();
|
||||
await expect(closeButton).toBeVisible();
|
||||
|
||||
const paneMetrics = await pane.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(element);
|
||||
return {
|
||||
bottom: rect.bottom,
|
||||
clientHeight: element.clientHeight,
|
||||
overflowY: style.overflowY,
|
||||
scrollHeight: element.scrollHeight,
|
||||
top: rect.top,
|
||||
viewportHeight: window.innerHeight,
|
||||
viewportWidth: window.innerWidth,
|
||||
width: rect.width,
|
||||
};
|
||||
});
|
||||
|
||||
expect(paneMetrics.top).toBeGreaterThanOrEqual(0);
|
||||
expect(paneMetrics.bottom).toBeLessThanOrEqual(paneMetrics.viewportHeight);
|
||||
expect(Math.round(paneMetrics.width)).toBe(Math.round(paneMetrics.viewportWidth * 0.8));
|
||||
expect(paneMetrics.scrollHeight).toBeGreaterThan(paneMetrics.clientHeight);
|
||||
expect(['auto', 'scroll']).toContain(paneMetrics.overflowY);
|
||||
|
||||
await pane.evaluate((element) => {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
});
|
||||
await expect
|
||||
.poll(() => pane.evaluate((element) => element.scrollTop))
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
await closeButton.click();
|
||||
await expect(pane).toBeHidden();
|
||||
await expect(settingsButton).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
30
eslint.config.js
Normal file
|
|
@ -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',
|
||||
},
|
||||
}
|
||||
);
|
||||
253
index.html
|
|
@ -6,76 +6,243 @@
|
|||
name="viewport"
|
||||
content="width=device-width,initial-scale=1,viewport-fit=cover"
|
||||
/>
|
||||
<meta name="theme-color" content="#b7455e" />
|
||||
<meta name="theme-color" content="#10151f" />
|
||||
<meta name="robots" content="index,follow" />
|
||||
<meta name="author" content="Andras Schmelczer" />
|
||||
<meta
|
||||
name="description"
|
||||
content="A WebGPU agent simulation: a million blobs leave trails, infect each other across generations, and react to your brush."
|
||||
content="Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser."
|
||||
/>
|
||||
|
||||
<meta property="og:title" content="Just a bunch of blobs" />
|
||||
<link rel="canonical" href="https://schmelczer.dev/fleeting/" />
|
||||
|
||||
<meta property="og:title" content="Fleeting Garden" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Fleeting Garden" />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="A WebGPU agent simulation: a million blobs leave trails, infect each other across generations, and react to your brush."
|
||||
content="Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser."
|
||||
/>
|
||||
<meta property="og:url" content="https://schmelczer.dev" />
|
||||
<meta property="og:image" content="https://schmelczer.dev/og-image.jpg" />
|
||||
<meta property="og:image:width" content="1920" />
|
||||
<meta property="og:image:height" content="1920" />
|
||||
<meta property="og:url" content="https://schmelczer.dev/fleeting/" />
|
||||
<meta property="og:image" content="https://schmelczer.dev/fleeting/og-image.jpg" />
|
||||
<meta property="og:image:type" content="image/jpeg" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:alt" content="Fleeting Garden social preview image." />
|
||||
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Fleeting Garden" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser."
|
||||
/>
|
||||
<meta name="twitter:image" content="https://schmelczer.dev/fleeting/og-image.jpg" />
|
||||
<meta name="twitter:image:alt" content="Fleeting Garden social preview image." />
|
||||
|
||||
<title>Just a bunch of blobs</title>
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebApplication",
|
||||
"name": "Fleeting Garden",
|
||||
"url": "https://schmelczer.dev/fleeting/",
|
||||
"description": "Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser.",
|
||||
"image": "https://schmelczer.dev/fleeting/og-image.jpg",
|
||||
"applicationCategory": "DesignApplication",
|
||||
"operatingSystem": "Any",
|
||||
"browserRequirements": "Requires a browser with WebGPU support.",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "Andras Schmelczer"
|
||||
},
|
||||
"sameAs": "https://github.com/schmelczer/webgpu"
|
||||
}
|
||||
</script>
|
||||
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon-180x180.png" />
|
||||
<link rel="manifest" href="manifest.webmanifest" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-title" content="Fleeting Garden" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
|
||||
<title>Fleeting Garden</title>
|
||||
</head>
|
||||
<body>
|
||||
<body class="is-loading">
|
||||
<main class="canvas-container">
|
||||
<canvas></canvas>
|
||||
<canvas
|
||||
role="img"
|
||||
aria-label="Interactive generative garden canvas"
|
||||
aria-describedby="canvas-description"
|
||||
>
|
||||
Your browser cannot display the interactive WebGPU garden canvas. Use a browser
|
||||
with WebGPU support to draw coloured paths and watch the garden grow.
|
||||
</canvas>
|
||||
<p id="canvas-description" class="visually-hidden">
|
||||
Fleeting Garden is a pointer-driven WebGPU drawing canvas. Drag or touch the scene
|
||||
to paint coloured paths, then use the toolbar to change colours, erase, export,
|
||||
adjust the config overlay, restart, or open more information.
|
||||
</p>
|
||||
<div class="garden-grain" aria-hidden="true"></div>
|
||||
<div class="eraser-preview" aria-hidden="true"></div>
|
||||
<div class="garden-prompt" aria-live="polite"></div>
|
||||
|
||||
<div class="loading-indicator" role="status">
|
||||
<div class="splash" data-visible="true">
|
||||
<h1 class="splash-title">Fleeting Garden</h1>
|
||||
<p class="splash-description">
|
||||
Tend it while you can. The garden returns to weather either way.
|
||||
</p>
|
||||
<button class="start-button" type="button" disabled>Start</button>
|
||||
</div>
|
||||
<div class="loading-bar" data-visible="false" aria-hidden="true" inert>
|
||||
<div class="loading-status">Starting up…</div>
|
||||
<div
|
||||
class="loading-progress"
|
||||
role="progressbar"
|
||||
aria-label="Loading Fleeting Garden"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-valuenow="0"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="errors-container">
|
||||
<noscript>JavaScript is required for this website.</noscript>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<aside>
|
||||
<nav class="buttons">
|
||||
<button class="info" aria-label="About"></button>
|
||||
<button class="maximize-full-screen" aria-label="Enter fullscreen"></button>
|
||||
<button class="minimize-full-screen" aria-label="Exit fullscreen"></button>
|
||||
<button class="settings" aria-label="Settings"></button>
|
||||
<button class="restart" aria-label="Restart simulation"></button>
|
||||
</nav>
|
||||
|
||||
<main class="pages hidden info-page">
|
||||
<aside class="control-dock">
|
||||
<section
|
||||
id="info-panel"
|
||||
class="hidden info-page"
|
||||
role="region"
|
||||
aria-label="About panel"
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
inert
|
||||
>
|
||||
<section>
|
||||
<h1>Just a bunch of blobs</h1>
|
||||
<h1>Fleeting Garden</h1>
|
||||
<p>
|
||||
A million autonomous agents wander a 2D field. Each one lays down a faint
|
||||
trail and follows trails it senses ahead. Two generations are competing for
|
||||
territory: the older one fades, the newer one spreads.
|
||||
A garden is what we tend; the wild is what we get the moment we look away.
|
||||
Both happen here at once. Your strokes plant colour, small agents follow them,
|
||||
branch off, and slowly rewrite the patch you laid down into something you
|
||||
didn't quite plan.
|
||||
</p>
|
||||
<p>
|
||||
Drag your finger or mouse anywhere on the canvas to paint a wall. Walls slow
|
||||
the new generation down and let the old one breathe a little longer. Open
|
||||
<em>Settings</em> to retune sensors, decay rates and aggression.
|
||||
Three swatches plant the line. The eraser carves a clearing. The mirror folds
|
||||
one gesture into many, like footpaths around a hidden well.
|
||||
</p>
|
||||
<p>
|
||||
Runs entirely on your GPU via WebGPU compute shaders — no servers, no
|
||||
tracking, no analytics. Source on
|
||||
<a href="https://github.com/schmelczer/webgpu" target="_blank" rel="noopener"
|
||||
>GitHub</a
|
||||
Switch vibes to change the season; your shapes stay, the light moves. Add or
|
||||
quiet the piano. Restart when you want a fresh field. Take a snapshot if you
|
||||
want to keep one particular instant of weather.
|
||||
</p>
|
||||
<p>
|
||||
Built with WebGPU, running locally in your browser. More of my work at
|
||||
<a href="https://schmelczer.dev" target="_blank" rel="noopener"
|
||||
>schmelczer.dev</a
|
||||
>.
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
</section>
|
||||
|
||||
<main class="pages hidden settings-page">
|
||||
<section>
|
||||
<div class="settings-content"></div>
|
||||
<button id="apply-defaults" class="large-button">Apply defaults</button>
|
||||
</section>
|
||||
</main>
|
||||
<div class="toolbar-row" role="toolbar" aria-label="Garden toolbar">
|
||||
<button
|
||||
class="previous-vibe vibe-button"
|
||||
aria-label="Previous vibe"
|
||||
title="Previous vibe"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
|
||||
<div class="toolbar-shell">
|
||||
<section class="garden-controls" aria-label="Garden controls">
|
||||
<div class="swatches" role="group" aria-label="Drawing colours">
|
||||
<button
|
||||
class="color-swatch"
|
||||
aria-label="Draw colour 1"
|
||||
title="Draw colour 1"
|
||||
></button>
|
||||
<button
|
||||
class="color-swatch"
|
||||
aria-label="Draw colour 2"
|
||||
title="Draw colour 2"
|
||||
></button>
|
||||
<button
|
||||
class="color-swatch"
|
||||
aria-label="Draw colour 3"
|
||||
title="Draw colour 3"
|
||||
></button>
|
||||
<label class="eraser-size-control" title="Erase and resize">
|
||||
<input class="eraser-size-slider" type="range" aria-label="Eraser size" />
|
||||
</label>
|
||||
<label class="mirror-segment-control" title="Mirror off">
|
||||
<input
|
||||
class="mirror-segment-slider"
|
||||
type="range"
|
||||
aria-label="Mirror segments"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<nav class="buttons" aria-label="App controls">
|
||||
<button
|
||||
class="info"
|
||||
data-control="info"
|
||||
aria-label="About"
|
||||
aria-controls="info-panel"
|
||||
aria-expanded="false"
|
||||
title="About"
|
||||
></button>
|
||||
<button
|
||||
class="full-screen-toggle"
|
||||
data-control="full-screen"
|
||||
aria-label="Enter fullscreen"
|
||||
title="Enter fullscreen"
|
||||
></button>
|
||||
<button
|
||||
class="settings"
|
||||
data-control="settings"
|
||||
aria-label="Show config overlay"
|
||||
aria-expanded="false"
|
||||
title="Show config overlay"
|
||||
></button>
|
||||
<div class="audio-control">
|
||||
<button
|
||||
class="sound"
|
||||
data-control="sound"
|
||||
aria-label="Mute audio"
|
||||
aria-pressed="false"
|
||||
title="Mute audio"
|
||||
></button>
|
||||
<label class="volume-control" title="Master volume">
|
||||
<input class="volume-slider" type="range" aria-label="Master volume" />
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
class="export-4k"
|
||||
data-control="export"
|
||||
aria-label="Download internal buffer snapshot"
|
||||
title="Download internal buffer snapshot"
|
||||
></button>
|
||||
<span class="export-status" aria-live="polite"></span>
|
||||
<button
|
||||
class="restart"
|
||||
data-control="restart"
|
||||
aria-label="Restart simulation"
|
||||
title="Restart simulation"
|
||||
></button>
|
||||
</nav>
|
||||
|
||||
<button class="next-vibe vibe-button" aria-label="Next vibe" title="Next vibe">
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
<script type="module" src="/src/index.ts"></script>
|
||||
</body>
|
||||
|
|
|
|||
1174
package-lock.json
generated
37
package.json
|
|
@ -1,22 +1,28 @@
|
|||
{
|
||||
"name": "webgpu-seed",
|
||||
"name": "fleeting-garden",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "A WebGPU-powered slime-mold-meets-territory-control simulation.",
|
||||
"description": "Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser.",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint --fix \"src/**/*.ts\" && prettier --write \"src/**/*.{ts,scss,json,html}\"",
|
||||
"lint:check": "eslint . && npm run unused:check",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"format": "prettier --write \"index.html\" \"public/manifest.webmanifest\" \"src/**/*.{ts,scss,json,html}\" \"e2e/**/*.ts\" \".forgejo/workflows/*.yml\" \"*.{json,js,ts,md}\" \".prettierrc\"",
|
||||
"format:check": "prettier --check \"index.html\" \"public/manifest.webmanifest\" \"src/**/*.{ts,scss,json,html}\" \"e2e/**/*.ts\" \".forgejo/workflows/*.yml\" \"*.{json,js,ts,md}\" \".prettierrc\"",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck:e2e": "tsc --noEmit --project tsconfig.playwright.json",
|
||||
"test": "vitest run",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:watch": "vitest",
|
||||
"generate-icons": "pwa-assets-generator",
|
||||
"update": "ncu"
|
||||
"unused:check": "knip --production --files --dependencies && knip --exports --include-entry-exports",
|
||||
"generate-icons": "pwa-assets-generator"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=22.13.0"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
@ -33,23 +39,28 @@
|
|||
"browserslist": [
|
||||
"supports webgpu and last 2 years"
|
||||
],
|
||||
"dependencies": {
|
||||
"gl-matrix": "^3.4.4"
|
||||
"knip": {
|
||||
"ignoreFiles": [
|
||||
"pwa-assets.config.ts"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@ianvs/prettier-plugin-sort-imports": "^4.7.1",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@tweakpane/core": "~2.0.5",
|
||||
"@types/node": "^25.6.0",
|
||||
"@vite-pwa/assets-generator": "^1.0.2",
|
||||
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
||||
"@webgpu/types": "^0.1.69",
|
||||
"browserslist": "^4.28.2",
|
||||
"browserslist-to-esbuild": "^2.1.1",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"eslint-plugin-unused-imports": "^4.4.1",
|
||||
"gl-matrix": "^3.4.4",
|
||||
"globals": "^17.6.0",
|
||||
"knip": "^6.14.1",
|
||||
"lightningcss": "^1.32.0",
|
||||
"npm-check-updates": "^22.1.0",
|
||||
"prettier": "^3.8.3",
|
||||
"sass": "^1.99.0",
|
||||
"typescript": "^6.0.3",
|
||||
|
|
@ -57,5 +68,9 @@
|
|||
"vite": "^8.0.10",
|
||||
"vite-plugin-singlefile": "^2.3.3",
|
||||
"vitest": "^4.1.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@plausible-analytics/tracker": "^0.4.5",
|
||||
"tweakpane": "~4.0.5"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
37
playwright.config.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
const port = 4173;
|
||||
const baseURL = `https://127.0.0.1:${port}`;
|
||||
const isCi = Boolean(process.env.CI);
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: isCi,
|
||||
retries: isCi ? 2 : 0,
|
||||
workers: 1,
|
||||
reporter: isCi ? [['list'], ['html', { open: 'never' }]] : 'list',
|
||||
use: {
|
||||
baseURL,
|
||||
ignoreHTTPSErrors: true,
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
webServer: {
|
||||
command: `npm run build && npm run preview -- --host 127.0.0.1 --port ${port}`,
|
||||
ignoreHTTPSErrors: true,
|
||||
reuseExistingServer: false,
|
||||
timeout: 120_000,
|
||||
url: baseURL,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
launchOptions: {
|
||||
args: ['--enable-unsafe-webgpu'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
Before Width: | Height: | Size: 908 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 587 B After Width: | Height: | Size: 892 B |
|
|
@ -1,6 +1,31 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="14" fill="#b7455e" />
|
||||
<circle cx="22" cy="26" r="9" fill="#fff" opacity="0.95" />
|
||||
<circle cx="42" cy="32" r="11" fill="#fff" opacity="0.85" />
|
||||
<circle cx="28" cy="44" r="7" fill="#fff" opacity="0.75" />
|
||||
<defs>
|
||||
<clipPath id="icon-clip">
|
||||
<rect width="64" height="64" rx="14" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<g clip-path="url(#icon-clip)">
|
||||
<rect width="64" height="64" fill="#10151f" />
|
||||
<path d="M0 64a32 32 0 0 1 64 0Z" fill="#40d6c8" />
|
||||
<path
|
||||
d="M32 34c1.2-7.2 4.8-12.3 10-16"
|
||||
fill="none"
|
||||
stroke="#10151f"
|
||||
stroke-linecap="round"
|
||||
stroke-width="8"
|
||||
/>
|
||||
<path
|
||||
d="M32 34c1.2-7.2 4.8-12.3 10-16"
|
||||
fill="none"
|
||||
stroke="#ff5da2"
|
||||
stroke-linecap="round"
|
||||
stroke-width="4"
|
||||
/>
|
||||
<ellipse cx="42" cy="11.5" rx="4.2" ry="6.4" fill="#ff5da2" />
|
||||
<ellipse cx="48.5" cy="18" rx="6.4" ry="4.2" fill="#ff5da2" />
|
||||
<ellipse cx="42" cy="24.5" rx="4.2" ry="6.4" fill="#ff5da2" />
|
||||
<ellipse cx="35.5" cy="18" rx="6.4" ry="4.2" fill="#ff5da2" />
|
||||
<circle cx="42" cy="18" r="3.2" fill="#10151f" />
|
||||
</g>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 312 B After Width: | Height: | Size: 950 B |
|
|
@ -1,38 +1,35 @@
|
|||
{
|
||||
"name": "Just a bunch of blobs",
|
||||
"short_name": "Blobs",
|
||||
"description": "A WebGPU agent simulation: a million blobs leave trails, infect each other across generations, and react to your brush.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"name": "Fleeting Garden",
|
||||
"short_name": "Garden",
|
||||
"description": "Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser.",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"display": "fullscreen",
|
||||
"display_override": ["fullscreen", "standalone", "minimal-ui"],
|
||||
"orientation": "any",
|
||||
"background_color": "#b7455e",
|
||||
"theme_color": "#b7455e",
|
||||
"background_color": "#10151f",
|
||||
"theme_color": "#10151f",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon.svg",
|
||||
"src": "favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
"type": "image/svg+xml"
|
||||
},
|
||||
{
|
||||
"src": "/pwa-64x64.png",
|
||||
"src": "pwa-64x64.png",
|
||||
"sizes": "64x64",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/pwa-192x192.png",
|
||||
"src": "pwa-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/pwa-512x512.png",
|
||||
"src": "pwa-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/maskable-icon-512x512.png",
|
||||
"src": "maskable-icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 3.4 KiB |
BIN
public/og-image.jpg
Normal file
|
After Width: | Height: | Size: 301 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 3 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 488 B After Width: | Height: | Size: 690 B |
|
|
@ -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>
|
||||
68
src/analytics.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import {
|
||||
init as plausibleInit,
|
||||
track as plausibleTrack,
|
||||
type PlausibleEventOptions,
|
||||
} from '@plausible-analytics/tracker';
|
||||
|
||||
import { appConfig } from './config';
|
||||
import type { VibeId } from './vibes';
|
||||
|
||||
let isInitialized = false;
|
||||
|
||||
const track = (eventName: string, options: PlausibleEventOptions = {}) => {
|
||||
try {
|
||||
plausibleTrack(eventName, options);
|
||||
} catch (error) {
|
||||
console.warn(`Could not track analytics event "${eventName}".`, error);
|
||||
}
|
||||
};
|
||||
|
||||
export const initAnalytics = () => {
|
||||
if (isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
plausibleInit({
|
||||
domain: appConfig.analytics.domain,
|
||||
endpoint: appConfig.analytics.endpoint,
|
||||
autoCapturePageviews: appConfig.analytics.autoCapturePageviews,
|
||||
logging: appConfig.analytics.logging,
|
||||
});
|
||||
isInitialized = true;
|
||||
} catch (error) {
|
||||
console.warn('Could not initialize analytics.', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const trackVibeChange = ({
|
||||
vibeId,
|
||||
vibeName,
|
||||
source,
|
||||
}: {
|
||||
vibeId: VibeId;
|
||||
vibeName: string;
|
||||
source: string;
|
||||
}) => {
|
||||
track('Vibe Change', {
|
||||
props: {
|
||||
vibeId,
|
||||
vibeName,
|
||||
source,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const trackStart = () => {
|
||||
track('Start');
|
||||
};
|
||||
|
||||
export const trackExport = ({ vibeId }: { vibeId: VibeId }) => {
|
||||
track('Export', {
|
||||
props: {
|
||||
format: 'png',
|
||||
resolution: 'internal-buffer',
|
||||
vibeId,
|
||||
},
|
||||
});
|
||||
};
|
||||
127
src/audio/garden-audio-config.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import type { PianoNoteRole } from './garden-audio-types';
|
||||
|
||||
export const DEFAULT_AUDIO_VOLUME = 0.5;
|
||||
export const SILENT_AUDIO_GAIN = 0.0001;
|
||||
|
||||
type GardenAudioChordQuality = 'major' | 'minor' | 'sus2' | 'sus4';
|
||||
|
||||
export interface GardenAudioChord {
|
||||
rootOffset: number;
|
||||
quality: GardenAudioChordQuality;
|
||||
}
|
||||
|
||||
export interface GardenAudioVibeSettings {
|
||||
idleIntensity: number;
|
||||
bpm: number;
|
||||
rampUpIntensity: number;
|
||||
rampUpTime: number;
|
||||
noteLength: number;
|
||||
notePitchOffset: number;
|
||||
brightness: number;
|
||||
scale?: Array<number>;
|
||||
progression?: Array<GardenAudioChord>;
|
||||
}
|
||||
|
||||
export interface GardenAudioVibeProfile extends GardenAudioVibeSettings {
|
||||
rootMidi: number;
|
||||
scale: Array<number>;
|
||||
progression: Array<GardenAudioChord>;
|
||||
}
|
||||
|
||||
export const defaultGardenAudioVibeSettings: GardenAudioVibeSettings = {
|
||||
idleIntensity: 0.08,
|
||||
bpm: 74,
|
||||
rampUpIntensity: 0.85,
|
||||
rampUpTime: 0.08,
|
||||
noteLength: 0.42,
|
||||
notePitchOffset: 0,
|
||||
brightness: 1,
|
||||
};
|
||||
|
||||
export const createGardenAudioConfig = () => ({
|
||||
masterVolume: DEFAULT_AUDIO_VOLUME,
|
||||
fadeInSeconds: 0.45,
|
||||
updateRampSeconds: 0.08,
|
||||
delay: {
|
||||
timeBeats: 0.5,
|
||||
timeMinSeconds: 0.18,
|
||||
timeMaxSeconds: 0.72,
|
||||
feedback: 0.12,
|
||||
wetGain: 0.044,
|
||||
erasingActivity: 0.12,
|
||||
activityFeedbackWeight: 0.08,
|
||||
feedbackMax: 0.32,
|
||||
feedbackMin: 0.04,
|
||||
outputActivityWeight: 0.5,
|
||||
outputBase: 0.65,
|
||||
outputActivityDuck: 0.28,
|
||||
timeRampSeconds: 0.12,
|
||||
},
|
||||
piano: {
|
||||
maxVoices: 24,
|
||||
gain: 0.48,
|
||||
sustainSeconds: 0.42,
|
||||
sustainLevel: 0.26,
|
||||
releaseSeconds: 0.34,
|
||||
lowpassHz: 7000,
|
||||
gainAttackSeconds: 0.006,
|
||||
lowpassMaxHz: 12000,
|
||||
lowpassMinHz: 1400,
|
||||
sustainBase: 0.45,
|
||||
sustainVelocityRange: 0.55,
|
||||
},
|
||||
rhythm: {
|
||||
idleIntensity: defaultGardenAudioVibeSettings.idleIntensity,
|
||||
bpm: defaultGardenAudioVibeSettings.bpm,
|
||||
stepsPerBeat: 4,
|
||||
stepsPerBar: 16,
|
||||
sparseActivity: 0.055,
|
||||
},
|
||||
eraser: {
|
||||
minIntervalSeconds: 0.12,
|
||||
noiseGain: 0.028,
|
||||
filterMinHz: 650,
|
||||
filterMaxHz: 3600,
|
||||
durationSeconds: 0.08,
|
||||
pan: 0,
|
||||
pianoActivity: 0,
|
||||
},
|
||||
energy: {
|
||||
decaySeconds: 0.9,
|
||||
releaseSeconds: 1.15,
|
||||
strokeDecaySeconds: 0.32,
|
||||
},
|
||||
graph: {
|
||||
pianoBusGains: {
|
||||
pad: 0.86,
|
||||
support: 0.94,
|
||||
texture: 0.88,
|
||||
gesture: 1,
|
||||
brush: 0.9,
|
||||
stinger: 0.92,
|
||||
} satisfies Record<PianoNoteRole, number>,
|
||||
pianoBusActivityDucking: {
|
||||
pad: 0.42,
|
||||
support: 0.18,
|
||||
texture: -0.06,
|
||||
gesture: 0,
|
||||
brush: -0.08,
|
||||
stinger: 0,
|
||||
} satisfies Record<PianoNoteRole, number>,
|
||||
noiseBusGain: 0.72,
|
||||
},
|
||||
input: {
|
||||
fullActivitySpeed: 0.86,
|
||||
activityNoiseFloorSpeed: 0.025,
|
||||
activityCurve: 0.74,
|
||||
activitySoftCeiling: 0.96,
|
||||
activityAttackSeconds: 0.055,
|
||||
activityReleaseSeconds: 0.2,
|
||||
minAudibleDistance: 0.0025,
|
||||
manicActivityThreshold: 0.9,
|
||||
manicReleaseThreshold: 0.76,
|
||||
maniaSmoothingSeconds: 0.12,
|
||||
},
|
||||
});
|
||||
|
||||
export type GardenAudioConfig = ReturnType<typeof createGardenAudioConfig>;
|
||||
66
src/audio/garden-audio-energy.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { approach, clamp01 } from '../utils/math';
|
||||
import type { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
|
||||
|
||||
export class GardenAudioEnergy {
|
||||
private isGestureActive = false;
|
||||
private energy = 0;
|
||||
private targetEnergy = 0;
|
||||
private lastEnergyUpdateAt = 0;
|
||||
|
||||
public constructor(private readonly config: GardenAudioConfig) {}
|
||||
|
||||
public beginGesture(now: number): void {
|
||||
this.isGestureActive = true;
|
||||
this.lastEnergyUpdateAt = now;
|
||||
}
|
||||
|
||||
public endGesture(): void {
|
||||
this.isGestureActive = false;
|
||||
this.targetEnergy = 0;
|
||||
}
|
||||
|
||||
public recordStroke(strokeEnergy: number, profile: GardenAudioVibeProfile): void {
|
||||
this.targetEnergy = Math.max(this.targetEnergy, strokeEnergy);
|
||||
if (this.isGestureActive) {
|
||||
this.energy = Math.max(this.energy, strokeEnergy * profile.rampUpIntensity);
|
||||
}
|
||||
}
|
||||
|
||||
public silence(): void {
|
||||
this.targetEnergy = 0;
|
||||
this.energy = 0;
|
||||
}
|
||||
|
||||
public update(now: number, profile: GardenAudioVibeProfile): void {
|
||||
if (this.lastEnergyUpdateAt <= 0) {
|
||||
this.lastEnergyUpdateAt = now;
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsedSeconds = now - this.lastEnergyUpdateAt;
|
||||
this.lastEnergyUpdateAt = now;
|
||||
this.targetEnergy *= Math.exp(
|
||||
-elapsedSeconds / this.config.energy.strokeDecaySeconds
|
||||
);
|
||||
|
||||
const target = this.isGestureActive ? this.targetEnergy : 0;
|
||||
let timeConstant = this.config.energy.decaySeconds;
|
||||
if (!this.isGestureActive) {
|
||||
timeConstant = this.config.energy.releaseSeconds;
|
||||
} else if (target > this.energy) {
|
||||
timeConstant = profile.rampUpTime;
|
||||
}
|
||||
this.energy = approach(this.energy, target, elapsedSeconds, timeConstant);
|
||||
}
|
||||
|
||||
public getLevel(): number {
|
||||
return clamp01(this.energy);
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.isGestureActive = false;
|
||||
this.energy = 0;
|
||||
this.targetEnergy = 0;
|
||||
this.lastEnergyUpdateAt = 0;
|
||||
}
|
||||
}
|
||||
75
src/audio/garden-audio-gesture-state.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { approach, clamp, clamp01, smoothstep } from '../utils/math';
|
||||
import type { GardenAudioConfig } from './garden-audio-config';
|
||||
import type { GardenAudioStrokeMetrics } from './garden-audio-input';
|
||||
|
||||
interface GardenAudioGestureFrame {
|
||||
activity: number;
|
||||
maniaAmount: number;
|
||||
}
|
||||
|
||||
export class GardenAudioGestureState {
|
||||
private activity = 0;
|
||||
private maniaAmount = 0;
|
||||
private isManic = false;
|
||||
|
||||
public constructor(private readonly inputConfig: GardenAudioConfig['input']) {}
|
||||
|
||||
public recordStroke({
|
||||
metrics,
|
||||
}: {
|
||||
metrics: GardenAudioStrokeMetrics;
|
||||
}): GardenAudioGestureFrame {
|
||||
const targetActivity = this.getTargetActivity(metrics);
|
||||
const activityTimeConstant =
|
||||
targetActivity > this.activity
|
||||
? this.inputConfig.activityAttackSeconds
|
||||
: this.inputConfig.activityReleaseSeconds;
|
||||
this.activity = approach(
|
||||
this.activity,
|
||||
targetActivity,
|
||||
metrics.elapsedSeconds,
|
||||
activityTimeConstant
|
||||
);
|
||||
|
||||
if (this.activity >= this.inputConfig.manicActivityThreshold) {
|
||||
this.isManic = true;
|
||||
} else if (this.activity <= this.inputConfig.manicReleaseThreshold) {
|
||||
this.isManic = false;
|
||||
}
|
||||
|
||||
const maniaTarget = this.isManic
|
||||
? smoothstep(this.inputConfig.manicReleaseThreshold, 1, this.activity)
|
||||
: 0;
|
||||
this.maniaAmount = approach(
|
||||
this.maniaAmount,
|
||||
maniaTarget,
|
||||
metrics.elapsedSeconds,
|
||||
this.inputConfig.maniaSmoothingSeconds
|
||||
);
|
||||
|
||||
return {
|
||||
activity: this.activity,
|
||||
maniaAmount: this.maniaAmount,
|
||||
};
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.activity = 0;
|
||||
this.maniaAmount = 0;
|
||||
this.isManic = false;
|
||||
}
|
||||
|
||||
private getTargetActivity(metrics: GardenAudioStrokeMetrics): number {
|
||||
const speedRange =
|
||||
this.inputConfig.fullActivitySpeed - this.inputConfig.activityNoiseFloorSpeed;
|
||||
const speedAmount = clamp01(
|
||||
(metrics.normalizedSpeed - this.inputConfig.activityNoiseFloorSpeed) / speedRange
|
||||
);
|
||||
const distanceAmount = clamp01(
|
||||
metrics.normalizedDistance / this.inputConfig.minAudibleDistance
|
||||
);
|
||||
const activity = Math.pow(speedAmount, this.inputConfig.activityCurve);
|
||||
|
||||
return clamp(activity * distanceAmount, 0, this.inputConfig.activitySoftCeiling);
|
||||
}
|
||||
}
|
||||
347
src/audio/garden-audio-graph.ts
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
import { clamp } from '../utils/math';
|
||||
import { SILENT_AUDIO_GAIN, type GardenAudioConfig } from './garden-audio-config';
|
||||
import type { PianoNoteRole } from './garden-audio-types';
|
||||
|
||||
type AudioSessionType = NonNullable<NavigatorWithAudioSession['audioSession']>['type'];
|
||||
|
||||
type NavigatorWithAudioSession = Navigator & {
|
||||
audioSession?: {
|
||||
type:
|
||||
| 'auto'
|
||||
| 'playback'
|
||||
| 'ambient'
|
||||
| 'transient'
|
||||
| 'transient-solo'
|
||||
| 'play-and-record';
|
||||
};
|
||||
};
|
||||
|
||||
const outputHighPassFrequencyHz = 45;
|
||||
const noiseBufferDurationSeconds = 1;
|
||||
const graphTuning = {
|
||||
closeGain: SILENT_AUDIO_GAIN,
|
||||
closeRampSeconds: 0.015,
|
||||
delayMaxSeconds: 2,
|
||||
eventBusGain: 1,
|
||||
noiseMax: 1,
|
||||
noiseMin: -1,
|
||||
latencyHint: 'interactive',
|
||||
outputFilterType: 'highpass',
|
||||
compressor: {
|
||||
thresholdDb: -18,
|
||||
kneeDb: 18,
|
||||
ratio: 2.1,
|
||||
attackSeconds: 0.018,
|
||||
releaseSeconds: 0.18,
|
||||
},
|
||||
} as const;
|
||||
const delayFilterTuning = {
|
||||
feedbackHighPassHz: 180,
|
||||
feedbackLowPassHz: 5200,
|
||||
returnLowPassHz: 6200,
|
||||
};
|
||||
|
||||
export class GardenAudioGraph {
|
||||
public context: AudioContext | null = null;
|
||||
public eventBus: GainNode | null = null;
|
||||
public delayInput: GainNode | null = null;
|
||||
public noiseBus: GainNode | null = null;
|
||||
public noiseBuffer: AudioBuffer | null = null;
|
||||
|
||||
private masterGain: GainNode | null = null;
|
||||
private delayNode: DelayNode | null = null;
|
||||
private delayFeedback: GainNode | null = null;
|
||||
private delayOutput: GainNode | null = null;
|
||||
private lastPianoBusActivity = 0;
|
||||
private pianoBusGainScale = 1;
|
||||
private pianoBusGainScaleAutomationUntil = 0;
|
||||
private pianoBusGainScaleTimeConstantSeconds = 0;
|
||||
private previousAudioSessionType: AudioSessionType | null = null;
|
||||
private readonly pianoBuses = new Map<PianoNoteRole, GainNode>();
|
||||
|
||||
public constructor(private readonly config: GardenAudioConfig) {}
|
||||
|
||||
public ensureContext(canCreate: boolean): AudioContext | null {
|
||||
if (this.context) {
|
||||
return this.context;
|
||||
}
|
||||
|
||||
if (!canCreate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const AudioContextConstructor = globalThis.AudioContext;
|
||||
if (!AudioContextConstructor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Tells iOS to treat this as media playback, so the hardware ringer/mute
|
||||
// switch does not silence Web Audio output. No-op on browsers without the
|
||||
// Audio Session API.
|
||||
const audioSession = (navigator as NavigatorWithAudioSession).audioSession;
|
||||
if (audioSession) {
|
||||
this.previousAudioSessionType ??= audioSession.type;
|
||||
audioSession.type = 'playback';
|
||||
}
|
||||
|
||||
const context = new AudioContextConstructor({
|
||||
latencyHint: graphTuning.latencyHint,
|
||||
});
|
||||
const masterGain = context.createGain();
|
||||
const highPass = context.createBiquadFilter();
|
||||
const compressor = context.createDynamicsCompressor();
|
||||
|
||||
masterGain.gain.value = 0;
|
||||
highPass.type = graphTuning.outputFilterType;
|
||||
highPass.frequency.value = outputHighPassFrequencyHz;
|
||||
compressor.threshold.value = graphTuning.compressor.thresholdDb;
|
||||
compressor.knee.value = graphTuning.compressor.kneeDb;
|
||||
compressor.ratio.value = graphTuning.compressor.ratio;
|
||||
compressor.attack.value = graphTuning.compressor.attackSeconds;
|
||||
compressor.release.value = graphTuning.compressor.releaseSeconds;
|
||||
|
||||
masterGain.connect(highPass);
|
||||
highPass.connect(compressor);
|
||||
compressor.connect(context.destination);
|
||||
|
||||
this.context = context;
|
||||
this.masterGain = masterGain;
|
||||
this.noiseBuffer = this.createNoiseBuffer(context);
|
||||
this.createDelay(context, masterGain);
|
||||
this.createBuses(context, masterGain);
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
public setMasterGain(targetGain: number, timeConstantSeconds: number): void {
|
||||
if (!this.context || !this.masterGain) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.masterGain.gain.setTargetAtTime(
|
||||
targetGain,
|
||||
this.context.currentTime,
|
||||
timeConstantSeconds
|
||||
);
|
||||
}
|
||||
|
||||
public applyDelayProfile(bpm: number): void {
|
||||
if (!this.context || !this.delayNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.delayNode.delayTime.setTargetAtTime(
|
||||
this.getDelayTimeSecondsForBpm(bpm),
|
||||
this.context.currentTime,
|
||||
this.config.delay.timeRampSeconds
|
||||
);
|
||||
}
|
||||
|
||||
public updateDelay(activity: number, bpm: number): void {
|
||||
if (!this.context || !this.delayNode || !this.delayFeedback || !this.delayOutput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = this.context.currentTime;
|
||||
const normalizedActivity = clamp(activity, 0, 1);
|
||||
this.delayNode.delayTime.setTargetAtTime(
|
||||
this.getDelayTimeSecondsForBpm(bpm),
|
||||
now,
|
||||
this.config.delay.timeRampSeconds
|
||||
);
|
||||
this.delayFeedback.gain.setTargetAtTime(
|
||||
clamp(
|
||||
this.config.delay.feedback +
|
||||
normalizedActivity * this.config.delay.activityFeedbackWeight,
|
||||
this.config.delay.feedbackMin,
|
||||
this.config.delay.feedbackMax
|
||||
),
|
||||
now,
|
||||
this.config.updateRampSeconds
|
||||
);
|
||||
this.delayOutput.gain.setTargetAtTime(
|
||||
this.config.delay.wetGain *
|
||||
(this.config.delay.outputBase +
|
||||
normalizedActivity * this.config.delay.outputActivityWeight) *
|
||||
(1 - normalizedActivity * this.config.delay.outputActivityDuck),
|
||||
now,
|
||||
this.config.updateRampSeconds
|
||||
);
|
||||
this.updatePianoBusGains(normalizedActivity, now);
|
||||
}
|
||||
|
||||
public getPianoBus(role: PianoNoteRole | undefined): GainNode | null {
|
||||
return this.pianoBuses.get(role ?? 'gesture') ?? this.eventBus;
|
||||
}
|
||||
|
||||
public setPianoBusGainScale(targetScale: number, timeConstantSeconds: number): void {
|
||||
if (!this.context) {
|
||||
this.pianoBusGainScale = clamp(targetScale, 0, 1);
|
||||
return;
|
||||
}
|
||||
|
||||
const now = this.context.currentTime;
|
||||
|
||||
this.pianoBusGainScale = clamp(targetScale, 0, 1);
|
||||
this.pianoBusGainScaleTimeConstantSeconds = timeConstantSeconds;
|
||||
this.pianoBusGainScaleAutomationUntil = now + timeConstantSeconds * 4;
|
||||
this.updatePianoBusGains(this.lastPianoBusActivity, now, timeConstantSeconds);
|
||||
}
|
||||
|
||||
public async close(): Promise<void> {
|
||||
const context = this.context;
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.masterGain && context.state !== 'closed') {
|
||||
this.masterGain.gain.setTargetAtTime(
|
||||
graphTuning.closeGain,
|
||||
context.currentTime,
|
||||
graphTuning.closeRampSeconds
|
||||
);
|
||||
}
|
||||
|
||||
this.clearNodes();
|
||||
|
||||
if (context.state !== 'closed') {
|
||||
await context.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
this.restoreAudioSessionType();
|
||||
}
|
||||
|
||||
private restoreAudioSessionType(): void {
|
||||
const previousType = this.previousAudioSessionType;
|
||||
this.previousAudioSessionType = null;
|
||||
if (previousType === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const audioSession = (navigator as NavigatorWithAudioSession).audioSession;
|
||||
if (audioSession) {
|
||||
audioSession.type = previousType;
|
||||
}
|
||||
}
|
||||
|
||||
private createDelay(context: AudioContext, masterGain: GainNode): void {
|
||||
const delayInput = context.createGain();
|
||||
const delayNode = context.createDelay(graphTuning.delayMaxSeconds);
|
||||
const delayFeedback = context.createGain();
|
||||
const delayOutput = context.createGain();
|
||||
const feedbackHighPass = context.createBiquadFilter();
|
||||
const feedbackLowPass = context.createBiquadFilter();
|
||||
const returnLowPass = context.createBiquadFilter();
|
||||
|
||||
delayNode.delayTime.value = this.getDelayTimeSecondsForBpm(this.config.rhythm.bpm);
|
||||
delayFeedback.gain.value = this.config.delay.feedback;
|
||||
delayOutput.gain.value = this.config.delay.wetGain;
|
||||
feedbackHighPass.type = 'highpass';
|
||||
feedbackHighPass.frequency.value = delayFilterTuning.feedbackHighPassHz;
|
||||
feedbackLowPass.type = 'lowpass';
|
||||
feedbackLowPass.frequency.value = delayFilterTuning.feedbackLowPassHz;
|
||||
returnLowPass.type = 'lowpass';
|
||||
returnLowPass.frequency.value = delayFilterTuning.returnLowPassHz;
|
||||
|
||||
delayInput.connect(delayNode);
|
||||
delayNode.connect(feedbackHighPass);
|
||||
feedbackHighPass.connect(feedbackLowPass);
|
||||
feedbackLowPass.connect(delayFeedback);
|
||||
delayFeedback.connect(delayNode);
|
||||
delayNode.connect(returnLowPass);
|
||||
returnLowPass.connect(delayOutput);
|
||||
delayOutput.connect(masterGain);
|
||||
|
||||
this.delayInput = delayInput;
|
||||
this.delayNode = delayNode;
|
||||
this.delayFeedback = delayFeedback;
|
||||
this.delayOutput = delayOutput;
|
||||
}
|
||||
|
||||
private createBuses(context: AudioContext, masterGain: GainNode): void {
|
||||
const eventBus = context.createGain();
|
||||
eventBus.gain.value = graphTuning.eventBusGain;
|
||||
eventBus.connect(masterGain);
|
||||
this.eventBus = eventBus;
|
||||
this.pianoBuses.clear();
|
||||
|
||||
(Object.keys(this.config.graph.pianoBusGains) as Array<PianoNoteRole>).forEach(
|
||||
(role) => {
|
||||
const bus = context.createGain();
|
||||
bus.gain.value = this.config.graph.pianoBusGains[role];
|
||||
bus.connect(eventBus);
|
||||
this.pianoBuses.set(role, bus);
|
||||
}
|
||||
);
|
||||
|
||||
this.noiseBus = context.createGain();
|
||||
this.noiseBus.gain.value = this.config.graph.noiseBusGain;
|
||||
this.noiseBus.connect(eventBus);
|
||||
}
|
||||
|
||||
private updatePianoBusGains(
|
||||
activity: number,
|
||||
now: number,
|
||||
timeConstantSeconds?: number
|
||||
): void {
|
||||
const effectiveTimeConstantSeconds =
|
||||
timeConstantSeconds ??
|
||||
(now < this.pianoBusGainScaleAutomationUntil
|
||||
? this.pianoBusGainScaleTimeConstantSeconds
|
||||
: this.config.updateRampSeconds);
|
||||
|
||||
this.lastPianoBusActivity = activity;
|
||||
this.pianoBuses.forEach((bus, role) => {
|
||||
const baseGain = this.config.graph.pianoBusGains[role];
|
||||
const ducking = this.config.graph.pianoBusActivityDucking[role];
|
||||
bus.gain.setTargetAtTime(
|
||||
Math.max(0, baseGain * (1 - activity * ducking) * this.pianoBusGainScale),
|
||||
now,
|
||||
effectiveTimeConstantSeconds
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private getDelayTimeSecondsForBpm(bpm: number): number {
|
||||
const safeBpm = Number.isFinite(bpm) ? Math.max(1, bpm) : this.config.rhythm.bpm;
|
||||
return clamp(
|
||||
(60 / safeBpm) * this.config.delay.timeBeats,
|
||||
this.config.delay.timeMinSeconds,
|
||||
this.config.delay.timeMaxSeconds
|
||||
);
|
||||
}
|
||||
|
||||
private createNoiseBuffer(context: AudioContext): AudioBuffer {
|
||||
const buffer = context.createBuffer(
|
||||
1,
|
||||
Math.floor(context.sampleRate * noiseBufferDurationSeconds),
|
||||
context.sampleRate
|
||||
);
|
||||
const data = buffer.getChannelData(0);
|
||||
|
||||
for (let index = 0; index < data.length; index++) {
|
||||
data[index] =
|
||||
graphTuning.noiseMin +
|
||||
Math.random() * (graphTuning.noiseMax - graphTuning.noiseMin);
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private clearNodes(): void {
|
||||
this.context = null;
|
||||
this.eventBus = null;
|
||||
this.delayInput = null;
|
||||
this.noiseBus = null;
|
||||
this.noiseBuffer = null;
|
||||
this.masterGain = null;
|
||||
this.delayNode = null;
|
||||
this.delayFeedback = null;
|
||||
this.delayOutput = null;
|
||||
this.lastPianoBusActivity = 0;
|
||||
this.pianoBusGainScale = 1;
|
||||
this.pianoBusGainScaleAutomationUntil = 0;
|
||||
this.pianoBusGainScaleTimeConstantSeconds = 0;
|
||||
this.pianoBuses.clear();
|
||||
}
|
||||
}
|
||||
27
src/audio/garden-audio-input.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import type { GardenAudioStroke } from './garden-audio-types';
|
||||
|
||||
const minElapsedSeconds = 0.001;
|
||||
|
||||
export interface GardenAudioStrokeMetrics {
|
||||
elapsedSeconds: number;
|
||||
normalizedDistance: number;
|
||||
normalizedSpeed: number;
|
||||
}
|
||||
|
||||
export const getStrokeMetrics = (stroke: GardenAudioStroke): GardenAudioStrokeMetrics => {
|
||||
const dx = stroke.to[0] - stroke.from[0];
|
||||
const dy = stroke.to[1] - stroke.from[1];
|
||||
const distancePixels = Math.hypot(dx, dy);
|
||||
const elapsedSeconds = Math.max(minElapsedSeconds, stroke.elapsedSeconds ?? 0);
|
||||
const normalizationPixels = Math.max(
|
||||
1,
|
||||
Math.min(stroke.canvasSize[0], stroke.canvasSize[1])
|
||||
);
|
||||
const normalizedDistance = distancePixels / normalizationPixels;
|
||||
|
||||
return {
|
||||
elapsedSeconds,
|
||||
normalizedDistance,
|
||||
normalizedSpeed: normalizedDistance / elapsedSeconds,
|
||||
};
|
||||
};
|
||||
33
src/audio/garden-audio-music.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import type { VibePreset } from '../vibes';
|
||||
import type { GardenAudioChord, GardenAudioVibeProfile } from './garden-audio-config';
|
||||
|
||||
export const PITCH_SEMITONES_PER_OCTAVE = 12;
|
||||
|
||||
const DEFAULT_PROGRESSION: ReadonlyArray<GardenAudioChord> = [
|
||||
{ rootOffset: 0, quality: 'major' },
|
||||
{ rootOffset: 9, quality: 'minor' },
|
||||
{ rootOffset: 5, quality: 'major' },
|
||||
{ rootOffset: 7, quality: 'major' },
|
||||
];
|
||||
|
||||
const DEFAULT_ROOT_MIDI = 57;
|
||||
const DEFAULT_SCALE: ReadonlyArray<number> = [0, 2, 4, 7, 9];
|
||||
|
||||
const getProfileScale = (vibe: VibePreset): Array<number> => {
|
||||
const scale = vibe.audio.scale?.length ? vibe.audio.scale : DEFAULT_SCALE;
|
||||
return [...scale];
|
||||
};
|
||||
|
||||
const getProfileProgression = (vibe: VibePreset): Array<GardenAudioChord> =>
|
||||
(vibe.audio.progression?.length ? vibe.audio.progression : DEFAULT_PROGRESSION).map(
|
||||
(chord) => ({ ...chord })
|
||||
);
|
||||
|
||||
export const getVibeProfile = (vibe: VibePreset): GardenAudioVibeProfile => {
|
||||
return {
|
||||
...vibe.audio,
|
||||
rootMidi: DEFAULT_ROOT_MIDI + vibe.audio.notePitchOffset,
|
||||
scale: getProfileScale(vibe),
|
||||
progression: getProfileProgression(vibe),
|
||||
};
|
||||
};
|
||||
48
src/audio/garden-audio-types.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import type { VibePreset } from '../vibes';
|
||||
|
||||
export interface GardenAudioSnapshot {
|
||||
vibe: VibePreset;
|
||||
isErasing: boolean;
|
||||
}
|
||||
|
||||
export interface GardenAudioStroke {
|
||||
vibe: VibePreset;
|
||||
from: ArrayLike<number>;
|
||||
to: ArrayLike<number>;
|
||||
canvasSize: ArrayLike<number>;
|
||||
isErasing: boolean;
|
||||
elapsedSeconds: number;
|
||||
}
|
||||
|
||||
export interface LoadedPianoSample {
|
||||
midi: number;
|
||||
buffer: AudioBuffer;
|
||||
}
|
||||
|
||||
export interface PianoNote {
|
||||
midi: number;
|
||||
velocity: number;
|
||||
startTime: number;
|
||||
durationSeconds: number;
|
||||
pan: number;
|
||||
role?: PianoNoteRole;
|
||||
delaySend?: number;
|
||||
lowpassHz?: number;
|
||||
sustainSeconds?: number;
|
||||
}
|
||||
|
||||
export type PianoNoteRole =
|
||||
| 'pad'
|
||||
| 'support'
|
||||
| 'texture'
|
||||
| 'gesture'
|
||||
| 'brush'
|
||||
| 'stinger';
|
||||
|
||||
export interface NoiseBurst {
|
||||
startTime: number;
|
||||
durationSeconds: number;
|
||||
gain: number;
|
||||
filterHz: number;
|
||||
pan: number;
|
||||
}
|
||||
448
src/audio/garden-audio.ts
Normal file
|
|
@ -0,0 +1,448 @@
|
|||
import { ErrorHandler, Severity } from '../utils/error-handler';
|
||||
import { clamp01 } from '../utils/math';
|
||||
import type { VibeId, VibePreset } from '../vibes';
|
||||
import {
|
||||
SILENT_AUDIO_GAIN,
|
||||
type GardenAudioConfig,
|
||||
type GardenAudioVibeProfile,
|
||||
} from './garden-audio-config';
|
||||
import { GardenAudioEnergy } from './garden-audio-energy';
|
||||
import { GardenAudioGestureState } from './garden-audio-gesture-state';
|
||||
import { GardenAudioGraph } from './garden-audio-graph';
|
||||
import { getStrokeMetrics } from './garden-audio-input';
|
||||
import { getVibeProfile } from './garden-audio-music';
|
||||
import type { GardenAudioSnapshot, GardenAudioStroke } from './garden-audio-types';
|
||||
import { GenerativePianoEngine } from './generative-piano';
|
||||
import { NoiseBurstPlayer } from './noise-burst-player';
|
||||
import { PianoSampler } from './piano-sampler';
|
||||
|
||||
type AudioLifecycle = 'idle' | 'started' | 'destroyed';
|
||||
type PianoReleasePhase =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'awaiting-fade' }
|
||||
| { kind: 'scheduled-fade'; fadeAt: number }
|
||||
| { kind: 'settling'; stopAt: number };
|
||||
|
||||
const muteRampSeconds = 0.02;
|
||||
const brushUpPianoBusFadeSeconds = 2.4;
|
||||
const brushUpPianoBusFadeSettleSeconds = 3.2;
|
||||
const vibeChangeStingerMinIntervalSeconds = 0.45;
|
||||
|
||||
export class GardenAudio {
|
||||
private readonly graph: GardenAudioGraph;
|
||||
private readonly piano: PianoSampler;
|
||||
private readonly noise: NoiseBurstPlayer;
|
||||
private readonly energy: GardenAudioEnergy;
|
||||
private readonly gestureState: GardenAudioGestureState;
|
||||
private readonly pianoEngine: GenerativePianoEngine;
|
||||
|
||||
private currentVibeId: VibeId | null = null;
|
||||
private currentVibe: VibePreset | null = null;
|
||||
private lifecycle: AudioLifecycle = 'idle';
|
||||
private pianoReleasePhase: PianoReleasePhase = { kind: 'idle' };
|
||||
private isMuted = false;
|
||||
private isGestureActive = false;
|
||||
private masterVolume: number;
|
||||
private lastEraserAt = Number.NEGATIVE_INFINITY;
|
||||
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
||||
private startRequestId = 0;
|
||||
private hasLoadedPiano = false;
|
||||
|
||||
public constructor(private readonly config: GardenAudioConfig) {
|
||||
this.masterVolume = clamp01(config.masterVolume);
|
||||
this.graph = new GardenAudioGraph(config);
|
||||
this.piano = new PianoSampler(config, this.graph);
|
||||
this.noise = new NoiseBurstPlayer(this.graph);
|
||||
this.energy = new GardenAudioEnergy(config);
|
||||
this.gestureState = new GardenAudioGestureState(config.input);
|
||||
this.pianoEngine = new GenerativePianoEngine(config, (note) => this.piano.play(note));
|
||||
}
|
||||
|
||||
public start(vibe: VibePreset, options: { userGesture?: boolean } = {}): void {
|
||||
const isUserGesture = options.userGesture === true;
|
||||
|
||||
if (this.lifecycle === 'destroyed') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
this.lifecycle === 'started' &&
|
||||
this.currentVibeId === vibe.id &&
|
||||
this.graph.context?.state === 'running' &&
|
||||
this.hasLoadedPiano
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const context = this.graph.ensureContext(isUserGesture);
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startupRampSeconds = isUserGesture
|
||||
? muteRampSeconds
|
||||
: this.config.fadeInSeconds;
|
||||
const needsResume = context.state !== 'running' && context.state !== 'closed';
|
||||
const startRequestId = ++this.startRequestId;
|
||||
|
||||
if (needsResume) {
|
||||
if (!isUserGesture) {
|
||||
return;
|
||||
}
|
||||
void context
|
||||
.resume()
|
||||
.then(() => {
|
||||
if (this.graph.context === context && this.lifecycle !== 'destroyed') {
|
||||
this.completeStart(vibe, { context, startupRampSeconds, startRequestId });
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
ErrorHandler.addException(error, {
|
||||
fallbackMessage: 'Could not resume audio playback.',
|
||||
severity: Severity.WARNING,
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.completeStart(vibe, { context, startupRampSeconds, startRequestId });
|
||||
}
|
||||
|
||||
private completeStart(
|
||||
vibe: VibePreset,
|
||||
{
|
||||
context,
|
||||
startRequestId,
|
||||
startupRampSeconds,
|
||||
}: {
|
||||
context: AudioContext;
|
||||
startRequestId: number;
|
||||
startupRampSeconds: number;
|
||||
}
|
||||
): void {
|
||||
if (this.graph.context !== context || this.lifecycle === 'destroyed') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isMuted) {
|
||||
this.activateMutedStart(vibe, context);
|
||||
this.graph.setMasterGain(SILENT_AUDIO_GAIN, muteRampSeconds);
|
||||
return;
|
||||
}
|
||||
|
||||
void this.piano
|
||||
.load(context)
|
||||
.then(() => {
|
||||
if (!this.canCompleteStart(context, startRequestId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.activateStart(vibe, context, startupRampSeconds, true);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (this.canCompleteStart(context, startRequestId)) {
|
||||
this.activateStart(vibe, context, startupRampSeconds, false);
|
||||
}
|
||||
ErrorHandler.addException(error, {
|
||||
fallbackMessage: 'Could not load piano samples.',
|
||||
severity: Severity.WARNING,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private canCompleteStart(context: AudioContext, startRequestId: number): boolean {
|
||||
return (
|
||||
this.graph.context === context &&
|
||||
this.lifecycle !== 'destroyed' &&
|
||||
!this.isMuted &&
|
||||
this.startRequestId === startRequestId
|
||||
);
|
||||
}
|
||||
|
||||
private activateStart(
|
||||
vibe: VibePreset,
|
||||
context: AudioContext,
|
||||
startupRampSeconds: number,
|
||||
cuePiano: boolean
|
||||
): void {
|
||||
this.lifecycle = 'started';
|
||||
this.currentVibeId = vibe.id;
|
||||
this.currentVibe = vibe;
|
||||
const profile = getVibeProfile(vibe);
|
||||
this.graph.applyDelayProfile(profile.bpm);
|
||||
this.graph.setMasterGain(this.masterVolume, startupRampSeconds);
|
||||
|
||||
if (cuePiano) {
|
||||
this.hasLoadedPiano = true;
|
||||
this.pianoEngine.cue(context.currentTime, profile);
|
||||
}
|
||||
}
|
||||
|
||||
private activateMutedStart(vibe: VibePreset, context: AudioContext): void {
|
||||
this.lifecycle = 'started';
|
||||
this.currentVibeId = vibe.id;
|
||||
this.currentVibe = vibe;
|
||||
this.hasLoadedPiano = false;
|
||||
this.graph.applyDelayProfile(getVibeProfile(vibe).bpm);
|
||||
if (this.graph.context === context) {
|
||||
this.pianoEngine.reset();
|
||||
}
|
||||
}
|
||||
|
||||
public changeVibe(vibe: VibePreset, options: { userGesture?: boolean } = {}): void {
|
||||
const previousVibeId = this.currentVibeId;
|
||||
this.start(vibe, options);
|
||||
const didChangeVibe = previousVibeId !== null && previousVibeId !== vibe.id;
|
||||
|
||||
if (didChangeVibe) {
|
||||
this.piano.stopAll();
|
||||
this.hasLoadedPiano = false;
|
||||
}
|
||||
|
||||
const context = this.graph.context;
|
||||
if (
|
||||
context &&
|
||||
(context.state === 'running' || options.userGesture === true) &&
|
||||
!this.isMuted &&
|
||||
this.lifecycle !== 'destroyed' &&
|
||||
didChangeVibe
|
||||
) {
|
||||
this.playVibeChangeStinger(vibe);
|
||||
}
|
||||
}
|
||||
|
||||
public setMuted(isMuted: boolean): void {
|
||||
if (this.isMuted === isMuted) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isMuted = isMuted;
|
||||
this.graph.setMasterGain(
|
||||
isMuted ? SILENT_AUDIO_GAIN : this.masterVolume,
|
||||
isMuted ? muteRampSeconds : this.config.fadeInSeconds
|
||||
);
|
||||
|
||||
if (!isMuted && this.currentVibe && !this.hasLoadedPiano) {
|
||||
this.start(this.currentVibe);
|
||||
}
|
||||
}
|
||||
|
||||
public setMasterVolume(masterVolume: number): void {
|
||||
this.masterVolume = clamp01(masterVolume);
|
||||
if (!this.isMuted) {
|
||||
this.graph.setMasterGain(this.masterVolume, this.config.updateRampSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
public beginGesture(): void {
|
||||
const context = this.graph.context;
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isGestureActive = true;
|
||||
this.pianoReleasePhase = { kind: 'idle' };
|
||||
this.graph.setPianoBusGainScale(1, this.config.fadeInSeconds);
|
||||
this.gestureState.reset();
|
||||
this.energy.beginGesture(context.currentTime);
|
||||
this.pianoEngine.beginGesture();
|
||||
}
|
||||
|
||||
public endGesture(): void {
|
||||
this.gestureState.reset();
|
||||
this.isGestureActive = false;
|
||||
this.pianoReleasePhase = { kind: 'awaiting-fade' };
|
||||
this.energy.endGesture();
|
||||
this.pianoEngine.endGesture();
|
||||
}
|
||||
|
||||
public update(snapshot: GardenAudioSnapshot): void {
|
||||
const context = this.graph.context;
|
||||
if (this.lifecycle !== 'started' || !context || this.isMuted) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.applyVibe(snapshot.vibe);
|
||||
const profile = getVibeProfile(snapshot.vibe);
|
||||
this.energy.update(context.currentTime, profile);
|
||||
|
||||
if (snapshot.isErasing) {
|
||||
this.energy.silence();
|
||||
}
|
||||
|
||||
if (!this.isGestureActive && this.pianoReleasePhase.kind !== 'idle') {
|
||||
this.updatePianoRelease(snapshot.vibe, context.currentTime);
|
||||
this.updateDelay(snapshot, profile);
|
||||
return;
|
||||
}
|
||||
|
||||
this.pianoEngine.renderLookahead({
|
||||
vibe: snapshot.vibe,
|
||||
now: context.currentTime,
|
||||
activity: snapshot.isErasing
|
||||
? this.config.eraser.pianoActivity
|
||||
: this.energy.getLevel(),
|
||||
});
|
||||
this.updateDelay(snapshot, profile);
|
||||
}
|
||||
|
||||
public stroke(stroke: GardenAudioStroke): void {
|
||||
if (this.lifecycle !== 'started' || this.isMuted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const context = this.graph.context;
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
if (!this.isGestureActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metrics = getStrokeMetrics(stroke);
|
||||
const now = context.currentTime;
|
||||
|
||||
const frame = this.gestureState.recordStroke({ metrics });
|
||||
const strokeEnergy = frame.activity;
|
||||
|
||||
if (stroke.isErasing) {
|
||||
this.playEraser(strokeEnergy, now);
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = getVibeProfile(stroke.vibe);
|
||||
this.energy.recordStroke(strokeEnergy, profile);
|
||||
this.pianoEngine.recordStroke({
|
||||
vibe: stroke.vibe,
|
||||
now,
|
||||
activity: strokeEnergy,
|
||||
maniaAmount: frame.maniaAmount,
|
||||
});
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
this.lifecycle = 'destroyed';
|
||||
await this.graph.close();
|
||||
|
||||
this.piano.reset();
|
||||
this.hasLoadedPiano = false;
|
||||
this.energy.reset();
|
||||
this.gestureState.reset();
|
||||
this.pianoEngine.reset();
|
||||
this.currentVibeId = null;
|
||||
this.currentVibe = null;
|
||||
this.isGestureActive = false;
|
||||
this.pianoReleasePhase = { kind: 'idle' };
|
||||
this.lastEraserAt = Number.NEGATIVE_INFINITY;
|
||||
this.lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
|
||||
private playVibeChangeStinger(vibe: VibePreset): void {
|
||||
const context = this.graph.context;
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = context.currentTime;
|
||||
if (now - this.lastVibeStingerAt < vibeChangeStingerMinIntervalSeconds) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastVibeStingerAt = now;
|
||||
this.pianoEngine.playVibeChangeStinger(vibe, now);
|
||||
}
|
||||
|
||||
private updatePianoRelease(vibe: VibePreset, now: number): void {
|
||||
if (this.pianoReleasePhase.kind === 'awaiting-fade') {
|
||||
const fadeAt = this.pianoEngine.release(vibe, now);
|
||||
if (now < fadeAt) {
|
||||
this.pianoReleasePhase = { kind: 'scheduled-fade', fadeAt };
|
||||
return;
|
||||
}
|
||||
|
||||
this.graph.setPianoBusGainScale(0, brushUpPianoBusFadeSeconds);
|
||||
this.pianoReleasePhase = {
|
||||
kind: 'settling',
|
||||
stopAt: now + brushUpPianoBusFadeSettleSeconds,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
this.pianoReleasePhase.kind === 'scheduled-fade' &&
|
||||
now >= this.pianoReleasePhase.fadeAt
|
||||
) {
|
||||
this.graph.setPianoBusGainScale(0, brushUpPianoBusFadeSeconds);
|
||||
this.pianoReleasePhase = {
|
||||
kind: 'settling',
|
||||
stopAt: now + brushUpPianoBusFadeSettleSeconds,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
this.pianoReleasePhase.kind === 'settling' &&
|
||||
now >= this.pianoReleasePhase.stopAt
|
||||
) {
|
||||
this.piano.stopAll();
|
||||
this.pianoEngine.reset();
|
||||
this.hasLoadedPiano = false;
|
||||
this.pianoReleasePhase = { kind: 'idle' };
|
||||
}
|
||||
}
|
||||
|
||||
private playEraser(activity: number, now: number): void {
|
||||
if (!this.graph.context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const distanceActivity = clamp01(activity);
|
||||
if (distanceActivity <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filterHz =
|
||||
this.config.eraser.filterMinHz +
|
||||
(this.config.eraser.filterMaxHz - this.config.eraser.filterMinHz) *
|
||||
distanceActivity;
|
||||
|
||||
if (now - this.lastEraserAt >= this.config.eraser.minIntervalSeconds) {
|
||||
this.lastEraserAt = now;
|
||||
this.noise.play({
|
||||
startTime: now,
|
||||
durationSeconds: this.config.eraser.durationSeconds,
|
||||
gain: this.config.eraser.noiseGain * distanceActivity,
|
||||
filterHz,
|
||||
pan: this.config.eraser.pan,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private updateDelay(
|
||||
snapshot: GardenAudioSnapshot,
|
||||
profile: GardenAudioVibeProfile
|
||||
): void {
|
||||
const context = this.graph.context;
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activity = snapshot.isErasing
|
||||
? this.config.delay.erasingActivity
|
||||
: this.energy.getLevel();
|
||||
this.graph.updateDelay(activity, profile.bpm);
|
||||
}
|
||||
|
||||
private applyVibe(vibe: VibePreset): void {
|
||||
if (!this.graph.context || this.currentVibeId === vibe.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentVibeId = vibe.id;
|
||||
this.currentVibe = vibe;
|
||||
const profile = getVibeProfile(vibe);
|
||||
this.graph.applyDelayProfile(profile.bpm);
|
||||
this.pianoEngine.cue(this.graph.context.currentTime, profile);
|
||||
this.hasLoadedPiano = true;
|
||||
}
|
||||
}
|
||||
443
src/audio/generative-piano-tuning.ts
Normal file
|
|
@ -0,0 +1,443 @@
|
|||
export interface GardenAudioRegister {
|
||||
midiMin: number;
|
||||
midiMax: number;
|
||||
preferredMidi: number;
|
||||
pan: number;
|
||||
}
|
||||
|
||||
export interface GardenAudioStylePool extends GardenAudioRegister {
|
||||
scaleDegrees: Array<number>;
|
||||
}
|
||||
|
||||
interface GardenAudioStyleVoice {
|
||||
scaleDegreeOffset: number;
|
||||
velocityMultiplier: number;
|
||||
panOffset: number;
|
||||
}
|
||||
|
||||
interface GenerativePianoTuning {
|
||||
stylePools: [GardenAudioStylePool, GardenAudioStylePool, GardenAudioStylePool];
|
||||
padRegisters: [GardenAudioRegister, GardenAudioRegister, GardenAudioRegister];
|
||||
vibeChangeStinger: {
|
||||
velocities: [number, number, number];
|
||||
pans: [number, number, number];
|
||||
delaySends: [number, number, number];
|
||||
lowpassExpression: number;
|
||||
noteDurationSeconds: number;
|
||||
spacingSeconds: number;
|
||||
};
|
||||
releaseResolution: {
|
||||
durationSeconds: number;
|
||||
fadeAfterSeconds: number;
|
||||
velocities: [number, number, number];
|
||||
delaySend: number;
|
||||
lowpassExpression: number;
|
||||
strumSeconds: number;
|
||||
};
|
||||
highActivityExtra: {
|
||||
barOffset: number;
|
||||
expressionMultiplier: number;
|
||||
};
|
||||
padChord: {
|
||||
velocities: [number, number, number];
|
||||
expressionVelocityWeight: number;
|
||||
delaySend: number;
|
||||
lowpassExpressionWeight: number;
|
||||
};
|
||||
supportNote: {
|
||||
velocityBase: number;
|
||||
velocityExpressionWeight: number;
|
||||
durationBaseSeconds: number;
|
||||
durationExpressionSeconds: number;
|
||||
delaySendBase: number;
|
||||
delaySendExpressionWeight: number;
|
||||
lowpassExpressionWeight: number;
|
||||
expressionThreshold: number;
|
||||
offsetsByStyle: [Array<number>, Array<number>, Array<number>];
|
||||
};
|
||||
textureNote: {
|
||||
velocityBase: number;
|
||||
velocityExpressionWeight: number;
|
||||
durationBaseSeconds: number;
|
||||
durationExpressionSeconds: number;
|
||||
delaySendBase: number;
|
||||
delaySendExpressionWeight: number;
|
||||
idleExpressionThreshold: number;
|
||||
mediumExpressionThreshold: number;
|
||||
intenseSpacing: number;
|
||||
idlePhase: number;
|
||||
};
|
||||
gestureAccent: {
|
||||
rotationStrengthMultiplier: number;
|
||||
quantizeStepLookahead: number;
|
||||
velocityBase: number;
|
||||
velocityStrengthWeight: number;
|
||||
durationBaseSeconds: number;
|
||||
durationStrengthSeconds: number;
|
||||
delaySend: number;
|
||||
};
|
||||
touchNote: {
|
||||
velocityBase: number;
|
||||
velocityStrengthWeight: number;
|
||||
durationBaseSeconds: number;
|
||||
durationStrengthSeconds: number;
|
||||
delaySend: number;
|
||||
lowpassBaseExpression: number;
|
||||
lowpassStrengthWeight: number;
|
||||
};
|
||||
brushPhrase: {
|
||||
initialMotifOffset: number;
|
||||
energyDecaySeconds: number;
|
||||
maniaDecaySeconds: number;
|
||||
layerIntensityBase: number;
|
||||
layerIntensityManiaWeight: number;
|
||||
frameActivityWeight: number;
|
||||
frameManiaWeight: number;
|
||||
};
|
||||
brushStream: {
|
||||
inferredManiaThreshold: number;
|
||||
inferredManiaRange: number;
|
||||
registerManiaShift: number;
|
||||
chordToneEverySteps: number;
|
||||
durationBaseSeconds: number;
|
||||
durationIntensitySeconds: number;
|
||||
durationManiaSeconds: number;
|
||||
durationMinSeconds: number;
|
||||
durationMaxSeconds: number;
|
||||
delaySendBase: number;
|
||||
delaySendIntensityWeight: number;
|
||||
delaySendManiaWeight: number;
|
||||
delaySendMin: number;
|
||||
delaySendMax: number;
|
||||
velocityBase: number;
|
||||
velocityIntensityWeight: number;
|
||||
lowpassBaseExpression: number;
|
||||
lowpassIntensityWeight: number;
|
||||
lowpassManiaWeight: number;
|
||||
intenseThreshold: number;
|
||||
activeThreshold: number;
|
||||
};
|
||||
brushStreamEcho: {
|
||||
maniaThreshold: number;
|
||||
stepModulo: number;
|
||||
stepRemainder: number;
|
||||
intensityThreshold: number;
|
||||
octaveSemitones: number;
|
||||
maxMidi: number;
|
||||
velocityBase: number;
|
||||
velocityIntensityWeight: number;
|
||||
durationMinSeconds: number;
|
||||
durationScale: number;
|
||||
panScale: number;
|
||||
delaySendMin: number;
|
||||
delaySendScale: number;
|
||||
lowpassBaseExpression: number;
|
||||
lowpassManiaWeight: number;
|
||||
};
|
||||
brushMotif: {
|
||||
highThreshold: number;
|
||||
mediumThreshold: number;
|
||||
highOffset: number;
|
||||
mediumOffset: number;
|
||||
lowOffset: number;
|
||||
};
|
||||
registerBias: {
|
||||
maniaShiftSemitones: number;
|
||||
midiMin: number;
|
||||
midiMaxForMin: number;
|
||||
minimumSpan: number;
|
||||
midiMax: number;
|
||||
};
|
||||
candidateOctaveSearch: {
|
||||
min: number;
|
||||
max: number;
|
||||
};
|
||||
stereoWidth: {
|
||||
idle: number;
|
||||
active: number;
|
||||
intense: number;
|
||||
intenseThreshold: number;
|
||||
};
|
||||
stylePanOffsetScale: number;
|
||||
lowpass: {
|
||||
midiBase: number;
|
||||
midiRange: number;
|
||||
midiLiftHz: number;
|
||||
expressionBase: number;
|
||||
expressionWeight: number;
|
||||
};
|
||||
styleRotationBars: number;
|
||||
chordBars: number;
|
||||
supportBarSpacing: number;
|
||||
supportBarOffset: number;
|
||||
idleTextureBarSpacing: number;
|
||||
mediumTextureBarSpacing: number;
|
||||
textureBeat: number;
|
||||
highActivityExtraBeat: number;
|
||||
highActivityExtraThreshold: number;
|
||||
noteScorePreferenceWeight: number;
|
||||
noteScoreRegisterWeight: number;
|
||||
noteScoreChordToneWeight: number;
|
||||
noteScoreRepeatPenalty: number;
|
||||
gestureAccentMinIntervalSeconds: number;
|
||||
strokeAccentMinSteps: number;
|
||||
strokeAccentThreshold: number;
|
||||
maxBrushPhraseLayers: number;
|
||||
maxBrushStreamNotesPerBar: number;
|
||||
brushLayerBaseSeconds: number;
|
||||
brushLayerEnergySeconds: number;
|
||||
brushLayerMinIntensity: number;
|
||||
brushStreamIdleIntervalBeats: number;
|
||||
brushStreamActiveIntervalBeats: number;
|
||||
brushStreamIntenseIntervalBeats: number;
|
||||
brushMotifMaxSteps: number;
|
||||
brushMotifCanonDelaySeconds: number;
|
||||
padDurationBarScale: number;
|
||||
}
|
||||
|
||||
export const generativePianoTuning: GenerativePianoTuning = {
|
||||
stylePools: [
|
||||
{
|
||||
midiMin: 48,
|
||||
midiMax: 67,
|
||||
preferredMidi: 55,
|
||||
pan: -0.18,
|
||||
scaleDegrees: [0, 1, 2, 4],
|
||||
},
|
||||
{
|
||||
midiMin: 55,
|
||||
midiMax: 74,
|
||||
preferredMidi: 63,
|
||||
pan: 0,
|
||||
scaleDegrees: [1, 2, 3, 5],
|
||||
},
|
||||
{
|
||||
midiMin: 62,
|
||||
midiMax: 78,
|
||||
preferredMidi: 70,
|
||||
pan: 0.18,
|
||||
scaleDegrees: [2, 3, 4, 6],
|
||||
},
|
||||
],
|
||||
padRegisters: [
|
||||
{
|
||||
midiMin: 40,
|
||||
midiMax: 55,
|
||||
preferredMidi: 48,
|
||||
pan: -0.12,
|
||||
},
|
||||
{
|
||||
midiMin: 48,
|
||||
midiMax: 64,
|
||||
preferredMidi: 55,
|
||||
pan: 0.08,
|
||||
},
|
||||
{
|
||||
midiMin: 58,
|
||||
midiMax: 76,
|
||||
preferredMidi: 67,
|
||||
pan: 0.2,
|
||||
},
|
||||
],
|
||||
vibeChangeStinger: {
|
||||
velocities: [0.1, 0.085, 0.07],
|
||||
pans: [-0.16, 0, 0.16],
|
||||
delaySends: [0.012, 0.014, 0.016],
|
||||
lowpassExpression: 0.35,
|
||||
noteDurationSeconds: 1.1,
|
||||
spacingSeconds: 0.08,
|
||||
},
|
||||
releaseResolution: {
|
||||
durationSeconds: 3.4,
|
||||
fadeAfterSeconds: 2.4,
|
||||
velocities: [0.064, 0.05, 0.038],
|
||||
delaySend: 0.018,
|
||||
lowpassExpression: 0.34,
|
||||
strumSeconds: 0.055,
|
||||
},
|
||||
highActivityExtra: {
|
||||
barOffset: 1,
|
||||
expressionMultiplier: 0.9,
|
||||
},
|
||||
padChord: {
|
||||
velocities: [0.046, 0.036, 0.029],
|
||||
expressionVelocityWeight: 0.018,
|
||||
delaySend: 0.008,
|
||||
lowpassExpressionWeight: 0.24,
|
||||
},
|
||||
supportNote: {
|
||||
velocityBase: 0.105,
|
||||
velocityExpressionWeight: 0.07,
|
||||
durationBaseSeconds: 1.35,
|
||||
durationExpressionSeconds: 0.4,
|
||||
delaySendBase: 0.016,
|
||||
delaySendExpressionWeight: 0.006,
|
||||
lowpassExpressionWeight: 0.7,
|
||||
expressionThreshold: 0.55,
|
||||
offsetsByStyle: [
|
||||
[0, 2, 12],
|
||||
[1, 2, 0, 12],
|
||||
[2, 12, 3, 13],
|
||||
],
|
||||
},
|
||||
textureNote: {
|
||||
velocityBase: 0.09,
|
||||
velocityExpressionWeight: 0.08,
|
||||
durationBaseSeconds: 0.62,
|
||||
durationExpressionSeconds: 0.24,
|
||||
delaySendBase: 0.016,
|
||||
delaySendExpressionWeight: 0.006,
|
||||
idleExpressionThreshold: 0.35,
|
||||
mediumExpressionThreshold: 0.7,
|
||||
intenseSpacing: 1,
|
||||
idlePhase: 1,
|
||||
},
|
||||
gestureAccent: {
|
||||
rotationStrengthMultiplier: 3,
|
||||
quantizeStepLookahead: 1,
|
||||
velocityBase: 0.12,
|
||||
velocityStrengthWeight: 0.09,
|
||||
durationBaseSeconds: 0.48,
|
||||
durationStrengthSeconds: 0.22,
|
||||
delaySend: 0.012,
|
||||
},
|
||||
touchNote: {
|
||||
velocityBase: 0.14,
|
||||
velocityStrengthWeight: 0.11,
|
||||
durationBaseSeconds: 0.55,
|
||||
durationStrengthSeconds: 0.18,
|
||||
delaySend: 0.006,
|
||||
lowpassBaseExpression: 0.55,
|
||||
lowpassStrengthWeight: 0.35,
|
||||
},
|
||||
brushPhrase: {
|
||||
initialMotifOffset: -1,
|
||||
energyDecaySeconds: 0.72,
|
||||
maniaDecaySeconds: 0.54,
|
||||
layerIntensityBase: 0.8,
|
||||
layerIntensityManiaWeight: 0.42,
|
||||
frameActivityWeight: 0.42,
|
||||
frameManiaWeight: 0.18,
|
||||
},
|
||||
brushStream: {
|
||||
inferredManiaThreshold: 0.82,
|
||||
inferredManiaRange: 0.18,
|
||||
registerManiaShift: 0.3,
|
||||
chordToneEverySteps: 4,
|
||||
durationBaseSeconds: 0.48,
|
||||
durationIntensitySeconds: 0.08,
|
||||
durationManiaSeconds: 0.34,
|
||||
durationMinSeconds: 0.14,
|
||||
durationMaxSeconds: 0.62,
|
||||
delaySendBase: 0.012,
|
||||
delaySendIntensityWeight: 0.011,
|
||||
delaySendManiaWeight: 0.006,
|
||||
delaySendMin: 0.006,
|
||||
delaySendMax: 0.032,
|
||||
velocityBase: 0.1,
|
||||
velocityIntensityWeight: 0.1,
|
||||
lowpassBaseExpression: 0.39,
|
||||
lowpassIntensityWeight: 0.48,
|
||||
lowpassManiaWeight: 0.18,
|
||||
intenseThreshold: 0.68,
|
||||
activeThreshold: 0.34,
|
||||
},
|
||||
brushStreamEcho: {
|
||||
maniaThreshold: 0.92,
|
||||
stepModulo: 3,
|
||||
stepRemainder: 1,
|
||||
intensityThreshold: 0.95,
|
||||
octaveSemitones: 12,
|
||||
maxMidi: 84,
|
||||
velocityBase: 0.035,
|
||||
velocityIntensityWeight: 0.04,
|
||||
durationMinSeconds: 0.11,
|
||||
durationScale: 0.68,
|
||||
panScale: -0.75,
|
||||
delaySendMin: 0.006,
|
||||
delaySendScale: 0.72,
|
||||
lowpassBaseExpression: 0.62,
|
||||
lowpassManiaWeight: 0.24,
|
||||
},
|
||||
brushMotif: {
|
||||
highThreshold: 0.82,
|
||||
mediumThreshold: 0.55,
|
||||
highOffset: 1,
|
||||
mediumOffset: 0,
|
||||
lowOffset: -1,
|
||||
},
|
||||
registerBias: {
|
||||
maniaShiftSemitones: 2,
|
||||
midiMin: 36,
|
||||
midiMaxForMin: 86,
|
||||
minimumSpan: 4,
|
||||
midiMax: 91,
|
||||
},
|
||||
candidateOctaveSearch: {
|
||||
min: -3,
|
||||
max: 3,
|
||||
},
|
||||
stereoWidth: {
|
||||
idle: 0.46,
|
||||
active: 0.9,
|
||||
intense: 1.16,
|
||||
intenseThreshold: 0.72,
|
||||
},
|
||||
stylePanOffsetScale: 0.35,
|
||||
lowpass: {
|
||||
midiBase: 48,
|
||||
midiRange: 33,
|
||||
midiLiftHz: 500,
|
||||
expressionBase: 0.58,
|
||||
expressionWeight: 0.32,
|
||||
},
|
||||
styleRotationBars: 2,
|
||||
chordBars: 4,
|
||||
supportBarSpacing: 2,
|
||||
supportBarOffset: 1,
|
||||
idleTextureBarSpacing: 2,
|
||||
mediumTextureBarSpacing: 1,
|
||||
textureBeat: 2,
|
||||
highActivityExtraBeat: 3,
|
||||
highActivityExtraThreshold: 0.45,
|
||||
noteScorePreferenceWeight: 1.8,
|
||||
noteScoreRegisterWeight: 0.28,
|
||||
noteScoreChordToneWeight: 0.75,
|
||||
noteScoreRepeatPenalty: 3.2,
|
||||
gestureAccentMinIntervalSeconds: 2.5,
|
||||
strokeAccentMinSteps: 12,
|
||||
strokeAccentThreshold: 0.58,
|
||||
maxBrushPhraseLayers: 3,
|
||||
maxBrushStreamNotesPerBar: 7,
|
||||
brushLayerBaseSeconds: 5.5,
|
||||
brushLayerEnergySeconds: 2.5,
|
||||
brushLayerMinIntensity: 0.12,
|
||||
brushStreamIdleIntervalBeats: 2,
|
||||
brushStreamActiveIntervalBeats: 1,
|
||||
brushStreamIntenseIntervalBeats: 0.75,
|
||||
brushMotifMaxSteps: 8,
|
||||
brushMotifCanonDelaySeconds: 0.055,
|
||||
padDurationBarScale: 0.82,
|
||||
};
|
||||
|
||||
export const styleVoices: [
|
||||
GardenAudioStyleVoice,
|
||||
GardenAudioStyleVoice,
|
||||
GardenAudioStyleVoice,
|
||||
] = [
|
||||
{
|
||||
scaleDegreeOffset: 0,
|
||||
velocityMultiplier: 0.92,
|
||||
panOffset: -0.14,
|
||||
},
|
||||
{
|
||||
scaleDegreeOffset: 1,
|
||||
velocityMultiplier: 1,
|
||||
panOffset: 0,
|
||||
},
|
||||
{
|
||||
scaleDegreeOffset: 2,
|
||||
velocityMultiplier: 0.86,
|
||||
panOffset: 0.14,
|
||||
},
|
||||
];
|
||||
1349
src/audio/generative-piano.ts
Normal file
64
src/audio/noise-burst-player.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { clamp } from '../utils/math';
|
||||
import type { GardenAudioGraph } from './garden-audio-graph';
|
||||
import type { NoiseBurst } from './garden-audio-types';
|
||||
|
||||
const noiseBurstTuning = {
|
||||
attackSeconds: 0.004,
|
||||
filterQ: 1.4,
|
||||
offsetRandomSeconds: 0.4,
|
||||
scheduleAheadSeconds: 0.002,
|
||||
silentGain: 0.0001,
|
||||
filterType: 'bandpass',
|
||||
} as const;
|
||||
|
||||
export class NoiseBurstPlayer {
|
||||
public constructor(private readonly graph: GardenAudioGraph) {}
|
||||
|
||||
public play({ startTime, durationSeconds, gain, filterHz, pan }: NoiseBurst): void {
|
||||
const { context, noiseBus, noiseBuffer } = this.graph;
|
||||
if (!context || !noiseBus || !noiseBuffer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scheduledStart = Math.max(
|
||||
context.currentTime + noiseBurstTuning.scheduleAheadSeconds,
|
||||
startTime
|
||||
);
|
||||
const source = context.createBufferSource();
|
||||
const filter = context.createBiquadFilter();
|
||||
const envelope = context.createGain();
|
||||
const panner = context.createStereoPanner();
|
||||
const stopAt = scheduledStart + durationSeconds;
|
||||
|
||||
source.buffer = noiseBuffer;
|
||||
filter.type = noiseBurstTuning.filterType;
|
||||
filter.frequency.setValueAtTime(filterHz, scheduledStart);
|
||||
filter.Q.value = noiseBurstTuning.filterQ;
|
||||
envelope.gain.setValueAtTime(noiseBurstTuning.silentGain, scheduledStart);
|
||||
envelope.gain.exponentialRampToValueAtTime(
|
||||
Math.max(noiseBurstTuning.silentGain, gain),
|
||||
scheduledStart + noiseBurstTuning.attackSeconds
|
||||
);
|
||||
envelope.gain.exponentialRampToValueAtTime(noiseBurstTuning.silentGain, stopAt);
|
||||
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
|
||||
source.connect(filter);
|
||||
filter.connect(envelope);
|
||||
envelope.connect(panner);
|
||||
panner.connect(noiseBus);
|
||||
const maxOffsetSeconds = Math.max(0, noiseBuffer.duration - durationSeconds);
|
||||
const offsetSeconds =
|
||||
Math.random() * Math.min(noiseBurstTuning.offsetRandomSeconds, maxOffsetSeconds);
|
||||
source.start(scheduledStart, offsetSeconds);
|
||||
source.stop(stopAt);
|
||||
source.addEventListener(
|
||||
'ended',
|
||||
() => {
|
||||
source.disconnect();
|
||||
filter.disconnect();
|
||||
envelope.disconnect();
|
||||
panner.disconnect();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
264
src/audio/piano-sampler.ts
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
import { clamp, clamp01 } from '../utils/math';
|
||||
import type { GardenAudioConfig } from './garden-audio-config';
|
||||
import type { GardenAudioGraph } from './garden-audio-graph';
|
||||
import { PITCH_SEMITONES_PER_OCTAVE } from './garden-audio-music';
|
||||
import type { LoadedPianoSample, PianoNote } from './garden-audio-types';
|
||||
import { getLoadedPianoSamples, loadPianoSamples } from './piano-samples';
|
||||
|
||||
export const PIANO_SCHEDULE_AHEAD_SECONDS = 0.002;
|
||||
|
||||
interface ActivePianoVoice {
|
||||
gain: GainNode;
|
||||
source: AudioScheduledSourceNode;
|
||||
stopAt: number;
|
||||
}
|
||||
|
||||
const pianoSamplerTuning = {
|
||||
filterType: 'lowpass',
|
||||
filterQ: 0.7,
|
||||
minDurationSeconds: 0.08,
|
||||
minFadeSeconds: 0.08,
|
||||
minGain: 0.0001,
|
||||
releaseTimeConstantCount: 5,
|
||||
tailStopExtraSeconds: 0.05,
|
||||
voiceStealFadeSeconds: 0.025,
|
||||
voiceStealStopSeconds: 0.05,
|
||||
} as const;
|
||||
|
||||
export class PianoSampler {
|
||||
private samples: Array<LoadedPianoSample> = [];
|
||||
private activeVoices: Array<ActivePianoVoice> = [];
|
||||
|
||||
public constructor(
|
||||
private readonly config: GardenAudioConfig,
|
||||
private readonly graph: GardenAudioGraph
|
||||
) {}
|
||||
|
||||
public load(context: BaseAudioContext): Promise<void> {
|
||||
if (this.samples.length > 0) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const loadedSamples = getLoadedPianoSamples();
|
||||
if (loadedSamples) {
|
||||
this.setSamples(loadedSamples);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return loadPianoSamples(context).then((samples) => {
|
||||
this.setSamples(samples);
|
||||
});
|
||||
}
|
||||
|
||||
public play({
|
||||
midi,
|
||||
velocity,
|
||||
startTime,
|
||||
durationSeconds,
|
||||
pan,
|
||||
role,
|
||||
delaySend = 0,
|
||||
lowpassHz = this.config.piano.lowpassHz,
|
||||
sustainSeconds: profileSustainSeconds = this.config.piano.sustainSeconds,
|
||||
}: PianoNote): void {
|
||||
const { context } = this.graph;
|
||||
const eventBus = this.graph.getPianoBus(role);
|
||||
if (!context || !eventBus) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sample = this.findNearestSample(midi);
|
||||
if (!sample) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scheduledStart = Math.max(
|
||||
context.currentTime + PIANO_SCHEDULE_AHEAD_SECONDS,
|
||||
startTime
|
||||
);
|
||||
const noteVelocity = clamp01(velocity);
|
||||
const noteGainValue = this.computeNoteGain(noteVelocity);
|
||||
const sustainSeconds =
|
||||
profileSustainSeconds *
|
||||
(this.config.piano.sustainBase +
|
||||
noteVelocity * this.config.piano.sustainVelocityRange);
|
||||
const sustainAt =
|
||||
scheduledStart + Math.max(pianoSamplerTuning.minDurationSeconds, durationSeconds);
|
||||
const releaseAt = sustainAt + sustainSeconds;
|
||||
const stopAt =
|
||||
releaseAt +
|
||||
this.config.piano.releaseSeconds * pianoSamplerTuning.releaseTimeConstantCount;
|
||||
const source = context.createBufferSource();
|
||||
|
||||
source.buffer = sample.buffer;
|
||||
source.playbackRate.setValueAtTime(
|
||||
Math.pow(2, (midi - sample.midi) / PITCH_SEMITONES_PER_OCTAVE),
|
||||
scheduledStart
|
||||
);
|
||||
|
||||
this.scheduleVoice({
|
||||
source,
|
||||
scheduledStart,
|
||||
stopAt,
|
||||
pan,
|
||||
lowpassHz,
|
||||
delaySend,
|
||||
eventBus,
|
||||
configureGainEnvelope: (gain) => {
|
||||
gain.gain.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart);
|
||||
gain.gain.exponentialRampToValueAtTime(
|
||||
noteGainValue,
|
||||
scheduledStart + this.config.piano.gainAttackSeconds
|
||||
);
|
||||
gain.gain.setTargetAtTime(
|
||||
Math.max(
|
||||
pianoSamplerTuning.minGain,
|
||||
noteGainValue * this.config.piano.sustainLevel
|
||||
),
|
||||
sustainAt,
|
||||
Math.max(
|
||||
pianoSamplerTuning.minFadeSeconds,
|
||||
sustainSeconds * this.config.piano.sustainBase
|
||||
)
|
||||
);
|
||||
gain.gain.setTargetAtTime(
|
||||
pianoSamplerTuning.minGain,
|
||||
releaseAt,
|
||||
this.config.piano.releaseSeconds
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public stopAll(): void {
|
||||
const context = this.graph.context;
|
||||
if (!context) {
|
||||
this.activeVoices = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const now = context.currentTime;
|
||||
|
||||
this.activeVoices.forEach((voice) => {
|
||||
this.stopVoice(voice, now);
|
||||
});
|
||||
this.activeVoices = [];
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.samples = [];
|
||||
this.activeVoices = [];
|
||||
}
|
||||
|
||||
private scheduleVoice({
|
||||
source,
|
||||
scheduledStart,
|
||||
stopAt,
|
||||
pan,
|
||||
lowpassHz,
|
||||
delaySend,
|
||||
eventBus,
|
||||
configureGainEnvelope,
|
||||
}: {
|
||||
source: AudioScheduledSourceNode;
|
||||
scheduledStart: number;
|
||||
stopAt: number;
|
||||
pan: number;
|
||||
lowpassHz: number;
|
||||
delaySend: number;
|
||||
eventBus: GainNode;
|
||||
configureGainEnvelope: (gain: GainNode) => void;
|
||||
}): void {
|
||||
const { context, delayInput } = this.graph;
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filter = context.createBiquadFilter();
|
||||
const gain = context.createGain();
|
||||
const panner = context.createStereoPanner();
|
||||
let sendGain: GainNode | null = null;
|
||||
|
||||
this.trimActiveVoices(scheduledStart);
|
||||
while (this.activeVoices.length >= this.config.piano.maxVoices) {
|
||||
const oldest = this.activeVoices.shift();
|
||||
if (!oldest) {
|
||||
break;
|
||||
}
|
||||
this.stopVoice(oldest, scheduledStart);
|
||||
}
|
||||
|
||||
filter.type = pianoSamplerTuning.filterType;
|
||||
filter.frequency.setValueAtTime(
|
||||
clamp(lowpassHz, this.config.piano.lowpassMinHz, this.config.piano.lowpassMaxHz),
|
||||
scheduledStart
|
||||
);
|
||||
filter.Q.value = pianoSamplerTuning.filterQ;
|
||||
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
|
||||
configureGainEnvelope(gain);
|
||||
|
||||
source.connect(filter);
|
||||
filter.connect(gain);
|
||||
gain.connect(panner);
|
||||
panner.connect(eventBus);
|
||||
|
||||
if (delayInput && delaySend > 0) {
|
||||
sendGain = context.createGain();
|
||||
sendGain.gain.value = delaySend;
|
||||
panner.connect(sendGain);
|
||||
sendGain.connect(delayInput);
|
||||
}
|
||||
|
||||
source.start(scheduledStart);
|
||||
source.stop(stopAt + pianoSamplerTuning.tailStopExtraSeconds);
|
||||
this.activeVoices.push({ gain, source, stopAt });
|
||||
|
||||
source.addEventListener(
|
||||
'ended',
|
||||
() => {
|
||||
source.disconnect();
|
||||
filter.disconnect();
|
||||
gain.disconnect();
|
||||
panner.disconnect();
|
||||
sendGain?.disconnect();
|
||||
this.activeVoices = this.activeVoices.filter((voice) => voice.gain !== gain);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
}
|
||||
|
||||
private computeNoteGain(velocity: number): number {
|
||||
return Math.max(pianoSamplerTuning.minGain, this.config.piano.gain * velocity);
|
||||
}
|
||||
|
||||
private findNearestSample(midi: number): LoadedPianoSample | null {
|
||||
if (this.samples.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.samples.reduce((nearest, sample) =>
|
||||
Math.abs(sample.midi - midi) < Math.abs(nearest.midi - midi) ? sample : nearest
|
||||
);
|
||||
}
|
||||
|
||||
private trimActiveVoices(now: number): void {
|
||||
this.activeVoices = this.activeVoices.filter((voice) => voice.stopAt > now);
|
||||
}
|
||||
|
||||
private stopVoice(voice: ActivePianoVoice, now: number): void {
|
||||
const stopAt = now + pianoSamplerTuning.voiceStealStopSeconds;
|
||||
|
||||
voice.gain.gain.cancelScheduledValues(now);
|
||||
voice.gain.gain.setTargetAtTime(
|
||||
pianoSamplerTuning.minGain,
|
||||
now,
|
||||
pianoSamplerTuning.voiceStealFadeSeconds
|
||||
);
|
||||
voice.stopAt = stopAt;
|
||||
voice.source.stop(stopAt);
|
||||
}
|
||||
|
||||
private setSamples(samples: Array<LoadedPianoSample>): void {
|
||||
this.samples = samples.slice().sort((a, b) => a.midi - b.midi);
|
||||
}
|
||||
}
|
||||
271
src/audio/piano-samples.ts
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
import type { LoadedPianoSample } from './garden-audio-types';
|
||||
import a0SampleUrl from './samples/A0v12.m4a?url&no-inline';
|
||||
import a1SampleUrl from './samples/A1v12.m4a?url&no-inline';
|
||||
import a2SampleUrl from './samples/A2v12.m4a?url&no-inline';
|
||||
import a3SampleUrl from './samples/A3v12.m4a?url&no-inline';
|
||||
import a4SampleUrl from './samples/A4v12.m4a?url&no-inline';
|
||||
import a5SampleUrl from './samples/A5v12.m4a?url&no-inline';
|
||||
import a6SampleUrl from './samples/A6v12.m4a?url&no-inline';
|
||||
import a7SampleUrl from './samples/A7v12.m4a?url&no-inline';
|
||||
import c1SampleUrl from './samples/C1v12.m4a?url&no-inline';
|
||||
import c2SampleUrl from './samples/C2v12.m4a?url&no-inline';
|
||||
import c3SampleUrl from './samples/C3v12.m4a?url&no-inline';
|
||||
import c4SampleUrl from './samples/C4v12.m4a?url&no-inline';
|
||||
import c5SampleUrl from './samples/C5v12.m4a?url&no-inline';
|
||||
import c6SampleUrl from './samples/C6v12.m4a?url&no-inline';
|
||||
import c7SampleUrl from './samples/C7v12.m4a?url&no-inline';
|
||||
import c8SampleUrl from './samples/C8v12.m4a?url&no-inline';
|
||||
import dSharp1SampleUrl from './samples/Dsharp1v12.m4a?url&no-inline';
|
||||
import dSharp2SampleUrl from './samples/Dsharp2v12.m4a?url&no-inline';
|
||||
import dSharp3SampleUrl from './samples/Dsharp3v12.m4a?url&no-inline';
|
||||
import dSharp4SampleUrl from './samples/Dsharp4v12.m4a?url&no-inline';
|
||||
import dSharp5SampleUrl from './samples/Dsharp5v12.m4a?url&no-inline';
|
||||
import dSharp6SampleUrl from './samples/Dsharp6v12.m4a?url&no-inline';
|
||||
import dSharp7SampleUrl from './samples/Dsharp7v12.m4a?url&no-inline';
|
||||
import fSharp1SampleUrl from './samples/Fsharp1v12.m4a?url&no-inline';
|
||||
import fSharp2SampleUrl from './samples/Fsharp2v12.m4a?url&no-inline';
|
||||
import fSharp3SampleUrl from './samples/Fsharp3v12.m4a?url&no-inline';
|
||||
import fSharp4SampleUrl from './samples/Fsharp4v12.m4a?url&no-inline';
|
||||
import fSharp5SampleUrl from './samples/Fsharp5v12.m4a?url&no-inline';
|
||||
import fSharp6SampleUrl from './samples/Fsharp6v12.m4a?url&no-inline';
|
||||
import fSharp7SampleUrl from './samples/Fsharp7v12.m4a?url&no-inline';
|
||||
|
||||
interface PianoSampleDefinition {
|
||||
note: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface PianoSampleLoadProgress {
|
||||
failedCount: number;
|
||||
loadedCount: number;
|
||||
settledCount: number;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
const pianoSampleDefinitions: Array<PianoSampleDefinition> = [
|
||||
{ url: a0SampleUrl, note: 'A0' },
|
||||
{ url: c1SampleUrl, note: 'C1' },
|
||||
{ url: dSharp1SampleUrl, note: 'Dsharp1' },
|
||||
{ url: fSharp1SampleUrl, note: 'Fsharp1' },
|
||||
{ url: a1SampleUrl, note: 'A1' },
|
||||
{ url: c2SampleUrl, note: 'C2' },
|
||||
{ url: dSharp2SampleUrl, note: 'Dsharp2' },
|
||||
{ url: fSharp2SampleUrl, note: 'Fsharp2' },
|
||||
{ url: a2SampleUrl, note: 'A2' },
|
||||
{ url: c3SampleUrl, note: 'C3' },
|
||||
{ url: dSharp3SampleUrl, note: 'Dsharp3' },
|
||||
{ url: fSharp3SampleUrl, note: 'Fsharp3' },
|
||||
{ url: a3SampleUrl, note: 'A3' },
|
||||
{ url: c4SampleUrl, note: 'C4' },
|
||||
{ url: dSharp4SampleUrl, note: 'Dsharp4' },
|
||||
{ url: fSharp4SampleUrl, note: 'Fsharp4' },
|
||||
{ url: a4SampleUrl, note: 'A4' },
|
||||
{ url: c5SampleUrl, note: 'C5' },
|
||||
{ url: dSharp5SampleUrl, note: 'Dsharp5' },
|
||||
{ url: fSharp5SampleUrl, note: 'Fsharp5' },
|
||||
{ url: a5SampleUrl, note: 'A5' },
|
||||
{ url: c6SampleUrl, note: 'C6' },
|
||||
{ url: dSharp6SampleUrl, note: 'Dsharp6' },
|
||||
{ url: fSharp6SampleUrl, note: 'Fsharp6' },
|
||||
{ url: a6SampleUrl, note: 'A6' },
|
||||
{ url: c7SampleUrl, note: 'C7' },
|
||||
{ url: dSharp7SampleUrl, note: 'Dsharp7' },
|
||||
{ url: fSharp7SampleUrl, note: 'Fsharp7' },
|
||||
{ url: a7SampleUrl, note: 'A7' },
|
||||
{ url: c8SampleUrl, note: 'C8' },
|
||||
];
|
||||
|
||||
let loadedPianoSamples: Array<LoadedPianoSample> | null = null;
|
||||
let pianoSampleLoadPromise: Promise<Array<LoadedPianoSample>> | null = null;
|
||||
let lastPianoSampleProgress: PianoSampleLoadProgress | null = null;
|
||||
const pianoSampleProgressListeners = new Set<
|
||||
(progress: PianoSampleLoadProgress) => void
|
||||
>();
|
||||
|
||||
const sampleLoadTuning = {
|
||||
concurrency: 4,
|
||||
sampleTimeoutMs: 15_000,
|
||||
};
|
||||
|
||||
export const preloadPianoSamples = (
|
||||
onProgress?: (progress: PianoSampleLoadProgress) => void
|
||||
): Promise<Array<LoadedPianoSample>> => {
|
||||
const OfflineAudioContextConstructor = globalThis.OfflineAudioContext;
|
||||
|
||||
if (!OfflineAudioContextConstructor) {
|
||||
return Promise.reject(
|
||||
new Error('OfflineAudioContext is required to preload piano samples.')
|
||||
);
|
||||
}
|
||||
|
||||
// Decoding ignores these, but the constructor demands real numbers.
|
||||
const decodeContext = new OfflineAudioContextConstructor(1, 1, 48_000);
|
||||
return loadPianoSamples(decodeContext, onProgress);
|
||||
};
|
||||
|
||||
export const loadPianoSamples = (
|
||||
decodeContext: BaseAudioContext,
|
||||
onProgress?: (progress: PianoSampleLoadProgress) => void
|
||||
): Promise<Array<LoadedPianoSample>> => {
|
||||
const unsubscribeProgress = subscribeToPianoSampleProgress(onProgress);
|
||||
|
||||
if (loadedPianoSamples) {
|
||||
emitPianoSampleProgress({
|
||||
failedCount: 0,
|
||||
loadedCount: loadedPianoSamples.length,
|
||||
settledCount: loadedPianoSamples.length,
|
||||
totalCount: pianoSampleDefinitions.length,
|
||||
});
|
||||
unsubscribeProgress();
|
||||
return Promise.resolve([...loadedPianoSamples]);
|
||||
}
|
||||
|
||||
if (pianoSampleLoadPromise) {
|
||||
return pianoSampleLoadPromise.finally(unsubscribeProgress);
|
||||
}
|
||||
|
||||
let loadedCount = 0;
|
||||
let failedCount = 0;
|
||||
let settledCount = 0;
|
||||
const totalCount = pianoSampleDefinitions.length;
|
||||
emitPianoSampleProgress({ failedCount, loadedCount, settledCount, totalCount });
|
||||
|
||||
pianoSampleLoadPromise = loadPianoSampleBatch(
|
||||
pianoSampleDefinitions,
|
||||
async (sample) => {
|
||||
try {
|
||||
const loadedSample = await withTimeout(
|
||||
(signal) => loadPianoSample(decodeContext, sample, signal),
|
||||
sampleLoadTuning.sampleTimeoutMs
|
||||
);
|
||||
loadedCount += 1;
|
||||
return loadedSample;
|
||||
} catch (error) {
|
||||
failedCount += 1;
|
||||
throw error;
|
||||
} finally {
|
||||
settledCount += 1;
|
||||
emitPianoSampleProgress({ failedCount, loadedCount, settledCount, totalCount });
|
||||
}
|
||||
}
|
||||
)
|
||||
.then(
|
||||
(samples) => {
|
||||
loadedPianoSamples = samples.sort((a, b) => a.midi - b.midi);
|
||||
if (loadedPianoSamples.length !== pianoSampleDefinitions.length) {
|
||||
throw new Error(
|
||||
`Loaded ${loadedPianoSamples.length}/${pianoSampleDefinitions.length} piano samples.`
|
||||
);
|
||||
}
|
||||
return [...loadedPianoSamples];
|
||||
},
|
||||
(error: unknown) => {
|
||||
pianoSampleLoadPromise = null;
|
||||
pianoSampleProgressListeners.clear();
|
||||
throw error;
|
||||
}
|
||||
)
|
||||
.finally(unsubscribeProgress);
|
||||
|
||||
return pianoSampleLoadPromise;
|
||||
};
|
||||
|
||||
export const getLoadedPianoSamples = (): Array<LoadedPianoSample> | null =>
|
||||
loadedPianoSamples ? [...loadedPianoSamples] : null;
|
||||
|
||||
const loadPianoSample = async (
|
||||
decodeContext: BaseAudioContext,
|
||||
sample: PianoSampleDefinition,
|
||||
signal: AbortSignal
|
||||
): Promise<LoadedPianoSample> => {
|
||||
const response = await fetch(sample.url, { signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unable to load piano sample ${getPianoSamplePath(sample)}`);
|
||||
}
|
||||
|
||||
const audioData = await response.arrayBuffer();
|
||||
const buffer = await decodeContext.decodeAudioData(audioData);
|
||||
return { midi: getMidiForPianoSample(sample), buffer };
|
||||
};
|
||||
|
||||
const loadPianoSampleBatch = async (
|
||||
samples: Array<PianoSampleDefinition>,
|
||||
loadSample: (sample: PianoSampleDefinition) => Promise<LoadedPianoSample>
|
||||
): Promise<Array<LoadedPianoSample>> => {
|
||||
const results: Array<LoadedPianoSample> = [];
|
||||
|
||||
for (let index = 0; index < samples.length; index += sampleLoadTuning.concurrency) {
|
||||
const batch = samples.slice(index, index + sampleLoadTuning.concurrency);
|
||||
const batchResults = await Promise.all(batch.map((sample) => loadSample(sample)));
|
||||
results.push(...batchResults);
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
const withTimeout = <T>(
|
||||
operation: (signal: AbortSignal) => Promise<T>,
|
||||
timeoutMs: number
|
||||
): Promise<T> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const controller = new AbortController();
|
||||
const timeout = globalThis.setTimeout(() => {
|
||||
controller.abort();
|
||||
reject(new Error('Timed out while loading a piano sample.'));
|
||||
}, timeoutMs);
|
||||
|
||||
operation(controller.signal).then(
|
||||
(value) => {
|
||||
globalThis.clearTimeout(timeout);
|
||||
resolve(value);
|
||||
},
|
||||
(error: unknown) => {
|
||||
globalThis.clearTimeout(timeout);
|
||||
reject(error);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const subscribeToPianoSampleProgress = (
|
||||
onProgress: ((progress: PianoSampleLoadProgress) => void) | undefined
|
||||
): (() => void) => {
|
||||
if (!onProgress) {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
pianoSampleProgressListeners.add(onProgress);
|
||||
if (lastPianoSampleProgress) {
|
||||
onProgress(lastPianoSampleProgress);
|
||||
}
|
||||
return () => {
|
||||
pianoSampleProgressListeners.delete(onProgress);
|
||||
};
|
||||
};
|
||||
|
||||
const emitPianoSampleProgress = (progress: PianoSampleLoadProgress): void => {
|
||||
lastPianoSampleProgress = progress;
|
||||
pianoSampleProgressListeners.forEach((listener) => listener(progress));
|
||||
};
|
||||
|
||||
const getPianoSamplePath = (sample: PianoSampleDefinition): string =>
|
||||
`./samples/${sample.note}v12.m4a`;
|
||||
|
||||
const getMidiForPianoSample = (sample: PianoSampleDefinition): number => {
|
||||
const match = /^(?<name>[A-G])(?<accidental>sharp)?(?<octave>\d+)$/.exec(sample.note);
|
||||
if (!match?.groups) {
|
||||
throw new Error(`Invalid piano sample note ${sample.note}`);
|
||||
}
|
||||
|
||||
const semitoneByName: Record<string, number> = {
|
||||
C: 0,
|
||||
D: 2,
|
||||
E: 4,
|
||||
F: 5,
|
||||
G: 7,
|
||||
A: 9,
|
||||
B: 11,
|
||||
};
|
||||
const octave = Number(match.groups.octave);
|
||||
const semitone = semitoneByName[match.groups.name] + (match.groups.accidental ? 1 : 0);
|
||||
return (octave + 1) * 12 + semitone;
|
||||
};
|
||||
BIN
src/audio/samples/A0v12.m4a
Normal file
BIN
src/audio/samples/A1v12.m4a
Normal file
BIN
src/audio/samples/A2v12.m4a
Normal file
BIN
src/audio/samples/A3v12.m4a
Normal file
BIN
src/audio/samples/A4v12.m4a
Normal file
BIN
src/audio/samples/A5v12.m4a
Normal file
BIN
src/audio/samples/A6v12.m4a
Normal file
BIN
src/audio/samples/A7v12.m4a
Normal file
BIN
src/audio/samples/C1v12.m4a
Normal file
BIN
src/audio/samples/C2v12.m4a
Normal file
BIN
src/audio/samples/C3v12.m4a
Normal file
BIN
src/audio/samples/C4v12.m4a
Normal file
BIN
src/audio/samples/C5v12.m4a
Normal file
BIN
src/audio/samples/C6v12.m4a
Normal file
BIN
src/audio/samples/C7v12.m4a
Normal file
BIN
src/audio/samples/C8v12.m4a
Normal file
BIN
src/audio/samples/Dsharp1v12.m4a
Normal file
BIN
src/audio/samples/Dsharp2v12.m4a
Normal file
BIN
src/audio/samples/Dsharp3v12.m4a
Normal file
BIN
src/audio/samples/Dsharp4v12.m4a
Normal file
BIN
src/audio/samples/Dsharp5v12.m4a
Normal file
BIN
src/audio/samples/Dsharp6v12.m4a
Normal file
BIN
src/audio/samples/Dsharp7v12.m4a
Normal file
BIN
src/audio/samples/Fsharp1v12.m4a
Normal file
BIN
src/audio/samples/Fsharp2v12.m4a
Normal file
BIN
src/audio/samples/Fsharp3v12.m4a
Normal file
BIN
src/audio/samples/Fsharp4v12.m4a
Normal file
BIN
src/audio/samples/Fsharp5v12.m4a
Normal file
BIN
src/audio/samples/Fsharp6v12.m4a
Normal file
BIN
src/audio/samples/Fsharp7v12.m4a
Normal file
16
src/audio/samples/README.md
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
Piano samples are Salamander Grand Piano V3 samples by Alexander Holm,
|
||||
transcoded from OGG Vorbis to AAC M4A for iOS browser playback and distributed
|
||||
under CC BY 3.0.
|
||||
|
||||
Source package: @audio-samples/piano-velocity12
|
||||
Source recording: https://archive.org/details/SalamanderGrandPianoV3
|
||||
License: https://creativecommons.org/licenses/by/3.0/
|
||||
|
||||
Checked-in subset: velocity layer `v12`, every minor-third anchor from A0
|
||||
through C8: A, C, Dsharp, and Fsharp for octaves 1-7, plus A0, A7, and C8.
|
||||
The app derives MIDI values from those note names in `piano-samples.ts`.
|
||||
|
||||
Repro notes: start from the matching `v12` OGG files in the source package and
|
||||
transcode each selected sample to AAC/M4A without renaming the note/velocity
|
||||
stem. The expected output filenames are `<note>v12.m4a`, for example
|
||||
`C4v12.m4a`.
|
||||
199
src/config.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import {
|
||||
createGardenAudioConfig,
|
||||
DEFAULT_AUDIO_VOLUME,
|
||||
} from './audio/garden-audio-config';
|
||||
import { defaultSettings } from './config/default-settings';
|
||||
import { runtimeControls } from './config/runtime-controls';
|
||||
import type { GardenAppConfig } from './config/types';
|
||||
import { defaultVibeId, vibePresets } from './config/vibe-presets';
|
||||
|
||||
export {
|
||||
normalizeNumberControlValue,
|
||||
normalizeRuntimeSettings,
|
||||
} from './config/normalize-runtime-settings';
|
||||
|
||||
export type {
|
||||
GardenAppConfig,
|
||||
GardenRuntimeSettings,
|
||||
NumberControlConfig,
|
||||
} from './config/types';
|
||||
|
||||
export const appConfig = {
|
||||
audio: createGardenAudioConfig(),
|
||||
analytics: {
|
||||
autoCapturePageviews: true,
|
||||
domain: 'fleeting.garden',
|
||||
endpoint: 'https://stats.schmelczer.dev/status',
|
||||
logging: import.meta.env.DEV,
|
||||
},
|
||||
deltaTime: {
|
||||
maxDeltaTimeSeconds: 1 / 30,
|
||||
minDeltaTimeSeconds: 1 / 240,
|
||||
},
|
||||
exportSnapshot: {
|
||||
bytesPerPixel: 4,
|
||||
filenameExtension: 'png',
|
||||
filenamePrefix: 'fleeting-garden',
|
||||
filenameSuffix: '-snapshot',
|
||||
mimeType: 'image/png',
|
||||
rowAlignmentBytes: 256,
|
||||
},
|
||||
menuHider: {
|
||||
bottomRevealDistancePx: 96,
|
||||
desktopMediaQuery: '(min-width: 600px) and (hover: hover) and (pointer: fine)',
|
||||
hideDelayMs: 3000,
|
||||
},
|
||||
pipelines: {
|
||||
common: {
|
||||
noiseChannelSeeds: [0, 1, 2, 3],
|
||||
noiseClearValue: { r: 1, g: 1, b: 1, a: 1 },
|
||||
noiseDrawInstanceCount: 1,
|
||||
noiseDrawVertexCount: 3,
|
||||
noiseHashMultiplier: 43758.5453123,
|
||||
noiseHashX: 12.9898,
|
||||
noiseHashY: 78.233,
|
||||
noiseTextureFormat: 'r8unorm',
|
||||
noiseTextureSize: 2048,
|
||||
},
|
||||
brush: {
|
||||
maxLineCount: 240,
|
||||
},
|
||||
diffusion: {
|
||||
minDiffusionRate: 0.000001,
|
||||
},
|
||||
eraser: {
|
||||
maxTextureLineCount: 384,
|
||||
},
|
||||
},
|
||||
defaultSettings,
|
||||
runtimeSettings: {
|
||||
controls: runtimeControls,
|
||||
},
|
||||
simulation: {
|
||||
brushEffectFramesPerSecond: 60,
|
||||
clearColor: { r: 0, g: 0, b: 0, a: 0 },
|
||||
initialAgentCount: 180_000,
|
||||
// How long the source map continues to be diffused after a brush stroke ends.
|
||||
// 600 frames at ~60 FPS ≈ 10 seconds.
|
||||
sourceActiveFramesAfterWrite: 600,
|
||||
intro: {
|
||||
angleJitterRadians: Math.PI * 0.08,
|
||||
angleEaseEnd: 1,
|
||||
angleEaseStart: 0.6,
|
||||
circleMaxSideRatio: 0.46,
|
||||
circleMinSideRatio: 0.32,
|
||||
drawHintDelayMs: 3000,
|
||||
durationSeconds: 4,
|
||||
entryJitterSideRatio: 0.035,
|
||||
fontScaleDown: 0.94,
|
||||
fontFamily: '"Open Sans", sans-serif',
|
||||
initialFontHeightRatio: 0.28,
|
||||
initialFontWidthRatio: 0.19,
|
||||
letterSpacingEm: 0.07,
|
||||
maskAlphaThreshold: 32,
|
||||
maskGradientThreshold: 8,
|
||||
maskMaxPixels: 1_000_000,
|
||||
maskSampleDensity: 540,
|
||||
maxHeightRatio: 0.25,
|
||||
maxWidthRatio: 0.76,
|
||||
minEntryJitterPx: 6,
|
||||
minFontSizePx: 18,
|
||||
minTargetJitterPx: 1,
|
||||
pathEasing: 'easeOutQuad',
|
||||
pathProgressEpsilon: 0.001,
|
||||
radialJitterRatio: 0.35,
|
||||
radialStartEpsilon: 0.001,
|
||||
resizeMinimumRemainingSeconds: 1.4,
|
||||
resizeSettleMs: 120,
|
||||
targetDelayDistanceMultiplier: 0.12,
|
||||
targetDelayMax: 0.22,
|
||||
targetDelayRandomMultiplier: 0.06,
|
||||
targetJitterSideRatio: 0.0035,
|
||||
title: 'Fleeting',
|
||||
titleColorCutLetters: [2, 5],
|
||||
titleRadiusMultiplier: 1.55,
|
||||
titleStrokeWidthMinPx: 6,
|
||||
titleStrokeWidthRatio: 0.11,
|
||||
verticalAnchor: 0.47,
|
||||
},
|
||||
introMoveSpeed: 280,
|
||||
stroke: {
|
||||
densityMultiplier: 110,
|
||||
maxAgentCount: 2_400,
|
||||
},
|
||||
},
|
||||
storage: {
|
||||
audioMutedKey: 'fleeting-garden:audio-muted',
|
||||
audioVolumeKey: 'fleeting-garden:audio-volume',
|
||||
vibeKey: 'fleeting-garden:vibe',
|
||||
},
|
||||
toolbar: {
|
||||
eraser: {
|
||||
controlScaleMax: 1.34,
|
||||
controlScaleMin: 0.74,
|
||||
default: 96,
|
||||
max: 480,
|
||||
min: 24,
|
||||
step: 1,
|
||||
},
|
||||
mirror: {
|
||||
default: 8,
|
||||
fallbackSegmentName: 'slices',
|
||||
max: 12,
|
||||
min: 1,
|
||||
names: {
|
||||
2: 'halves',
|
||||
3: 'thirds',
|
||||
4: 'quarters',
|
||||
5: 'fifths',
|
||||
6: 'sixths',
|
||||
7: 'sevenths',
|
||||
8: 'eighths',
|
||||
9: 'ninths',
|
||||
10: 'tenths',
|
||||
11: 'elevenths',
|
||||
12: 'twelfths',
|
||||
},
|
||||
offLabel: 'Mirror off',
|
||||
step: 1,
|
||||
},
|
||||
contrast: {
|
||||
backgroundOpacityMax: 0.82,
|
||||
brightLuminanceThreshold: 0.32,
|
||||
brightWeight: 0.65,
|
||||
bytesPerSample: 4,
|
||||
contrastOffset: 0.05,
|
||||
linearChannelBreakpoint: 0.03928,
|
||||
linearChannelDivisor: 12.92,
|
||||
linearChannelGamma: 2.4,
|
||||
linearChannelOffset: 0.055,
|
||||
linearChannelScale: 1.055,
|
||||
lowContrastThreshold: 3,
|
||||
lowContrastWeight: 1.8,
|
||||
luminanceBase: 0.11,
|
||||
luminanceBlueWeight: 0.0722,
|
||||
luminanceGreenWeight: 0.7152,
|
||||
luminanceRange: 0.28,
|
||||
luminanceRedWeight: 0.2126,
|
||||
sampleColumns: 13,
|
||||
sampleIntervalMs: 300,
|
||||
sampleRows: 7,
|
||||
whiteContrastNumerator: 1.05,
|
||||
},
|
||||
volume: {
|
||||
default: DEFAULT_AUDIO_VOLUME,
|
||||
max: 1,
|
||||
min: 0,
|
||||
step: 0.01,
|
||||
},
|
||||
},
|
||||
tuningPane: {
|
||||
showFpsOverlay: import.meta.env.DEV,
|
||||
startHidden: true,
|
||||
title: 'Garden Settings',
|
||||
},
|
||||
vibes: {
|
||||
defaultVibeId,
|
||||
presets: vibePresets,
|
||||
},
|
||||
} satisfies GardenAppConfig;
|
||||
27
src/config/brush-size.test.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS,
|
||||
getBrushRenderQualityScale,
|
||||
getRenderQualityBrushSize,
|
||||
} from './brush-size';
|
||||
|
||||
describe('render-quality brush sizing', () => {
|
||||
it('keeps brush sizes unchanged at the 7.3 MP baseline', () => {
|
||||
expect(
|
||||
getRenderQualityBrushSize(21, BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS)
|
||||
).toBe(21);
|
||||
});
|
||||
|
||||
it('scales linear brush size with the square root of render area', () => {
|
||||
const doubledLinearQuality = BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS * 4;
|
||||
|
||||
expect(getBrushRenderQualityScale(doubledLinearQuality)).toBe(2);
|
||||
expect(getRenderQualityBrushSize(9.75, doubledLinearQuality)).toBe(19.5);
|
||||
});
|
||||
|
||||
it('falls back to baseline scaling for invalid render areas', () => {
|
||||
expect(getBrushRenderQualityScale(0)).toBe(1);
|
||||
expect(getRenderQualityBrushSize(6.5, Number.NaN)).toBe(6.5);
|
||||
});
|
||||
});
|
||||
19
src/config/brush-size.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
export const BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS = 7.3;
|
||||
|
||||
const getSafeRenderAreaMegapixels = (renderAreaMegapixels: number): number =>
|
||||
Number.isFinite(renderAreaMegapixels) && renderAreaMegapixels > 0
|
||||
? renderAreaMegapixels
|
||||
: BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS;
|
||||
|
||||
export const getBrushRenderQualityScale = (renderAreaMegapixels: number): number =>
|
||||
Math.sqrt(
|
||||
getSafeRenderAreaMegapixels(renderAreaMegapixels) /
|
||||
BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS
|
||||
);
|
||||
|
||||
export const getRenderQualityBrushSize = (
|
||||
brushSize: number,
|
||||
renderAreaMegapixels: number
|
||||
): number =>
|
||||
Math.max(0, Number.isFinite(brushSize) ? brushSize : 0) *
|
||||
getBrushRenderQualityScale(renderAreaMegapixels);
|
||||
14
src/config/color-interactions.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import type { NumberControlConfig } from './types';
|
||||
|
||||
export const colorInteractionControl = (label: string): NumberControlConfig => ({
|
||||
folder: 'Color Reactions',
|
||||
label,
|
||||
min: -1,
|
||||
max: 1,
|
||||
step: 1,
|
||||
options: {
|
||||
'Move Toward': 1,
|
||||
Ignore: 0,
|
||||
'Move Away': -1,
|
||||
},
|
||||
});
|
||||
74
src/config/default-settings.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS } from './runtime-setting-bounds';
|
||||
import type { GardenAppConfig } from './types';
|
||||
|
||||
// Mirrors the historical render-scale cap so the default render area stays
|
||||
// roughly equivalent to native rendering on high-DPR phones without the
|
||||
// pipeline applying its own clamp. The slider can override freely.
|
||||
const DEFAULT_DEVICE_PIXEL_RATIO_CAP = 2;
|
||||
|
||||
const computeDefaultInternalRenderAreaMegapixels = (): number => {
|
||||
const rawDpr =
|
||||
typeof window !== 'undefined' && Number.isFinite(window.devicePixelRatio)
|
||||
? window.devicePixelRatio
|
||||
: 1;
|
||||
const dpr = Math.min(Math.max(rawDpr, 1), DEFAULT_DEVICE_PIXEL_RATIO_CAP);
|
||||
const cssWidth = typeof window !== 'undefined' ? window.innerWidth : 1920;
|
||||
const cssHeight = typeof window !== 'undefined' ? window.innerHeight : 1080;
|
||||
const cssMegapixels = (Math.max(cssWidth, 1) * Math.max(cssHeight, 1)) / 1_000_000;
|
||||
return Math.min(
|
||||
INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS.max,
|
||||
Math.max(INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS.min, dpr * dpr * cssMegapixels)
|
||||
);
|
||||
};
|
||||
|
||||
export const defaultSettings: GardenAppConfig['defaultSettings'] = {
|
||||
selectedColorIndex: 0,
|
||||
|
||||
introNearDistanceMin: 28,
|
||||
introNearDistanceInner: 4,
|
||||
introNearSensorOffsetMultiplier: 0.75,
|
||||
introTargetAngleBlend: 0.2,
|
||||
introProgressCutoff: 0.999,
|
||||
introTurnRateMultiplier: 3.4,
|
||||
introRandomTurnMultiplier: 0.18,
|
||||
introStepStopDistance: 0.5,
|
||||
randomTimeScale: 0.34816,
|
||||
|
||||
diffusionRateTrails: 0.22,
|
||||
decayRateBrush: 18,
|
||||
diffusionDecayRateDivisor: 1000,
|
||||
diffusionNeighborDivisor: 8,
|
||||
brushDecayAlphaOffset: 1.001,
|
||||
brushEffectDuration: 8,
|
||||
|
||||
brushCurveResolution: 12,
|
||||
brushCurveMinBrushRadius: 1,
|
||||
brushCurveMinSegmentSpacing: 4,
|
||||
brushCurveMirrorResolutionExponent: 0.5,
|
||||
brushCurveSegmentBrushRadiusRatio: 0.65,
|
||||
brushSmoothingMinSampleDistance: 0.5,
|
||||
|
||||
brushAlpha: 1,
|
||||
brushDiscardThreshold: 0.02,
|
||||
brushGrainNoiseScale: 22,
|
||||
brushGrainNoiseOffsetX: 0.31,
|
||||
brushGrainNoiseOffsetY: 0.67,
|
||||
brushGrainMinStrength: 0.45,
|
||||
brushGrainMaxStrength: 1,
|
||||
|
||||
eraserClearAlpha: 0,
|
||||
eraserClearBlue: 0,
|
||||
eraserClearGreen: 0,
|
||||
eraserClearRed: 0,
|
||||
eraserLineDistanceEpsilon: 0.0001,
|
||||
eraserMaskAlphaThreshold: 0.5,
|
||||
|
||||
adaptiveCapInitial: 1_000_000,
|
||||
adaptiveCapMin: 50_000,
|
||||
internalRenderAreaMegapixels: computeDefaultInternalRenderAreaMegapixels(),
|
||||
maxAgentCount: 1_500_000,
|
||||
|
||||
renderTraceNormalizationFloor: 1,
|
||||
renderBrushColorBase: 1.2,
|
||||
renderBrushColorStrengthMultiplier: 1.6,
|
||||
};
|
||||
65
src/config/normalize-runtime-settings.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
normalizeNumberControlValue,
|
||||
normalizeRuntimeSettings,
|
||||
} from './normalize-runtime-settings';
|
||||
import type { GardenRuntimeSettings } from './types';
|
||||
|
||||
describe('normalizeNumberControlValue', () => {
|
||||
it('clamps and rounds numeric controls', () => {
|
||||
expect(
|
||||
normalizeNumberControlValue(12.6, {
|
||||
folder: 'Test',
|
||||
integer: true,
|
||||
max: 10,
|
||||
min: 0,
|
||||
})
|
||||
).toBe(10);
|
||||
|
||||
expect(
|
||||
normalizeNumberControlValue(Number.NaN, {
|
||||
folder: 'Test',
|
||||
min: 3,
|
||||
})
|
||||
).toBe(3);
|
||||
});
|
||||
|
||||
it('keeps only declared option values', () => {
|
||||
expect(
|
||||
normalizeNumberControlValue(2, {
|
||||
folder: 'Test',
|
||||
options: { off: 0, on: 2 },
|
||||
})
|
||||
).toBe(2);
|
||||
|
||||
expect(
|
||||
normalizeNumberControlValue(3, {
|
||||
folder: 'Test',
|
||||
options: { off: 0, on: 2 },
|
||||
})
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeRuntimeSettings', () => {
|
||||
it('normalizes configured runtime keys and leaves hidden keys alone', () => {
|
||||
const settings = {
|
||||
brushSize: 99,
|
||||
selectedColorIndex: 7,
|
||||
} as GardenRuntimeSettings;
|
||||
|
||||
expect(
|
||||
normalizeRuntimeSettings(settings, {
|
||||
brushSize: {
|
||||
folder: 'Brush',
|
||||
max: 12,
|
||||
min: 1,
|
||||
},
|
||||
})
|
||||
).toMatchObject({
|
||||
brushSize: 12,
|
||||
selectedColorIndex: 7,
|
||||
});
|
||||
});
|
||||
});
|
||||
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;
|
||||
};
|
||||
148
src/config/runtime-controls.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { colorInteractionControl } from './color-interactions';
|
||||
import { INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS } from './runtime-setting-bounds';
|
||||
import type { GardenAppConfig } from './types';
|
||||
|
||||
const formatPercent = (value: number): string => `${Math.round(value * 100)}%`;
|
||||
const formatRadiansAsDegrees = (value: number): string =>
|
||||
`${Math.round((value * 180) / Math.PI)} deg`;
|
||||
const formatCompactNumber = (value: number): string => {
|
||||
if (value >= 1_000_000) {
|
||||
const millions = value / 1_000_000;
|
||||
return `${Number.isInteger(millions) ? millions : millions.toFixed(1)}M`;
|
||||
}
|
||||
if (value >= 1_000) {
|
||||
return `${Math.round(value / 1_000)}k`;
|
||||
}
|
||||
return `${value}`;
|
||||
};
|
||||
|
||||
export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
||||
color1ToColor1: colorInteractionControl('Color 1 Follows Color 1'),
|
||||
color1ToColor2: colorInteractionControl('Color 1 Follows Color 2'),
|
||||
color1ToColor3: colorInteractionControl('Color 1 Follows Color 3'),
|
||||
color2ToColor1: colorInteractionControl('Color 2 Follows Color 1'),
|
||||
color2ToColor2: colorInteractionControl('Color 2 Follows Color 2'),
|
||||
color2ToColor3: colorInteractionControl('Color 2 Follows Color 3'),
|
||||
color3ToColor1: colorInteractionControl('Color 3 Follows Color 1'),
|
||||
color3ToColor2: colorInteractionControl('Color 3 Follows Color 2'),
|
||||
color3ToColor3: colorInteractionControl('Color 3 Follows Color 3'),
|
||||
|
||||
brushSize: {
|
||||
folder: 'Brush',
|
||||
label: 'Brush Size',
|
||||
min: 1,
|
||||
max: 36,
|
||||
step: 0.25,
|
||||
},
|
||||
spawnPerPixel: {
|
||||
folder: 'Brush',
|
||||
label: 'Density',
|
||||
min: 0.01,
|
||||
max: 0.38,
|
||||
step: 0.001,
|
||||
},
|
||||
strokeAngleJitterRadians: {
|
||||
folder: 'Brush',
|
||||
format: formatRadiansAsDegrees,
|
||||
label: 'Spawn Spread',
|
||||
min: 0,
|
||||
max: Math.PI,
|
||||
step: 0.01,
|
||||
},
|
||||
sensorOffsetDistance: {
|
||||
folder: 'Movement',
|
||||
label: 'Sensor Reach',
|
||||
min: 0,
|
||||
max: 200,
|
||||
step: 1,
|
||||
},
|
||||
sensorOffsetAngle: {
|
||||
folder: 'Movement',
|
||||
label: 'Sensor Angle',
|
||||
min: 0,
|
||||
max: 180,
|
||||
step: 1,
|
||||
},
|
||||
moveSpeed: {
|
||||
folder: 'Movement',
|
||||
label: 'Travel Speed',
|
||||
min: 10,
|
||||
max: 250,
|
||||
step: 1,
|
||||
},
|
||||
turnSpeed: {
|
||||
folder: 'Movement',
|
||||
label: 'Turning Speed',
|
||||
min: 1,
|
||||
max: 200,
|
||||
step: 1,
|
||||
},
|
||||
forwardRotationScale: {
|
||||
folder: 'Movement',
|
||||
format: formatPercent,
|
||||
label: 'Forward Focus',
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.01,
|
||||
},
|
||||
turnWhenLost: {
|
||||
folder: 'Movement',
|
||||
label: 'Wander Turn',
|
||||
min: 0,
|
||||
max: Math.PI * 2,
|
||||
step: 0.01,
|
||||
},
|
||||
individualTrailWeight: {
|
||||
folder: 'Movement',
|
||||
label: 'Trail Strength',
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.001,
|
||||
},
|
||||
diffusionRateTrails: {
|
||||
folder: 'Movement',
|
||||
label: 'Diffusion Rate',
|
||||
min: 0.01,
|
||||
max: 1,
|
||||
step: 0.01,
|
||||
},
|
||||
decayRateTrails: {
|
||||
folder: 'Movement',
|
||||
label: 'Trail Fade',
|
||||
min: 800,
|
||||
max: 1000,
|
||||
step: 1,
|
||||
},
|
||||
|
||||
clarity: {
|
||||
folder: 'Look',
|
||||
inverted: true,
|
||||
label: 'Sharpness',
|
||||
min: 0.00001,
|
||||
max: 1,
|
||||
step: 0.001,
|
||||
},
|
||||
backgroundGrainStrength: {
|
||||
folder: 'Look',
|
||||
label: 'Background Grain',
|
||||
min: 0,
|
||||
max: 0.12,
|
||||
step: 0.001,
|
||||
},
|
||||
|
||||
maxAgentCount: {
|
||||
folder: 'Performance',
|
||||
format: formatCompactNumber,
|
||||
integer: true,
|
||||
label: 'Population Limit',
|
||||
min: 0,
|
||||
step: 10_000,
|
||||
},
|
||||
internalRenderAreaMegapixels: {
|
||||
folder: 'Performance',
|
||||
label: 'Render Quality (MP)',
|
||||
min: INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS.min,
|
||||
max: INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS.max,
|
||||
step: 0.1,
|
||||
},
|
||||
};
|
||||
4
src/config/runtime-setting-bounds.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export const INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS = {
|
||||
min: 0.5,
|
||||
max: 16.6,
|
||||
} as const;
|
||||
269
src/config/types.ts
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
import type {
|
||||
GardenAudioConfig,
|
||||
GardenAudioVibeSettings,
|
||||
} from '../audio/garden-audio-config';
|
||||
import type { AgentSettings } from '../pipelines/agents/agent-pipeline';
|
||||
import type { BrushSettings } from '../pipelines/brush/brush-pipeline';
|
||||
import type { DiffusionSettings } from '../pipelines/diffusion/diffusion-pipeline';
|
||||
import type { RenderSettings } from '../pipelines/render/render-pipeline';
|
||||
import type { RgbColor } from '../utils/rgb-color';
|
||||
|
||||
export interface NumberControlConfig {
|
||||
format?: (value: number) => string;
|
||||
folder: string;
|
||||
integer?: boolean;
|
||||
inverted?: boolean;
|
||||
label?: string;
|
||||
max?: number;
|
||||
min?: number;
|
||||
options?: Record<string, number>;
|
||||
step?: number;
|
||||
}
|
||||
|
||||
export type GardenRuntimeSettings = {
|
||||
adaptiveCapInitial: number;
|
||||
adaptiveCapMin: number;
|
||||
backgroundGrainStrength: number;
|
||||
brushCurveResolution: number;
|
||||
brushCurveMinBrushRadius: number;
|
||||
brushCurveMinSegmentSpacing: number;
|
||||
brushCurveMirrorResolutionExponent: number;
|
||||
brushCurveSegmentBrushRadiusRatio: number;
|
||||
brushEffectDuration: number;
|
||||
brushSmoothingMinSampleDistance: number;
|
||||
eraserClearAlpha: number;
|
||||
eraserClearBlue: number;
|
||||
eraserClearGreen: number;
|
||||
eraserClearRed: number;
|
||||
eraserLineDistanceEpsilon: number;
|
||||
eraserMaskAlphaThreshold: number;
|
||||
eraserSize: number;
|
||||
internalRenderAreaMegapixels: number;
|
||||
mirrorSegmentCount: number;
|
||||
maxAgentCount: number;
|
||||
selectedColorIndex: number;
|
||||
spawnPerPixel: number;
|
||||
strokeAngleJitterRadians: number;
|
||||
} & AgentSettings &
|
||||
BrushSettings &
|
||||
DiffusionSettings &
|
||||
RenderSettings;
|
||||
|
||||
type RuntimeSettingControlConfig = Partial<
|
||||
Record<keyof GardenRuntimeSettings, NumberControlConfig>
|
||||
>;
|
||||
|
||||
export type GardenVibeSettings = Pick<
|
||||
GardenRuntimeSettings,
|
||||
| 'backgroundGrainStrength'
|
||||
| 'brushSize'
|
||||
| 'clarity'
|
||||
| 'color1ToColor1'
|
||||
| 'color1ToColor2'
|
||||
| 'color1ToColor3'
|
||||
| 'color2ToColor1'
|
||||
| 'color2ToColor2'
|
||||
| 'color2ToColor3'
|
||||
| 'color3ToColor1'
|
||||
| 'color3ToColor2'
|
||||
| 'color3ToColor3'
|
||||
| 'decayRateTrails'
|
||||
| 'forwardRotationScale'
|
||||
| 'individualTrailWeight'
|
||||
| 'moveSpeed'
|
||||
| 'sensorOffsetAngle'
|
||||
| 'sensorOffsetDistance'
|
||||
| 'spawnPerPixel'
|
||||
| 'strokeAngleJitterRadians'
|
||||
| 'turnSpeed'
|
||||
| 'turnWhenLost'
|
||||
>;
|
||||
|
||||
type GardenDefaultSettings = Omit<
|
||||
GardenRuntimeSettings,
|
||||
keyof GardenVibeSettings | 'eraserSize' | 'mirrorSegmentCount'
|
||||
>;
|
||||
|
||||
export enum VibeId {
|
||||
AuroraMycelium = 'aurora-mycelium',
|
||||
VelvetObservatory = 'velvet-observatory',
|
||||
LichenSignal = 'lichen-signal',
|
||||
TidepoolLantern = 'tidepool-lantern',
|
||||
PaperLanternFog = 'paper-lantern-fog',
|
||||
ChromePollen = 'chrome-pollen',
|
||||
}
|
||||
|
||||
export interface VibePreset {
|
||||
id: VibeId;
|
||||
name: string;
|
||||
colors: [RgbColor, RgbColor, RgbColor];
|
||||
backgroundColor: RgbColor;
|
||||
settings: GardenVibeSettings;
|
||||
audio: GardenAudioVibeSettings;
|
||||
}
|
||||
|
||||
export interface GardenAppConfig {
|
||||
audio: GardenAudioConfig;
|
||||
analytics: {
|
||||
autoCapturePageviews: boolean;
|
||||
domain: string;
|
||||
endpoint: string;
|
||||
logging: boolean;
|
||||
};
|
||||
deltaTime: {
|
||||
maxDeltaTimeSeconds: number;
|
||||
minDeltaTimeSeconds: number;
|
||||
};
|
||||
exportSnapshot: {
|
||||
bytesPerPixel: number;
|
||||
filenameExtension: string;
|
||||
filenamePrefix: string;
|
||||
filenameSuffix: string;
|
||||
mimeType: string;
|
||||
rowAlignmentBytes: number;
|
||||
};
|
||||
menuHider: {
|
||||
bottomRevealDistancePx: number;
|
||||
desktopMediaQuery: string;
|
||||
hideDelayMs: number;
|
||||
};
|
||||
pipelines: {
|
||||
common: {
|
||||
noiseChannelSeeds: [number, number, number, number];
|
||||
noiseClearValue: GPUColor;
|
||||
noiseDrawInstanceCount: number;
|
||||
noiseDrawVertexCount: number;
|
||||
noiseHashMultiplier: number;
|
||||
noiseHashX: number;
|
||||
noiseHashY: number;
|
||||
noiseTextureFormat: GPUTextureFormat;
|
||||
noiseTextureSize: number;
|
||||
};
|
||||
brush: {
|
||||
maxLineCount: number;
|
||||
};
|
||||
diffusion: {
|
||||
minDiffusionRate: number;
|
||||
};
|
||||
eraser: {
|
||||
maxTextureLineCount: number;
|
||||
};
|
||||
};
|
||||
defaultSettings: GardenDefaultSettings;
|
||||
runtimeSettings: {
|
||||
controls: RuntimeSettingControlConfig;
|
||||
};
|
||||
simulation: {
|
||||
brushEffectFramesPerSecond: number;
|
||||
clearColor: GPUColor;
|
||||
initialAgentCount: number;
|
||||
sourceActiveFramesAfterWrite: number;
|
||||
intro: {
|
||||
angleJitterRadians: number;
|
||||
angleEaseEnd: number;
|
||||
angleEaseStart: number;
|
||||
circleMaxSideRatio: number;
|
||||
circleMinSideRatio: number;
|
||||
drawHintDelayMs: number;
|
||||
durationSeconds: number;
|
||||
entryJitterSideRatio: number;
|
||||
fontScaleDown: number;
|
||||
fontFamily: string;
|
||||
initialFontHeightRatio: number;
|
||||
initialFontWidthRatio: number;
|
||||
letterSpacingEm: number;
|
||||
maskAlphaThreshold: number;
|
||||
maskGradientThreshold: number;
|
||||
maskMaxPixels: number;
|
||||
maskSampleDensity: number;
|
||||
maxHeightRatio: number;
|
||||
maxWidthRatio: number;
|
||||
minEntryJitterPx: number;
|
||||
minFontSizePx: number;
|
||||
minTargetJitterPx: number;
|
||||
pathEasing: 'easeOutQuad' | 'linear';
|
||||
pathProgressEpsilon: number;
|
||||
radialJitterRatio: number;
|
||||
radialStartEpsilon: number;
|
||||
resizeMinimumRemainingSeconds: number;
|
||||
resizeSettleMs: number;
|
||||
targetDelayDistanceMultiplier: number;
|
||||
targetDelayMax: number;
|
||||
targetDelayRandomMultiplier: number;
|
||||
targetJitterSideRatio: number;
|
||||
title: string;
|
||||
titleColorCutLetters: [number, number];
|
||||
titleRadiusMultiplier: number;
|
||||
titleStrokeWidthMinPx: number;
|
||||
titleStrokeWidthRatio: number;
|
||||
verticalAnchor: number;
|
||||
};
|
||||
introMoveSpeed: number;
|
||||
stroke: {
|
||||
densityMultiplier: number;
|
||||
maxAgentCount: number;
|
||||
};
|
||||
};
|
||||
storage: {
|
||||
audioMutedKey: string;
|
||||
audioVolumeKey: string;
|
||||
vibeKey: string;
|
||||
};
|
||||
toolbar: {
|
||||
eraser: {
|
||||
controlScaleMax: number;
|
||||
controlScaleMin: number;
|
||||
default: number;
|
||||
max: number;
|
||||
min: number;
|
||||
step: number;
|
||||
};
|
||||
mirror: {
|
||||
default: number;
|
||||
fallbackSegmentName: string;
|
||||
max: number;
|
||||
min: number;
|
||||
names: Record<number, string>;
|
||||
offLabel: string;
|
||||
step: number;
|
||||
};
|
||||
contrast: {
|
||||
backgroundOpacityMax: number;
|
||||
brightLuminanceThreshold: number;
|
||||
brightWeight: number;
|
||||
bytesPerSample: number;
|
||||
contrastOffset: number;
|
||||
linearChannelBreakpoint: number;
|
||||
linearChannelDivisor: number;
|
||||
linearChannelGamma: number;
|
||||
linearChannelOffset: number;
|
||||
linearChannelScale: number;
|
||||
lowContrastThreshold: number;
|
||||
lowContrastWeight: number;
|
||||
luminanceBase: number;
|
||||
luminanceBlueWeight: number;
|
||||
luminanceGreenWeight: number;
|
||||
luminanceRange: number;
|
||||
luminanceRedWeight: number;
|
||||
sampleColumns: number;
|
||||
sampleIntervalMs: number;
|
||||
sampleRows: number;
|
||||
whiteContrastNumerator: number;
|
||||
};
|
||||
volume: {
|
||||
default: number;
|
||||
max: number;
|
||||
min: number;
|
||||
step: number;
|
||||
};
|
||||
};
|
||||
tuningPane: {
|
||||
showFpsOverlay: boolean;
|
||||
startHidden: boolean;
|
||||
title: string;
|
||||
};
|
||||
vibes: {
|
||||
defaultVibeId: VibeId;
|
||||
presets: Array<VibePreset>;
|
||||
};
|
||||
}
|
||||
81
src/config/vibe-presets.test.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { runtimeControls } from './runtime-controls';
|
||||
import { vibePresets } from './vibe-presets';
|
||||
|
||||
const FINAL_VIBE_NAMES = [
|
||||
'Aurora Mycelium Copy',
|
||||
'Velvet Observatory Copy',
|
||||
'Lichen Signal',
|
||||
'Tidepool Lantern',
|
||||
'Paper Lantern Fog',
|
||||
'Chrome Pollen',
|
||||
];
|
||||
|
||||
const BLENDED_BRUSH_SIZE_MIN = 17;
|
||||
const BLENDED_CLARITY_MAX = 0.56;
|
||||
const SOFT_PARTICLE_BRUSH_SIZE_MAX = 5;
|
||||
const SOFT_PARTICLE_CLARITY_MAX = 0.2;
|
||||
|
||||
// Performance guardrails — bumping any of these is an explicit perf trade-off.
|
||||
const MAX_SPAWN_PER_PIXEL = runtimeControls.spawnPerPixel?.max ?? 0.38;
|
||||
const MAX_BRUSH_SIZE = runtimeControls.brushSize?.max ?? 36;
|
||||
const HIGH_DENSITY_SPAWN_THRESHOLD = 0.28;
|
||||
const HIGH_DENSITY_DECAY_LIMIT = 940;
|
||||
const HIGH_DENSITY_BRUSH_SIZE_LIMIT = 14;
|
||||
const HIGH_DENSITY_TRAIL_WEIGHT_LIMIT = 0.055;
|
||||
|
||||
describe('vibePresets', () => {
|
||||
it('keeps the classic preset set distinct', () => {
|
||||
expect(vibePresets.map((preset) => preset.name)).toEqual(FINAL_VIBE_NAMES);
|
||||
|
||||
const ids = vibePresets.map((preset) => preset.id);
|
||||
expect(new Set(ids).size).toBe(vibePresets.length);
|
||||
});
|
||||
|
||||
it('includes both blended and visibly particulate styles', () => {
|
||||
const blendedNames = vibePresets
|
||||
.filter(
|
||||
(preset) =>
|
||||
preset.settings.brushSize >= BLENDED_BRUSH_SIZE_MIN &&
|
||||
preset.settings.clarity <= BLENDED_CLARITY_MAX
|
||||
)
|
||||
.map((preset) => preset.name);
|
||||
const softParticleNames = vibePresets
|
||||
.filter(
|
||||
(preset) =>
|
||||
preset.settings.brushSize <= SOFT_PARTICLE_BRUSH_SIZE_MAX &&
|
||||
preset.settings.clarity <= SOFT_PARTICLE_CLARITY_MAX
|
||||
)
|
||||
.map((preset) => preset.name);
|
||||
|
||||
expect(blendedNames).toEqual(['Tidepool Lantern']);
|
||||
expect(softParticleNames).toEqual(['Chrome Pollen']);
|
||||
});
|
||||
|
||||
it('stays inside interactive performance guardrails', () => {
|
||||
const violations = vibePresets.flatMap((preset) => {
|
||||
const { name, settings } = preset;
|
||||
const presetViolations: Array<string> = [];
|
||||
|
||||
if (settings.spawnPerPixel > MAX_SPAWN_PER_PIXEL) {
|
||||
presetViolations.push(`${name} density exceeds ${MAX_SPAWN_PER_PIXEL}`);
|
||||
}
|
||||
if (settings.brushSize > MAX_BRUSH_SIZE) {
|
||||
presetViolations.push(`${name} brush size exceeds ${MAX_BRUSH_SIZE}`);
|
||||
}
|
||||
if (
|
||||
settings.spawnPerPixel >= HIGH_DENSITY_SPAWN_THRESHOLD &&
|
||||
(settings.decayRateTrails > HIGH_DENSITY_DECAY_LIMIT ||
|
||||
settings.brushSize > HIGH_DENSITY_BRUSH_SIZE_LIMIT ||
|
||||
settings.individualTrailWeight > HIGH_DENSITY_TRAIL_WEIGHT_LIMIT)
|
||||
) {
|
||||
presetViolations.push(`${name} combines high density with too much persistence`);
|
||||
}
|
||||
|
||||
return presetViolations;
|
||||
});
|
||||
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
366
src/config/vibe-presets.ts
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
import {
|
||||
defaultGardenAudioVibeSettings,
|
||||
type GardenAudioChord,
|
||||
} from '../audio/garden-audio-config';
|
||||
import { VibeId, type GardenVibeSettings, type VibePreset } from './types';
|
||||
|
||||
type ColorReactionSettings = Pick<
|
||||
GardenVibeSettings,
|
||||
| 'color1ToColor1'
|
||||
| 'color1ToColor2'
|
||||
| 'color1ToColor3'
|
||||
| 'color2ToColor1'
|
||||
| 'color2ToColor2'
|
||||
| 'color2ToColor3'
|
||||
| 'color3ToColor1'
|
||||
| 'color3ToColor2'
|
||||
| 'color3ToColor3'
|
||||
>;
|
||||
|
||||
const colorReactions = {
|
||||
auroraMycelium: {
|
||||
color1ToColor1: 1,
|
||||
color1ToColor2: 0,
|
||||
color1ToColor3: 0,
|
||||
color2ToColor1: -1,
|
||||
color2ToColor2: 1,
|
||||
color2ToColor3: 0,
|
||||
color3ToColor1: -1,
|
||||
color3ToColor2: -1,
|
||||
color3ToColor3: 1,
|
||||
},
|
||||
velvetObservatory: {
|
||||
color1ToColor1: 1,
|
||||
color1ToColor2: -1,
|
||||
color1ToColor3: -1,
|
||||
color2ToColor1: -1,
|
||||
color2ToColor2: 1,
|
||||
color2ToColor3: -1,
|
||||
color3ToColor1: -1,
|
||||
color3ToColor2: -1,
|
||||
color3ToColor3: 1,
|
||||
},
|
||||
lichenSignal: {
|
||||
color1ToColor1: 0,
|
||||
color1ToColor2: -1,
|
||||
color1ToColor3: 1,
|
||||
color2ToColor1: -1,
|
||||
color2ToColor2: 0,
|
||||
color2ToColor3: -1,
|
||||
color3ToColor1: 1,
|
||||
color3ToColor2: -1,
|
||||
color3ToColor3: 1,
|
||||
},
|
||||
tidepoolLantern: {
|
||||
color1ToColor1: 0,
|
||||
color1ToColor2: 1,
|
||||
color1ToColor3: 0,
|
||||
color2ToColor1: 0,
|
||||
color2ToColor2: 0,
|
||||
color2ToColor3: 1,
|
||||
color3ToColor1: 1,
|
||||
color3ToColor2: 0,
|
||||
color3ToColor3: 0,
|
||||
},
|
||||
paperLanternFog: {
|
||||
color1ToColor1: 1,
|
||||
color1ToColor2: 1,
|
||||
color1ToColor3: 1,
|
||||
color2ToColor1: 1,
|
||||
color2ToColor2: 1,
|
||||
color2ToColor3: 1,
|
||||
color3ToColor1: 1,
|
||||
color3ToColor2: 1,
|
||||
color3ToColor3: 1,
|
||||
},
|
||||
chromePollen: {
|
||||
color1ToColor1: 1,
|
||||
color1ToColor2: 0,
|
||||
color1ToColor3: 1,
|
||||
color2ToColor1: -1,
|
||||
color2ToColor2: 1,
|
||||
color2ToColor3: 0,
|
||||
color3ToColor1: 1,
|
||||
color3ToColor2: 0,
|
||||
color3ToColor3: 1,
|
||||
},
|
||||
} satisfies Record<string, ColorReactionSettings>;
|
||||
|
||||
const musicScales = {
|
||||
dorian: [0, 2, 3, 5, 7, 9, 10],
|
||||
lydian: [0, 2, 4, 6, 7, 9, 11],
|
||||
mixolydian: [0, 2, 4, 5, 7, 9, 10],
|
||||
naturalMinor: [0, 2, 3, 5, 7, 8, 10],
|
||||
} satisfies Record<string, Array<number>>;
|
||||
|
||||
const musicProgressions = {
|
||||
aurora: [
|
||||
{ rootOffset: 0, quality: 'sus2' },
|
||||
{ rootOffset: 7, quality: 'major' },
|
||||
{ rootOffset: 9, quality: 'minor' },
|
||||
{ rootOffset: 5, quality: 'sus4' },
|
||||
],
|
||||
chrome: [
|
||||
{ rootOffset: 0, quality: 'major' },
|
||||
{ rootOffset: 2, quality: 'major' },
|
||||
{ rootOffset: 7, quality: 'sus2' },
|
||||
{ rootOffset: 9, quality: 'minor' },
|
||||
],
|
||||
lichen: [
|
||||
{ rootOffset: 0, quality: 'minor' },
|
||||
{ rootOffset: 5, quality: 'major' },
|
||||
{ rootOffset: 10, quality: 'major' },
|
||||
{ rootOffset: 3, quality: 'major' },
|
||||
],
|
||||
paperLantern: [
|
||||
{ rootOffset: 0, quality: 'minor' },
|
||||
{ rootOffset: 8, quality: 'major' },
|
||||
{ rootOffset: 5, quality: 'minor' },
|
||||
{ rootOffset: 10, quality: 'sus4' },
|
||||
],
|
||||
tidepool: [
|
||||
{ rootOffset: 0, quality: 'major' },
|
||||
{ rootOffset: 10, quality: 'major' },
|
||||
{ rootOffset: 5, quality: 'sus2' },
|
||||
{ rootOffset: 9, quality: 'minor' },
|
||||
],
|
||||
velvet: [
|
||||
{ rootOffset: 0, quality: 'minor' },
|
||||
{ rootOffset: 8, quality: 'major' },
|
||||
{ rootOffset: 3, quality: 'major' },
|
||||
{ rootOffset: 5, quality: 'sus4' },
|
||||
],
|
||||
} satisfies Record<string, Array<GardenAudioChord>>;
|
||||
|
||||
export const defaultVibeId = VibeId.AuroraMycelium;
|
||||
|
||||
export const vibePresets: Array<VibePreset> = [
|
||||
{
|
||||
id: VibeId.AuroraMycelium,
|
||||
name: 'Aurora Mycelium Copy',
|
||||
colors: [
|
||||
[251, 210, 94],
|
||||
[154, 99, 255],
|
||||
[255, 31, 199],
|
||||
],
|
||||
backgroundColor: [6, 13, 22],
|
||||
settings: {
|
||||
...colorReactions.auroraMycelium,
|
||||
backgroundGrainStrength: 0.003,
|
||||
brushSize: 8.75,
|
||||
clarity: 1,
|
||||
decayRateTrails: 973,
|
||||
forwardRotationScale: 0.37,
|
||||
individualTrailWeight: 0.053000000000000005,
|
||||
moveSpeed: 144,
|
||||
sensorOffsetAngle: 35,
|
||||
sensorOffsetDistance: 52,
|
||||
spawnPerPixel: 0.13999999999999999,
|
||||
strokeAngleJitterRadians: 0.45,
|
||||
turnSpeed: 13,
|
||||
turnWhenLost: 0,
|
||||
},
|
||||
audio: {
|
||||
...defaultGardenAudioVibeSettings,
|
||||
idleIntensity: 0.12000000000000002,
|
||||
bpm: 60,
|
||||
rampUpIntensity: 0.7,
|
||||
rampUpTime: 0.14,
|
||||
noteLength: 0.8599999999999999,
|
||||
notePitchOffset: -2,
|
||||
brightness: 0.84,
|
||||
scale: musicScales.lydian,
|
||||
progression: musicProgressions.aurora,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: VibeId.VelvetObservatory,
|
||||
name: 'Velvet Observatory Copy',
|
||||
colors: [
|
||||
[178, 76, 62],
|
||||
[2, 174, 255],
|
||||
[213, 193, 9],
|
||||
],
|
||||
backgroundColor: [7, 4, 22],
|
||||
settings: {
|
||||
...colorReactions.velvetObservatory,
|
||||
backgroundGrainStrength: 0.005,
|
||||
brushSize: 9.75,
|
||||
clarity: 1,
|
||||
decayRateTrails: 974,
|
||||
forwardRotationScale: 0,
|
||||
individualTrailWeight: 0.232,
|
||||
moveSpeed: 121,
|
||||
sensorOffsetAngle: 24,
|
||||
sensorOffsetDistance: 17,
|
||||
spawnPerPixel: 0.11499999999999999,
|
||||
strokeAngleJitterRadians: 0.17,
|
||||
turnSpeed: 33,
|
||||
turnWhenLost: 0.42,
|
||||
},
|
||||
audio: {
|
||||
...defaultGardenAudioVibeSettings,
|
||||
idleIntensity: 0.24000000000000002,
|
||||
bpm: 72,
|
||||
rampUpIntensity: 1.42,
|
||||
rampUpTime: 0.07,
|
||||
noteLength: 0.7,
|
||||
notePitchOffset: 0,
|
||||
brightness: 0.94,
|
||||
scale: musicScales.naturalMinor,
|
||||
progression: musicProgressions.velvet,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: VibeId.LichenSignal,
|
||||
name: 'Lichen Signal',
|
||||
colors: [
|
||||
[183, 216, 92],
|
||||
[65, 166, 128],
|
||||
[238, 120, 76],
|
||||
],
|
||||
backgroundColor: [0, 0, 0],
|
||||
settings: {
|
||||
...colorReactions.lichenSignal,
|
||||
backgroundGrainStrength: 0.02,
|
||||
brushSize: 6.5,
|
||||
clarity: 0.74,
|
||||
decayRateTrails: 962,
|
||||
forwardRotationScale: 0.3,
|
||||
individualTrailWeight: 0.052,
|
||||
moveSpeed: 72,
|
||||
sensorOffsetAngle: 42,
|
||||
sensorOffsetDistance: 54,
|
||||
spawnPerPixel: 0.16,
|
||||
strokeAngleJitterRadians: 3.14,
|
||||
turnSpeed: 44,
|
||||
turnWhenLost: 0.92,
|
||||
},
|
||||
audio: {
|
||||
...defaultGardenAudioVibeSettings,
|
||||
idleIntensity: 0.13,
|
||||
bpm: 68,
|
||||
rampUpIntensity: 1.46,
|
||||
rampUpTime: 0.1,
|
||||
noteLength: 0.6,
|
||||
notePitchOffset: -3,
|
||||
brightness: 1.21,
|
||||
scale: musicScales.dorian,
|
||||
progression: musicProgressions.lichen,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: VibeId.TidepoolLantern,
|
||||
name: 'Tidepool Lantern',
|
||||
colors: [
|
||||
[30, 219, 194],
|
||||
[61, 118, 255],
|
||||
[255, 191, 91],
|
||||
],
|
||||
backgroundColor: [4, 18, 29],
|
||||
settings: {
|
||||
...colorReactions.tidepoolLantern,
|
||||
backgroundGrainStrength: 0.018,
|
||||
brushSize: 17,
|
||||
clarity: 0.56,
|
||||
decayRateTrails: 968,
|
||||
forwardRotationScale: 0.38,
|
||||
individualTrailWeight: 0.06,
|
||||
moveSpeed: 88,
|
||||
sensorOffsetAngle: 64,
|
||||
sensorOffsetDistance: 46,
|
||||
spawnPerPixel: 0.22,
|
||||
strokeAngleJitterRadians: 1.8,
|
||||
turnSpeed: 66,
|
||||
turnWhenLost: 1.05,
|
||||
},
|
||||
audio: {
|
||||
...defaultGardenAudioVibeSettings,
|
||||
idleIntensity: 0.08,
|
||||
bpm: 84,
|
||||
rampUpIntensity: 0.95,
|
||||
rampUpTime: 0.08,
|
||||
noteLength: 0.46,
|
||||
notePitchOffset: 0,
|
||||
brightness: 0.98,
|
||||
scale: musicScales.mixolydian,
|
||||
progression: musicProgressions.tidepool,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: VibeId.PaperLanternFog,
|
||||
name: 'Paper Lantern Fog',
|
||||
colors: [
|
||||
[255, 176, 108],
|
||||
[239, 90, 108],
|
||||
[128, 213, 184],
|
||||
],
|
||||
backgroundColor: [30, 23, 20],
|
||||
settings: {
|
||||
...colorReactions.paperLanternFog,
|
||||
backgroundGrainStrength: 0.038,
|
||||
brushSize: 3.5,
|
||||
clarity: 1,
|
||||
decayRateTrails: 999,
|
||||
forwardRotationScale: 0.24,
|
||||
individualTrailWeight: 0.937,
|
||||
moveSpeed: 28,
|
||||
sensorOffsetAngle: 34,
|
||||
sensorOffsetDistance: 66,
|
||||
spawnPerPixel: 0.055,
|
||||
strokeAngleJitterRadians: 0,
|
||||
turnSpeed: 30,
|
||||
turnWhenLost: 1.52,
|
||||
},
|
||||
audio: {
|
||||
...defaultGardenAudioVibeSettings,
|
||||
idleIntensity: 0.33,
|
||||
bpm: 127,
|
||||
rampUpIntensity: 0.66,
|
||||
rampUpTime: 0.03,
|
||||
noteLength: 0.92,
|
||||
notePitchOffset: 10,
|
||||
brightness: 1.42,
|
||||
scale: musicScales.naturalMinor,
|
||||
progression: musicProgressions.paperLantern,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: VibeId.ChromePollen,
|
||||
name: 'Chrome Pollen',
|
||||
colors: [
|
||||
[178, 34, 34],
|
||||
[255, 214, 48],
|
||||
[77, 240, 157],
|
||||
],
|
||||
backgroundColor: [7, 12, 11],
|
||||
settings: {
|
||||
...colorReactions.chromePollen,
|
||||
backgroundGrainStrength: 0.012,
|
||||
brushSize: 4.5,
|
||||
clarity: 0.1,
|
||||
decayRateTrails: 922,
|
||||
forwardRotationScale: 0.5,
|
||||
individualTrailWeight: 0.026,
|
||||
moveSpeed: 86,
|
||||
sensorOffsetAngle: 46,
|
||||
sensorOffsetDistance: 14,
|
||||
spawnPerPixel: 0.36,
|
||||
strokeAngleJitterRadians: 3,
|
||||
turnSpeed: 34,
|
||||
turnWhenLost: 1.35,
|
||||
},
|
||||
audio: {
|
||||
...defaultGardenAudioVibeSettings,
|
||||
idleIntensity: 0.11,
|
||||
bpm: 150,
|
||||
rampUpIntensity: 2,
|
||||
rampUpTime: 0.06,
|
||||
noteLength: 1.8,
|
||||
notePitchOffset: -12,
|
||||
brightness: 0.5,
|
||||
scale: musicScales.lydian,
|
||||
progression: musicProgressions.chrome,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
@ -1 +0,0 @@
|
|||
export const isProduction: boolean = import.meta.env.PROD;
|
||||
178
src/game-loop/agent-population.test.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { appConfig } from '../config';
|
||||
import { type AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
|
||||
import { AGENT_FLOAT_COUNT } from '../pipelines/agents/agent-limits';
|
||||
import { settings } from '../settings';
|
||||
import { AgentPopulation } from './agent-population';
|
||||
import { type FramePerformance } from './frame-performance';
|
||||
|
||||
const originalSettings = {
|
||||
brushSize: settings.brushSize,
|
||||
maxAgentCount: settings.maxAgentCount,
|
||||
selectedColorIndex: settings.selectedColorIndex,
|
||||
spawnPerPixel: settings.spawnPerPixel,
|
||||
strokeAngleJitterRadians: settings.strokeAngleJitterRadians,
|
||||
};
|
||||
|
||||
class RecordingAgentGenerationPipeline {
|
||||
public readonly writtenAgentCounts: Array<number> = [];
|
||||
public readonly writtenAgentOffsets: Array<number> = [];
|
||||
public readonly writtenBatches: Array<Float32Array> = [];
|
||||
public readonly maxSupportedAgentCount = 1_000_000;
|
||||
public maxAgentCount = 1_000_000;
|
||||
private compactResolver: ((compactedAgentCount: number) => void) | null = null;
|
||||
|
||||
public ensureMaxAgentCount(requestedMaxAgentCount: number): number {
|
||||
this.maxAgentCount = Math.max(this.maxAgentCount, requestedMaxAgentCount);
|
||||
return this.maxAgentCount;
|
||||
}
|
||||
|
||||
public writeAgents(agentOffset: number, data: Float32Array): void {
|
||||
this.writtenAgentOffsets.push(agentOffset);
|
||||
this.writtenAgentCounts.push(data.length / AGENT_FLOAT_COUNT);
|
||||
this.writtenBatches.push(data.slice());
|
||||
}
|
||||
|
||||
public compactAgents(): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
this.compactResolver = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
public finishCompaction(compactedAgentCount: number): void {
|
||||
this.compactResolver?.(compactedAgentCount);
|
||||
this.compactResolver = null;
|
||||
}
|
||||
}
|
||||
|
||||
const framePerformance = {
|
||||
adaptiveCapDecreaseAgents: 0,
|
||||
adaptiveCapInitial: 1_000_000,
|
||||
adaptiveCapMin: 0,
|
||||
hasAdaptiveCapHeadroom: true,
|
||||
} as FramePerformance;
|
||||
|
||||
const createPopulation = (): {
|
||||
pipeline: RecordingAgentGenerationPipeline;
|
||||
population: AgentPopulation;
|
||||
} => {
|
||||
const pipeline = new RecordingAgentGenerationPipeline();
|
||||
const population = new AgentPopulation(
|
||||
pipeline as unknown as AgentGenerationPipeline,
|
||||
0,
|
||||
() => 1,
|
||||
framePerformance
|
||||
);
|
||||
population.beginStroke();
|
||||
return { pipeline, population };
|
||||
};
|
||||
|
||||
const setSpawnRate = (agentsPerPixel: number): void => {
|
||||
settings.spawnPerPixel = agentsPerPixel / appConfig.simulation.stroke.densityMultiplier;
|
||||
};
|
||||
|
||||
describe('AgentPopulation stroke spawning', () => {
|
||||
beforeEach(() => {
|
||||
settings.brushSize = 0;
|
||||
settings.maxAgentCount = 1_000_000;
|
||||
settings.selectedColorIndex = 0;
|
||||
settings.strokeAngleJitterRadians = 0;
|
||||
setSpawnRate(1);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.assign(settings, originalSettings);
|
||||
});
|
||||
|
||||
it('spawns the same count for the same stroke length regardless of segmentation', () => {
|
||||
const segmented = createPopulation();
|
||||
for (let x = 0; x < 10; x++) {
|
||||
segmented.population.spawnStrokeAgents(
|
||||
vec2.fromValues(x, 0),
|
||||
vec2.fromValues(x + 1, 0)
|
||||
);
|
||||
}
|
||||
|
||||
const singleSegment = createPopulation();
|
||||
singleSegment.population.spawnStrokeAgents(
|
||||
vec2.fromValues(0, 0),
|
||||
vec2.fromValues(10, 0)
|
||||
);
|
||||
|
||||
expect(segmented.population.activeAgentCount).toBe(10);
|
||||
expect(singleSegment.population.activeAgentCount).toBe(10);
|
||||
});
|
||||
|
||||
it('carries fractional spawn budget within a stroke', () => {
|
||||
setSpawnRate(0.5);
|
||||
const { population } = createPopulation();
|
||||
|
||||
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(1, 0));
|
||||
expect(population.activeAgentCount).toBe(0);
|
||||
|
||||
population.spawnStrokeAgents(vec2.fromValues(1, 0), vec2.fromValues(2, 0));
|
||||
expect(population.activeAgentCount).toBe(1);
|
||||
|
||||
population.spawnStrokeAgents(vec2.fromValues(2, 0), vec2.fromValues(3, 0));
|
||||
expect(population.activeAgentCount).toBe(1);
|
||||
|
||||
population.spawnStrokeAgents(vec2.fromValues(3, 0), vec2.fromValues(4, 0));
|
||||
expect(population.activeAgentCount).toBe(2);
|
||||
});
|
||||
|
||||
it('chunks long stroke writes without clipping length-linear spawn counts', () => {
|
||||
const { pipeline, population } = createPopulation();
|
||||
const batchCapacity = appConfig.simulation.stroke.maxAgentCount;
|
||||
const expectedAgentCount = batchCapacity + 10;
|
||||
|
||||
population.spawnStrokeAgents(
|
||||
vec2.fromValues(0, 0),
|
||||
vec2.fromValues(expectedAgentCount, 0)
|
||||
);
|
||||
|
||||
expect(population.activeAgentCount).toBe(expectedAgentCount);
|
||||
expect(pipeline.writtenAgentCounts).toEqual([batchCapacity, 10]);
|
||||
});
|
||||
|
||||
it('spawns agents in the movement direction', () => {
|
||||
const { pipeline, population } = createPopulation();
|
||||
|
||||
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(3, 0));
|
||||
|
||||
expect(population.activeAgentCount).toBe(3);
|
||||
expect(pipeline.writtenBatches[0][2]).toBe(0);
|
||||
});
|
||||
|
||||
it('clears active agents when an intro replacement has no generated agents', () => {
|
||||
const { population } = createPopulation();
|
||||
|
||||
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(3, 0));
|
||||
expect(population.activeAgentCount).toBe(3);
|
||||
|
||||
settings.maxAgentCount = 0;
|
||||
population.replaceIntroAgents(vec2.fromValues(100, 100), 0);
|
||||
|
||||
expect(population.activeAgentCount).toBe(0);
|
||||
});
|
||||
|
||||
it('queues stroke writes while async compaction is in flight', async () => {
|
||||
const { pipeline, population } = createPopulation();
|
||||
|
||||
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(10, 0));
|
||||
population.requestCompactionAfterErase();
|
||||
population.compactAfterErase(false);
|
||||
|
||||
population.spawnStrokeAgents(vec2.fromValues(10, 0), vec2.fromValues(15, 0));
|
||||
expect(population.activeAgentCount).toBe(10);
|
||||
expect(pipeline.writtenAgentCounts).toEqual([10]);
|
||||
|
||||
pipeline.finishCompaction(6);
|
||||
await population.waitForCompaction();
|
||||
|
||||
expect(population.activeAgentCount).toBe(11);
|
||||
expect(pipeline.writtenAgentOffsets).toEqual([0, 6]);
|
||||
expect(pipeline.writtenAgentCounts).toEqual([10, 5]);
|
||||
});
|
||||
});
|
||||
339
src/game-loop/agent-population.ts
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
import { appConfig } from '../config';
|
||||
import { getRenderQualityBrushSize } from '../config/brush-size';
|
||||
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
|
||||
import { AGENT_FLOAT_COUNT, writeAgentValues } from '../pipelines/agents/agent-limits';
|
||||
import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline';
|
||||
import { settings } from '../settings';
|
||||
import type { FramePerformance } from './frame-performance';
|
||||
import { createIntroTitleAgents } from './intro-title-agents';
|
||||
|
||||
export class AgentPopulation {
|
||||
private activeCount = 0;
|
||||
// Current performance-aware limit; new agents above it replace old agents.
|
||||
private adaptiveCap: number;
|
||||
// Next active agent slot to overwrite when new agents exceed the current cap.
|
||||
private replacementCursor = 0;
|
||||
private canExpandAdaptiveCap = true;
|
||||
private shouldCompactAfterErase = false;
|
||||
private isCompacting = false;
|
||||
private pendingCompaction: Promise<void> | null = null;
|
||||
private readonly queuedAgentBatches: Array<Float32Array> = [];
|
||||
private pendingStrokeAgentCount = 0;
|
||||
private readonly strokeAgentData = new Float32Array(
|
||||
appConfig.simulation.stroke.maxAgentCount * AGENT_FLOAT_COUNT
|
||||
);
|
||||
|
||||
public constructor(
|
||||
private readonly pipeline: AgentGenerationPipeline,
|
||||
private readonly introSeed: number,
|
||||
private readonly getCanvasPixelRatio: () => number,
|
||||
private readonly framePerformance: FramePerformance
|
||||
) {
|
||||
this.adaptiveCap = this.clampAndEnsureAdaptiveCap(
|
||||
this.framePerformance.adaptiveCapInitial
|
||||
);
|
||||
}
|
||||
|
||||
public get activeAgentCount(): number {
|
||||
return this.activeCount;
|
||||
}
|
||||
|
||||
public initializeIntroAgents(canvasSize: vec2): void {
|
||||
this.replaceIntroAgents(canvasSize, 0);
|
||||
}
|
||||
|
||||
public replaceIntroAgents(canvasSize: vec2, progress: number): void {
|
||||
this.adaptiveCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
|
||||
const introAgentCount = Math.min(
|
||||
this.adaptiveCap,
|
||||
appConfig.simulation.initialAgentCount
|
||||
);
|
||||
const data = createIntroTitleAgents({
|
||||
count: introAgentCount,
|
||||
width: canvasSize[0],
|
||||
height: canvasSize[1],
|
||||
progress,
|
||||
seed: this.introSeed,
|
||||
});
|
||||
|
||||
if (data.length === 0) {
|
||||
this.activeCount = 0;
|
||||
this.replacementCursor = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
this.pipeline.writeAgents(0, data);
|
||||
this.activeCount = data.length / AGENT_FLOAT_COUNT;
|
||||
this.replacementCursor = 0;
|
||||
}
|
||||
|
||||
public onVibeChanged(): void {
|
||||
this.pendingStrokeAgentCount = 0;
|
||||
this.adaptiveCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
|
||||
this.trimActiveCountToBudget();
|
||||
}
|
||||
|
||||
public beginStroke(): void {
|
||||
this.pendingStrokeAgentCount = 0;
|
||||
}
|
||||
|
||||
public resizeAgents(scale: vec2): void {
|
||||
this.pipeline.resizeAgents(this.activeCount, scale);
|
||||
}
|
||||
|
||||
public requestCompactionAfterErase(): void {
|
||||
this.shouldCompactAfterErase = true;
|
||||
}
|
||||
|
||||
public compactAfterErase(isSwipeActive: boolean): void {
|
||||
if (!this.shouldCompactAfterErase || this.isCompacting || isSwipeActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.shouldCompactAfterErase = false;
|
||||
if (this.activeCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isCompacting = true;
|
||||
this.pendingCompaction = this.pipeline
|
||||
.compactAgents(this.activeCount)
|
||||
.then((compactedAgentCount) => {
|
||||
const finiteCompactedAgentCount = Number.isFinite(compactedAgentCount)
|
||||
? Math.max(0, Math.floor(compactedAgentCount))
|
||||
: 0;
|
||||
this.activeCount = Math.min(this.activeCount, finiteCompactedAgentCount);
|
||||
this.clampReplacementCursor();
|
||||
this.trimActiveCountToBudget();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.warn('Could not compact agents after erase.', error);
|
||||
})
|
||||
.finally(() => {
|
||||
this.isCompacting = false;
|
||||
this.pendingCompaction = null;
|
||||
this.flushQueuedAgentBatches();
|
||||
});
|
||||
}
|
||||
|
||||
public async waitForCompaction(): Promise<void> {
|
||||
await this.pendingCompaction;
|
||||
}
|
||||
|
||||
public updateAdaptiveCap(): void {
|
||||
const previousCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
|
||||
this.canExpandAdaptiveCap = this.framePerformance.hasAdaptiveCapHeadroom;
|
||||
|
||||
if (this.canExpandAdaptiveCap) {
|
||||
this.adaptiveCap = previousCap;
|
||||
this.trimActiveCountToBudget();
|
||||
return;
|
||||
}
|
||||
|
||||
const decrease = this.framePerformance.adaptiveCapDecreaseAgents;
|
||||
const responsiveCap = Math.min(
|
||||
previousCap,
|
||||
this.clampAndEnsureAdaptiveCap(this.activeCount)
|
||||
);
|
||||
const nextCap = this.clampAndEnsureAdaptiveCap(responsiveCap - decrease);
|
||||
this.adaptiveCap = nextCap;
|
||||
this.trimActiveCountToBudget(decrease);
|
||||
}
|
||||
|
||||
public spawnStrokeAgents(from: vec2, to: vec2): void {
|
||||
const deltaX = to[0] - from[0];
|
||||
const deltaY = to[1] - from[1];
|
||||
const length = Math.hypot(deltaX, deltaY);
|
||||
const spawnRate = getStrokeSpawnRate();
|
||||
if (!Number.isFinite(length) || length <= 0 || spawnRate <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const expectedAgentCount = length * spawnRate + this.pendingStrokeAgentCount;
|
||||
if (!Number.isFinite(expectedAgentCount)) {
|
||||
this.pendingStrokeAgentCount = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const count = Math.floor(expectedAgentCount);
|
||||
this.pendingStrokeAgentCount = expectedAgentCount - count;
|
||||
|
||||
if (count <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseAngle = Math.atan2(deltaY, deltaX);
|
||||
const spread =
|
||||
getRenderQualityBrushSize(
|
||||
settings.brushSize,
|
||||
settings.internalRenderAreaMegapixels
|
||||
) * getSafePixelRatio(this.getCanvasPixelRatio());
|
||||
const batchCapacity = this.strokeAgentData.length / AGENT_FLOAT_COUNT;
|
||||
if (batchCapacity <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let written = 0; written < count; written += batchCapacity) {
|
||||
const batchCount = Math.min(batchCapacity, count - written);
|
||||
this.populateStrokeAgentBatch({
|
||||
baseAngle,
|
||||
batchCount,
|
||||
from,
|
||||
spread,
|
||||
to,
|
||||
totalCount: count,
|
||||
written,
|
||||
});
|
||||
this.writeAgentBatch(
|
||||
this.strokeAgentData.subarray(0, batchCount * AGENT_FLOAT_COUNT)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private populateStrokeAgentBatch({
|
||||
baseAngle,
|
||||
batchCount,
|
||||
from,
|
||||
spread,
|
||||
to,
|
||||
totalCount,
|
||||
written,
|
||||
}: {
|
||||
baseAngle: number;
|
||||
batchCount: number;
|
||||
from: vec2;
|
||||
spread: number;
|
||||
to: vec2;
|
||||
totalCount: number;
|
||||
written: number;
|
||||
}): void {
|
||||
for (let i = 0; i < batchCount; i++) {
|
||||
const agentIndex = written + i;
|
||||
const t = totalCount === 1 ? 0.5 : agentIndex / (totalCount - 1);
|
||||
const x = from[0] + (to[0] - from[0]) * t;
|
||||
const y = from[1] + (to[1] - from[1]) * t;
|
||||
const angle = baseAngle + (Math.random() - 0.5) * settings.strokeAngleJitterRadians;
|
||||
const positionX = x + (Math.random() - 0.5) * spread;
|
||||
const positionY = y + (Math.random() - 0.5) * spread;
|
||||
|
||||
writeAgentValues(this.strokeAgentData, i, {
|
||||
positionX,
|
||||
positionY,
|
||||
angle,
|
||||
colorIndex: settings.selectedColorIndex,
|
||||
targetPositionX: -1,
|
||||
targetPositionY: -1,
|
||||
targetAngle: angle,
|
||||
introDelay: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private writeAgentBatch(data: Float32Array): void {
|
||||
if (data.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isCompacting) {
|
||||
this.queuedAgentBatches.push(data.slice());
|
||||
return;
|
||||
}
|
||||
|
||||
const count = data.length / AGENT_FLOAT_COUNT;
|
||||
this.adaptiveCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
|
||||
this.expandAdaptiveCapForPendingAgents(count);
|
||||
|
||||
const available = Math.max(0, this.adaptiveCap - this.activeCount);
|
||||
const appendCount = Math.min(count, available);
|
||||
|
||||
if (appendCount > 0) {
|
||||
this.pipeline.writeAgents(
|
||||
this.activeCount,
|
||||
data.subarray(0, appendCount * AGENT_FLOAT_COUNT)
|
||||
);
|
||||
this.activeCount += appendCount;
|
||||
}
|
||||
|
||||
let sourceAgentOffset = appendCount;
|
||||
while (sourceAgentOffset < count && this.activeCount > 0) {
|
||||
const targetAgentOffset = this.replacementCursor % this.activeCount;
|
||||
const chunkAgentCount = Math.min(
|
||||
count - sourceAgentOffset,
|
||||
this.activeCount - targetAgentOffset
|
||||
);
|
||||
|
||||
this.pipeline.writeAgents(
|
||||
targetAgentOffset,
|
||||
data.subarray(
|
||||
sourceAgentOffset * AGENT_FLOAT_COUNT,
|
||||
(sourceAgentOffset + chunkAgentCount) * AGENT_FLOAT_COUNT
|
||||
)
|
||||
);
|
||||
|
||||
sourceAgentOffset += chunkAgentCount;
|
||||
this.replacementCursor = (targetAgentOffset + chunkAgentCount) % this.activeCount;
|
||||
}
|
||||
}
|
||||
|
||||
private flushQueuedAgentBatches(): void {
|
||||
const batches = this.queuedAgentBatches.splice(0);
|
||||
batches.forEach((batch) => this.writeAgentBatch(batch));
|
||||
}
|
||||
|
||||
private expandAdaptiveCapForPendingAgents(requestedAgentCount: number): void {
|
||||
const available = Math.max(0, this.adaptiveCap - this.activeCount);
|
||||
if (requestedAgentCount <= available || !this.canExpandAdaptiveCap) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
|
||||
const pendingAgentCount = requestedAgentCount - available;
|
||||
this.adaptiveCap = this.clampAndEnsureAdaptiveCap(currentCap + pendingAgentCount);
|
||||
}
|
||||
|
||||
private trimActiveCountToBudget(maxDecrease = Number.POSITIVE_INFINITY): void {
|
||||
if (this.activeCount <= this.adaptiveCap) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeCount = Math.max(
|
||||
this.adaptiveCap,
|
||||
this.activeCount - Math.max(1, Math.ceil(maxDecrease))
|
||||
);
|
||||
this.clampReplacementCursor();
|
||||
}
|
||||
|
||||
private clampReplacementCursor(): void {
|
||||
this.replacementCursor =
|
||||
this.activeCount === 0 ? 0 : this.replacementCursor % this.activeCount;
|
||||
}
|
||||
|
||||
private clampAndEnsureAdaptiveCap(value: number): number {
|
||||
const runtimeMaxCap =
|
||||
settings.maxAgentCount === Number.POSITIVE_INFINITY
|
||||
? Number.POSITIVE_INFINITY
|
||||
: Number.isFinite(settings.maxAgentCount)
|
||||
? Math.max(0, Math.floor(settings.maxAgentCount))
|
||||
: Math.max(0, Math.floor(this.pipeline.maxAgentCount));
|
||||
const maxCap = Math.min(this.pipeline.maxSupportedAgentCount, runtimeMaxCap);
|
||||
const minCap = Math.min(this.framePerformance.adaptiveCapMin, maxCap);
|
||||
const finiteValue = Number.isFinite(value) ? value : minCap;
|
||||
const nextCap = Math.min(maxCap, Math.max(minCap, Math.round(finiteValue)));
|
||||
return Math.min(
|
||||
nextCap,
|
||||
this.pipeline.ensureMaxAgentCount(nextCap, this.activeCount)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const getStrokeSpawnRate = (): number => {
|
||||
const spawnPerPixel = Number.isFinite(settings.spawnPerPixel)
|
||||
? settings.spawnPerPixel
|
||||
: 0;
|
||||
const densityMultiplier = Number.isFinite(appConfig.simulation.stroke.densityMultiplier)
|
||||
? appConfig.simulation.stroke.densityMultiplier
|
||||
: 0;
|
||||
return Math.max(0, spawnPerPixel * densityMultiplier);
|
||||
};
|
||||
155
src/game-loop/brush-stroke-smoother.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
import { appConfig } from '../config';
|
||||
import { getRenderQualityBrushSize } from '../config/brush-size';
|
||||
import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline';
|
||||
import { settings } from '../settings';
|
||||
import { type StrokeSegment } from './game-loop-types';
|
||||
|
||||
interface BrushStrokeSmootherOptions {
|
||||
getCanvasPixelRatio: () => number;
|
||||
getMirrorSegmentCount: () => number;
|
||||
}
|
||||
|
||||
export class BrushStrokeSmoother {
|
||||
private readonly strokePoints: Array<vec2> = [];
|
||||
private lastBrushPosition: vec2 | null = null;
|
||||
|
||||
public constructor(private readonly options: BrushStrokeSmootherOptions) {}
|
||||
|
||||
public addSample(position: vec2): Array<StrokeSegment> {
|
||||
const previousSample = this.strokePoints[this.strokePoints.length - 1];
|
||||
if (
|
||||
previousSample !== undefined &&
|
||||
vec2.squaredDistance(previousSample, position) <=
|
||||
getBrushSmoothingDistanceSquared(this.options.getCanvasPixelRatio())
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
this.strokePoints.push(vec2.clone(position));
|
||||
|
||||
if (this.strokePoints.length > 3) {
|
||||
this.strokePoints.shift();
|
||||
}
|
||||
|
||||
if (this.strokePoints.length === 1) {
|
||||
this.lastBrushPosition = vec2.clone(position);
|
||||
return [{ from: position, to: position }];
|
||||
}
|
||||
|
||||
if (this.strokePoints.length === 2) {
|
||||
const [start, end] = this.strokePoints;
|
||||
const midpoint = getMidpoint(start, end);
|
||||
this.lastBrushPosition = midpoint;
|
||||
return [{ from: start, to: midpoint }];
|
||||
}
|
||||
|
||||
const [start, control, end] = this.strokePoints;
|
||||
const curveStart = getMidpoint(start, control);
|
||||
const curveEnd = getMidpoint(control, end);
|
||||
this.lastBrushPosition = curveEnd;
|
||||
return this.getQuadraticSegments(curveStart, control, curveEnd);
|
||||
}
|
||||
|
||||
public finish(): Array<StrokeSegment> {
|
||||
if (this.strokePoints.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const finalSample = this.strokePoints[this.strokePoints.length - 1];
|
||||
if (
|
||||
this.lastBrushPosition !== null &&
|
||||
vec2.squaredDistance(this.lastBrushPosition, finalSample) >
|
||||
getBrushSmoothingDistanceSquared(this.options.getCanvasPixelRatio())
|
||||
) {
|
||||
return [{ from: this.lastBrushPosition, to: finalSample }];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this.strokePoints.length = 0;
|
||||
this.lastBrushPosition = null;
|
||||
}
|
||||
|
||||
public scale(scale: vec2): void {
|
||||
this.strokePoints.forEach((point) => {
|
||||
vec2.mul(point, point, scale);
|
||||
});
|
||||
|
||||
if (this.lastBrushPosition !== null) {
|
||||
vec2.mul(this.lastBrushPosition, this.lastBrushPosition, scale);
|
||||
}
|
||||
}
|
||||
|
||||
private getQuadraticSegments(
|
||||
start: vec2,
|
||||
control: vec2,
|
||||
end: vec2
|
||||
): Array<StrokeSegment> {
|
||||
const curveLength = vec2.distance(start, control) + vec2.distance(control, end);
|
||||
const canvasPixelRatio = getSafePixelRatio(this.options.getCanvasPixelRatio());
|
||||
const brushSize = getRenderQualityBrushSize(
|
||||
settings.brushSize,
|
||||
settings.internalRenderAreaMegapixels
|
||||
);
|
||||
const brushRadius = Math.max(
|
||||
settings.brushCurveMinBrushRadius * canvasPixelRatio,
|
||||
(brushSize * canvasPixelRatio) / 2
|
||||
);
|
||||
const segmentSpacing = Math.max(
|
||||
settings.brushCurveMinSegmentSpacing * canvasPixelRatio,
|
||||
brushRadius * settings.brushCurveSegmentBrushRadiusRatio
|
||||
);
|
||||
const mirrorSegmentCount = Math.max(1, this.options.getMirrorSegmentCount());
|
||||
const curveResolution = getBrushCurveResolution();
|
||||
const maxCurveSegments = Math.max(
|
||||
1,
|
||||
Math.floor(
|
||||
curveResolution /
|
||||
Math.max(1, mirrorSegmentCount ** settings.brushCurveMirrorResolutionExponent)
|
||||
)
|
||||
);
|
||||
const segmentCount = Math.min(
|
||||
maxCurveSegments,
|
||||
Math.max(1, Math.ceil(curveLength / segmentSpacing))
|
||||
);
|
||||
|
||||
let previousPoint = start;
|
||||
const segments: Array<StrokeSegment> = [];
|
||||
for (let i = 1; i <= segmentCount; i++) {
|
||||
const point = getQuadraticPoint(start, control, end, i / segmentCount);
|
||||
segments.push({ from: previousPoint, to: point });
|
||||
previousPoint = point;
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
}
|
||||
|
||||
const getMidpoint = (from: vec2, to: vec2): vec2 =>
|
||||
vec2.fromValues((from[0] + to[0]) / 2, (from[1] + to[1]) / 2);
|
||||
|
||||
const getQuadraticPoint = (start: vec2, control: vec2, end: vec2, t: number): vec2 => {
|
||||
const inverseT = 1 - t;
|
||||
return vec2.fromValues(
|
||||
inverseT * inverseT * start[0] + 2 * inverseT * t * control[0] + t * t * end[0],
|
||||
inverseT * inverseT * start[1] + 2 * inverseT * t * control[1] + t * t * end[1]
|
||||
);
|
||||
};
|
||||
|
||||
const getBrushCurveResolution = (): number => {
|
||||
const resolution = Number.isFinite(settings.brushCurveResolution)
|
||||
? settings.brushCurveResolution
|
||||
: appConfig.defaultSettings.brushCurveResolution;
|
||||
return Math.max(1, Math.floor(resolution));
|
||||
};
|
||||
|
||||
const getBrushSmoothingDistanceSquared = (pixelRatio?: number): number => {
|
||||
const distance = Number.isFinite(settings.brushSmoothingMinSampleDistance)
|
||||
? settings.brushSmoothingMinSampleDistance
|
||||
: appConfig.defaultSettings.brushSmoothingMinSampleDistance;
|
||||
return Math.max(0, distance * getSafePixelRatio(pixelRatio)) ** 2;
|
||||
};
|
||||
122
src/game-loop/eraser-preview.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { settings } from '../settings';
|
||||
|
||||
export class EraserPreview {
|
||||
private previewClientPosition: { x: number; y: number } | null = null;
|
||||
private isErasing = false;
|
||||
private isPointerHoveringCanvas = false;
|
||||
private isSwipeActive = false;
|
||||
private previousSize: number | null = null;
|
||||
private previousLeft = '';
|
||||
private previousTop = '';
|
||||
private isVisible = false;
|
||||
|
||||
public constructor(
|
||||
private readonly canvas: HTMLCanvasElement,
|
||||
private readonly element: HTMLElement,
|
||||
private readonly getIsSwipeActive: () => boolean
|
||||
) {}
|
||||
|
||||
public attach(): void {
|
||||
this.canvas.addEventListener('pointerenter', this.onPointerEnter);
|
||||
this.canvas.addEventListener('pointerleave', this.onPointerLeave);
|
||||
this.canvas.addEventListener('pointerdown', this.onPointerDown);
|
||||
this.canvas.addEventListener('pointermove', this.onPointerMove);
|
||||
this.canvas.addEventListener('pointerup', this.onPointerUp);
|
||||
this.canvas.addEventListener('pointercancel', this.onPointerUp);
|
||||
}
|
||||
|
||||
public detach(): void {
|
||||
this.canvas.removeEventListener('pointerenter', this.onPointerEnter);
|
||||
this.canvas.removeEventListener('pointerleave', this.onPointerLeave);
|
||||
this.canvas.removeEventListener('pointerdown', this.onPointerDown);
|
||||
this.canvas.removeEventListener('pointermove', this.onPointerMove);
|
||||
this.canvas.removeEventListener('pointerup', this.onPointerUp);
|
||||
this.canvas.removeEventListener('pointercancel', this.onPointerUp);
|
||||
}
|
||||
|
||||
public setEraseMode(isErasing: boolean): void {
|
||||
this.isErasing = isErasing;
|
||||
this.update();
|
||||
}
|
||||
|
||||
public update(event?: PointerEvent): void {
|
||||
this.isSwipeActive = this.getIsSwipeActive();
|
||||
|
||||
if (event) {
|
||||
this.previewClientPosition = {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
};
|
||||
}
|
||||
|
||||
if (this.previousSize !== settings.eraserSize) {
|
||||
this.element.style.setProperty('--eraser-preview-size', `${settings.eraserSize}px`);
|
||||
this.previousSize = settings.eraserSize;
|
||||
}
|
||||
|
||||
if (
|
||||
!this.isErasing ||
|
||||
this.previewClientPosition === null ||
|
||||
(!this.isPointerHoveringCanvas && !this.isSwipeActive)
|
||||
) {
|
||||
this.setVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const left = `${this.previewClientPosition.x - rect.left}px`;
|
||||
const top = `${this.previewClientPosition.y - rect.top}px`;
|
||||
if (this.previousLeft !== left) {
|
||||
this.element.style.left = left;
|
||||
this.previousLeft = left;
|
||||
}
|
||||
if (this.previousTop !== top) {
|
||||
this.element.style.top = top;
|
||||
this.previousTop = top;
|
||||
}
|
||||
this.setVisible(true);
|
||||
}
|
||||
|
||||
private setVisible(isVisible: boolean): void {
|
||||
if (this.isVisible === isVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isVisible = isVisible;
|
||||
this.element.classList.toggle('visible', isVisible);
|
||||
}
|
||||
|
||||
private isPointerInsideCanvas(event: PointerEvent): boolean {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
return (
|
||||
event.clientX >= rect.left &&
|
||||
event.clientX <= rect.right &&
|
||||
event.clientY >= rect.top &&
|
||||
event.clientY <= rect.bottom
|
||||
);
|
||||
}
|
||||
|
||||
private readonly onPointerDown = (event: PointerEvent) => {
|
||||
this.isPointerHoveringCanvas = true;
|
||||
this.update(event);
|
||||
};
|
||||
|
||||
private readonly onPointerMove = (event: PointerEvent) => {
|
||||
this.update(event);
|
||||
};
|
||||
|
||||
private readonly onPointerUp = (event: PointerEvent) => {
|
||||
this.isPointerHoveringCanvas = this.isPointerInsideCanvas(event);
|
||||
this.update(event);
|
||||
};
|
||||
|
||||
private readonly onPointerEnter = (event: PointerEvent) => {
|
||||
this.isPointerHoveringCanvas = true;
|
||||
this.update(event);
|
||||
};
|
||||
|
||||
private readonly onPointerLeave = () => {
|
||||
this.isPointerHoveringCanvas = false;
|
||||
this.update();
|
||||
};
|
||||
}
|
||||
204
src/game-loop/export-snapshot-renderer.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import { appConfig } from '../config';
|
||||
import { RenderPipeline } from '../pipelines/render/render-pipeline';
|
||||
import type { VibeId } from '../vibes';
|
||||
|
||||
interface ExportSnapshotRendererOptions {
|
||||
device: GPUDevice;
|
||||
renderPipeline: RenderPipeline;
|
||||
canvasFormat: GPUTextureFormat;
|
||||
statusElement: HTMLElement;
|
||||
seed: string;
|
||||
getSourceSize: () => { width: number; height: number };
|
||||
getColorTextureView: () => GPUTextureView;
|
||||
getSourceTextureView: () => GPUTextureView;
|
||||
getSourceActive?: () => boolean;
|
||||
getVibeId: () => VibeId;
|
||||
}
|
||||
|
||||
interface SnapshotLayout {
|
||||
width: number;
|
||||
height: number;
|
||||
unpaddedBytesPerRow: number;
|
||||
bytesPerRow: number;
|
||||
readbackBufferBytes: number;
|
||||
}
|
||||
|
||||
export class ExportSnapshotRenderer {
|
||||
private isExporting = false;
|
||||
|
||||
public constructor(private readonly options: ExportSnapshotRendererOptions) {}
|
||||
|
||||
public async export(): Promise<void> {
|
||||
if (this.isExporting) {
|
||||
this.statusElement.textContent = 'Snapshot already saving...';
|
||||
return;
|
||||
}
|
||||
|
||||
this.isExporting = true;
|
||||
this.statusElement.textContent = 'Saving snapshot...';
|
||||
|
||||
try {
|
||||
const sourceSize = this.options.getSourceSize();
|
||||
await this.renderSnapshot(getSnapshotLayout(sourceSize.width, sourceSize.height));
|
||||
this.statusElement.textContent = '';
|
||||
} catch (error) {
|
||||
this.statusElement.textContent = 'Snapshot failed';
|
||||
throw error;
|
||||
} finally {
|
||||
this.isExporting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async renderSnapshot(layout: SnapshotLayout): Promise<void> {
|
||||
const { width, height, unpaddedBytesPerRow, bytesPerRow } = layout;
|
||||
let texture: GPUTexture | null = null;
|
||||
let output: GPUBuffer | null = null;
|
||||
let isOutputMapped = false;
|
||||
|
||||
try {
|
||||
texture = this.device.createTexture({
|
||||
size: { width, height },
|
||||
format: this.options.canvasFormat,
|
||||
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
|
||||
});
|
||||
output = this.device.createBuffer({
|
||||
size: layout.readbackBufferBytes,
|
||||
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
|
||||
});
|
||||
|
||||
const commandEncoder = this.device.createCommandEncoder();
|
||||
this.options.renderPipeline.executeToView(
|
||||
commandEncoder,
|
||||
this.options.getColorTextureView(),
|
||||
this.options.getSourceTextureView(),
|
||||
texture.createView(),
|
||||
this.options.getSourceActive?.() ?? true
|
||||
);
|
||||
commandEncoder.copyTextureToBuffer(
|
||||
{ texture },
|
||||
{ buffer: output, bytesPerRow, rowsPerImage: height },
|
||||
{ width, height }
|
||||
);
|
||||
this.device.queue.submit([commandEncoder.finish()]);
|
||||
|
||||
await output.mapAsync(GPUMapMode.READ);
|
||||
isOutputMapped = true;
|
||||
const pixels = readSnapshotPixels({
|
||||
mapped: new Uint8Array(output.getMappedRange()),
|
||||
width,
|
||||
height,
|
||||
unpaddedBytesPerRow,
|
||||
bytesPerRow,
|
||||
isBgra: this.options.canvasFormat === 'bgra8unorm',
|
||||
});
|
||||
output.unmap();
|
||||
isOutputMapped = false;
|
||||
output.destroy();
|
||||
output = null;
|
||||
texture.destroy();
|
||||
texture = null;
|
||||
|
||||
await this.downloadPixels(pixels, width, height);
|
||||
} finally {
|
||||
if (output && isOutputMapped) {
|
||||
output.unmap();
|
||||
}
|
||||
output?.destroy();
|
||||
texture?.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
private async downloadPixels(
|
||||
pixels: Uint8ClampedArray<ArrayBuffer>,
|
||||
width: number,
|
||||
height: number
|
||||
): Promise<void> {
|
||||
const canvas = new OffscreenCanvas(width, height);
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('Could not create export canvas');
|
||||
}
|
||||
|
||||
context.putImageData(new ImageData(pixels, width, height), 0, 0);
|
||||
const blob = await canvas.convertToBlob({
|
||||
type: appConfig.exportSnapshot.mimeType,
|
||||
});
|
||||
const link = document.createElement('a');
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
try {
|
||||
link.href = objectUrl;
|
||||
link.download = `${appConfig.exportSnapshot.filenamePrefix}_${this.options.getVibeId()}_${
|
||||
this.options.seed
|
||||
}_${width}x${height}${appConfig.exportSnapshot.filenameSuffix}.${appConfig.exportSnapshot.filenameExtension}`;
|
||||
link.click();
|
||||
} finally {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
}
|
||||
|
||||
private get device(): GPUDevice {
|
||||
return this.options.device;
|
||||
}
|
||||
|
||||
private get statusElement(): HTMLElement {
|
||||
return this.options.statusElement;
|
||||
}
|
||||
}
|
||||
|
||||
const alignTo = (value: number, alignment: number): number =>
|
||||
Math.ceil(value / alignment) * alignment;
|
||||
|
||||
const getSnapshotDimension = (value: number): number =>
|
||||
Number.isFinite(value) && value > 0 ? Math.max(1, Math.floor(value)) : 1;
|
||||
|
||||
const getSnapshotLayout = (sourceWidth: number, sourceHeight: number): SnapshotLayout => {
|
||||
const width = getSnapshotDimension(sourceWidth);
|
||||
const height = getSnapshotDimension(sourceHeight);
|
||||
const unpaddedBytesPerRow = width * appConfig.exportSnapshot.bytesPerPixel;
|
||||
const bytesPerRow = alignTo(
|
||||
unpaddedBytesPerRow,
|
||||
appConfig.exportSnapshot.rowAlignmentBytes
|
||||
);
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
unpaddedBytesPerRow,
|
||||
bytesPerRow,
|
||||
readbackBufferBytes: bytesPerRow * height,
|
||||
};
|
||||
};
|
||||
|
||||
const readSnapshotPixels = ({
|
||||
mapped,
|
||||
width,
|
||||
height,
|
||||
unpaddedBytesPerRow,
|
||||
bytesPerRow,
|
||||
isBgra,
|
||||
}: {
|
||||
mapped: Uint8Array;
|
||||
width: number;
|
||||
height: number;
|
||||
unpaddedBytesPerRow: number;
|
||||
bytesPerRow: number;
|
||||
isBgra: boolean;
|
||||
}): Uint8ClampedArray<ArrayBuffer> => {
|
||||
const pixels: Uint8ClampedArray<ArrayBuffer> = new Uint8ClampedArray(
|
||||
unpaddedBytesPerRow * height
|
||||
);
|
||||
for (let y = 0; y < height; y++) {
|
||||
const sourceOffset = y * bytesPerRow;
|
||||
const targetOffset = y * unpaddedBytesPerRow;
|
||||
for (let x = 0; x < width; x++) {
|
||||
const source = sourceOffset + x * appConfig.exportSnapshot.bytesPerPixel;
|
||||
const target = targetOffset + x * appConfig.exportSnapshot.bytesPerPixel;
|
||||
pixels[target] = isBgra ? mapped[source + 2] : mapped[source];
|
||||
pixels[target + 1] = mapped[source + 1];
|
||||
pixels[target + 2] = isBgra ? mapped[source] : mapped[source + 2];
|
||||
pixels[target + 3] = mapped[source + 3];
|
||||
}
|
||||
}
|
||||
|
||||
return pixels;
|
||||
};
|
||||
61
src/game-loop/frame-performance.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { settings } from '../settings';
|
||||
|
||||
const ADAPTIVE_REFRESH_TARGET_FPS = 60;
|
||||
const ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND = 200_000;
|
||||
const FRAME_GAP_RESET_SECONDS = 1;
|
||||
const FPS_HEADROOM = 0.9;
|
||||
const FPS_SMOOTHING_NEW = 0.06;
|
||||
const FPS_SMOOTHING_RETAIN = 1 - FPS_SMOOTHING_NEW;
|
||||
|
||||
export class FramePerformance {
|
||||
public smoothedFps = ADAPTIVE_REFRESH_TARGET_FPS;
|
||||
public measuredFps = 0;
|
||||
public frameDeltaSeconds = 0;
|
||||
public measuredFrameTimeMs = 0;
|
||||
|
||||
private previousFrameTime: DOMHighResTimeStamp | null = null;
|
||||
|
||||
public get adaptiveCapInitial(): number {
|
||||
return settings.adaptiveCapInitial;
|
||||
}
|
||||
|
||||
public get adaptiveCapMin(): number {
|
||||
return settings.adaptiveCapMin;
|
||||
}
|
||||
|
||||
public get hasAdaptiveCapHeadroom(): boolean {
|
||||
return this.smoothedFps >= ADAPTIVE_REFRESH_TARGET_FPS * FPS_HEADROOM;
|
||||
}
|
||||
|
||||
public get adaptiveCapDecreaseAgents(): number {
|
||||
return Math.max(
|
||||
1,
|
||||
Math.ceil(ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND * this.frameDeltaSeconds)
|
||||
);
|
||||
}
|
||||
|
||||
public update(time: DOMHighResTimeStamp): void {
|
||||
const previous = this.previousFrameTime;
|
||||
this.previousFrameTime = time;
|
||||
if (previous === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaSeconds = (time - previous) / 1000;
|
||||
if (deltaSeconds <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.measuredFrameTimeMs = deltaSeconds * 1000;
|
||||
const fps = 1 / deltaSeconds;
|
||||
this.measuredFps = fps;
|
||||
if (deltaSeconds > FRAME_GAP_RESET_SECONDS) {
|
||||
this.frameDeltaSeconds = 0;
|
||||
this.smoothedFps = ADAPTIVE_REFRESH_TARGET_FPS;
|
||||
return;
|
||||
}
|
||||
|
||||
this.frameDeltaSeconds = deltaSeconds;
|
||||
this.smoothedFps = this.smoothedFps * FPS_SMOOTHING_RETAIN + fps * FPS_SMOOTHING_NEW;
|
||||
}
|
||||
}
|
||||