4
definitions.d.ts
vendored
|
|
@ -2,3 +2,7 @@ declare module '*.wgsl?raw' {
|
||||||
const content: string;
|
const content: string;
|
||||||
export default content;
|
export default content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface HTMLCanvasElement {
|
||||||
|
getContext(contextId: 'webgpu'): GPUCanvasContext | null;
|
||||||
|
}
|
||||||
|
|
|
||||||
290
e2e/app.spec.ts
|
|
@ -1,12 +1,77 @@
|
||||||
import { expect, test } from '@playwright/test';
|
import { expect, test, type Page } from '@playwright/test';
|
||||||
|
|
||||||
test('loads the app shell and WebGPU fallback in Chromium', async ({ page }) => {
|
type WebGpuFailureMode = 'adapter-null' | 'adapter-rejects' | 'device-rejects';
|
||||||
|
|
||||||
|
const disableWebGpu = async (page: Page) => {
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
Object.defineProperty(navigator, 'gpu', {
|
Object.defineProperty(navigator, 'gpu', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: undefined,
|
value: undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const emulateWebGpuFailure = async (page: Page, mode: WebGpuFailureMode) => {
|
||||||
|
await page.addInitScript((failureMode) => {
|
||||||
|
const limits = {
|
||||||
|
maxBufferSize: 256 * 1024 * 1024,
|
||||||
|
maxComputeWorkgroupsPerDimension: 65_535,
|
||||||
|
maxStorageBufferBindingSize: 128 * 1024 * 1024,
|
||||||
|
};
|
||||||
|
const adapter = {
|
||||||
|
features: new Set(),
|
||||||
|
info: {
|
||||||
|
architecture: 'test',
|
||||||
|
description: 'Playwright fake adapter',
|
||||||
|
device: 'test-device',
|
||||||
|
isFallbackAdapter: false,
|
||||||
|
subgroupMaxSize: 0,
|
||||||
|
subgroupMinSize: 0,
|
||||||
|
vendor: 'test-vendor',
|
||||||
|
},
|
||||||
|
limits,
|
||||||
|
requestDevice: async () => {
|
||||||
|
if (failureMode === 'device-rejects') {
|
||||||
|
throw new Error('Playwright fake device failure');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.defineProperty(navigator, 'gpu', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
getPreferredCanvasFormat: () => 'rgba8unorm',
|
||||||
|
requestAdapter: async () => {
|
||||||
|
if (failureMode === 'adapter-null') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failureMode === 'adapter-rejects') {
|
||||||
|
throw new Error('Playwright fake adapter failure');
|
||||||
|
}
|
||||||
|
|
||||||
|
return adapter;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, mode);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFirstSwatchColor = (page: Page) =>
|
||||||
|
page
|
||||||
|
.locator('.color-swatch')
|
||||||
|
.first()
|
||||||
|
.evaluate((element) => getComputedStyle(element).backgroundColor);
|
||||||
|
|
||||||
|
const getGardenBackground = (page: Page) =>
|
||||||
|
page.evaluate(() =>
|
||||||
|
document.documentElement.style.getPropertyValue('--garden-background').trim()
|
||||||
|
);
|
||||||
|
|
||||||
|
test('loads the app shell and WebGPU fallback in Chromium', async ({ page }) => {
|
||||||
|
await disableWebGpu(page);
|
||||||
|
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
|
||||||
|
|
@ -21,3 +86,224 @@ test('loads the app shell and WebGPU fallback in Chromium', async ({ page }) =>
|
||||||
await page.getByRole('button', { name: 'About' }).click();
|
await page.getByRole('button', { name: 'About' }).click();
|
||||||
await expect(page.getByRole('heading', { name: 'Fleeting Garden' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Fleeting Garden' })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('keeps fallback controls interactive and accessible', async ({ page }) => {
|
||||||
|
await disableWebGpu(page);
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
|
||||||
|
|
||||||
|
const aboutButton = page.getByRole('button', { name: 'About' });
|
||||||
|
const aboutPanel = page.locator('#info-panel');
|
||||||
|
await expect(aboutButton).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
await aboutButton.click();
|
||||||
|
await expect(aboutButton).toHaveAttribute('aria-expanded', 'true');
|
||||||
|
await expect(aboutPanel).toHaveAttribute('aria-hidden', 'false');
|
||||||
|
await expect(aboutPanel).not.toHaveAttribute('inert', '');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Fleeting Garden' })).toBeVisible();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(aboutButton).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
await expect(aboutPanel).toHaveAttribute('aria-hidden', 'true');
|
||||||
|
await expect(aboutPanel).toHaveAttribute('inert', '');
|
||||||
|
|
||||||
|
const settingsButton = page.getByRole('button', { name: 'Show config overlay' });
|
||||||
|
await settingsButton.click();
|
||||||
|
await expect(page.getByRole('button', { name: 'Hide config overlay' })).toHaveAttribute(
|
||||||
|
'aria-expanded',
|
||||||
|
'true'
|
||||||
|
);
|
||||||
|
await expect(page.locator('.config-pane')).toBeVisible();
|
||||||
|
|
||||||
|
const soundButton = page.locator('button.sound');
|
||||||
|
await expect(soundButton).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
await soundButton.click();
|
||||||
|
await expect(soundButton).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await expect(soundButton).toHaveAttribute('aria-label', 'Unmute audio');
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
|
||||||
|
await expect(page.locator('button.sound')).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
|
||||||
|
const initialSwatchColor = await getFirstSwatchColor(page);
|
||||||
|
const initialBackground = await getGardenBackground(page);
|
||||||
|
await page.getByRole('button', { name: 'Next vibe' }).click();
|
||||||
|
await expect.poll(() => getFirstSwatchColor(page)).not.toBe(initialSwatchColor);
|
||||||
|
await expect.poll(() => getGardenBackground(page)).not.toBe(initialBackground);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Draw colour 2' }).click();
|
||||||
|
await expect(page.locator('.color-swatch').nth(1)).toHaveClass(/active/);
|
||||||
|
await expect(page.locator('.color-swatch').first()).not.toHaveClass(/active/);
|
||||||
|
|
||||||
|
const mirrorSlider = page.locator('.mirror-segment-slider');
|
||||||
|
await mirrorSlider.evaluate((input) => {
|
||||||
|
const slider = input as HTMLInputElement;
|
||||||
|
slider.value = '3';
|
||||||
|
slider.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
});
|
||||||
|
await expect(page.locator('.mirror-segment-control')).toHaveAttribute(
|
||||||
|
'title',
|
||||||
|
'3 thirds'
|
||||||
|
);
|
||||||
|
await expect(page.locator('.mirror-segment-control')).toHaveClass(/active/);
|
||||||
|
});
|
||||||
|
|
||||||
|
(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
expectedCode: 'webgpu-adapter-unavailable',
|
||||||
|
expectedMessage:
|
||||||
|
'WebGPU is available, but this browser could not provide a compatible GPU adapter.',
|
||||||
|
mode: 'adapter-null',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expectedCode: 'webgpu-adapter-unavailable',
|
||||||
|
expectedMessage: 'Could not request a WebGPU adapter.',
|
||||||
|
mode: 'adapter-rejects',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expectedCode: 'webgpu-device-unavailable',
|
||||||
|
expectedMessage: 'Could not create a WebGPU device for this adapter.',
|
||||||
|
mode: 'device-rejects',
|
||||||
|
},
|
||||||
|
] satisfies Array<{
|
||||||
|
expectedCode: string;
|
||||||
|
expectedMessage: string;
|
||||||
|
mode: WebGpuFailureMode;
|
||||||
|
}>
|
||||||
|
).forEach(({ expectedCode, expectedMessage, mode }) => {
|
||||||
|
test(`reports ${mode} startup failures without leaving the shell loading`, async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await emulateWebGpuFailure(page, mode);
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
|
||||||
|
await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('alert')).toContainText(expectedMessage);
|
||||||
|
await expect(page.getByRole('alert')).toContainText(expectedCode);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('serves the production bundle without missing browser assets', async ({ page }) => {
|
||||||
|
const browserFailures: Array<string> = [];
|
||||||
|
|
||||||
|
page.on('requestfailed', (request) => {
|
||||||
|
const failure = request.failure();
|
||||||
|
browserFailures.push(`${request.method()} ${request.url()} ${failure?.errorText}`);
|
||||||
|
});
|
||||||
|
page.on('response', (response) => {
|
||||||
|
if (response.status() >= 400) {
|
||||||
|
browserFailures.push(`${response.status()} ${response.url()}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await disableWebGpu(page);
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
|
||||||
|
|
||||||
|
expect(browserFailures).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
[
|
||||||
|
{ height: 720, name: 'desktop', width: 1280 },
|
||||||
|
{ height: 844, name: 'mobile', width: 390 },
|
||||||
|
].forEach(({ height, name, width }) => {
|
||||||
|
test(`keeps the fallback shell usable on ${name}`, async ({ page }) => {
|
||||||
|
await page.setViewportSize({ height, width });
|
||||||
|
await disableWebGpu(page);
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
|
||||||
|
|
||||||
|
const canvasBox = await page
|
||||||
|
.getByRole('img', { name: 'Interactive generative garden canvas' })
|
||||||
|
.boundingBox();
|
||||||
|
expect(canvasBox?.width).toBeGreaterThan(0);
|
||||||
|
expect(canvasBox?.height).toBeGreaterThan(0);
|
||||||
|
await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'About' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('alert')).toContainText('Fleeting Garden needs WebGPU');
|
||||||
|
|
||||||
|
const aboutButtonReceivesPointer = await page
|
||||||
|
.getByRole('button', { name: 'About' })
|
||||||
|
.evaluate((button) => {
|
||||||
|
const rect = button.getBoundingClientRect();
|
||||||
|
const target = document.elementFromPoint(
|
||||||
|
rect.left + rect.width / 2,
|
||||||
|
rect.top + rect.height / 2
|
||||||
|
);
|
||||||
|
|
||||||
|
return button === target || button.contains(target);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(aboutButtonReceivesPointer).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hides the bottom dock after the cursor leaves fullscreen controls', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await disableWebGpu(page);
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Enter fullscreen' }).click();
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate(() => Boolean(document.fullscreenElement)))
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
await page.mouse.move(640, 120);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
if (document.activeElement instanceof HTMLElement) {
|
||||||
|
document.activeElement.blur();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(page.locator('aside.control-dock')).toHaveClass(/menu-hidden/, {
|
||||||
|
timeout: 6000,
|
||||||
|
});
|
||||||
|
await expect(page.locator('.garden-controls')).not.toBeVisible();
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page
|
||||||
|
.locator('aside.control-dock')
|
||||||
|
.evaluate((dock) => dock.getBoundingClientRect().top >= window.innerHeight)
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
await page.mouse.move(640, 700);
|
||||||
|
await expect(page.locator('aside.control-dock')).not.toHaveClass(/menu-hidden/);
|
||||||
|
await expect(page.locator('.garden-controls')).toBeVisible();
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page
|
||||||
|
.locator('aside.control-dock')
|
||||||
|
.evaluate((dock) => dock.getBoundingClientRect().bottom <= window.innerHeight)
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps the bottom dock visible in mobile fullscreen', async ({ page }) => {
|
||||||
|
await page.setViewportSize({ height: 844, width: 390 });
|
||||||
|
await disableWebGpu(page);
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Enter fullscreen' }).click();
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate(() => Boolean(document.fullscreenElement)))
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
await page.mouse.move(195, 120);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
if (document.activeElement instanceof HTMLElement) {
|
||||||
|
document.activeElement.blur();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(5200);
|
||||||
|
|
||||||
|
await expect(page.locator('aside.control-dock')).not.toHaveClass(/menu-hidden/);
|
||||||
|
await expect(page.getByRole('button', { name: 'Show config overlay' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 914 B After Width: | Height: | Size: 892 B |
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
<g clip-path="url(#icon-clip)">
|
<g clip-path="url(#icon-clip)">
|
||||||
<rect width="64" height="64" fill="#10151f" />
|
<rect width="64" height="64" fill="#10151f" />
|
||||||
<path d="M0 64a32 32 0 0 1 64 0Z" fill="#ffd84d" />
|
<path d="M0 64a32 32 0 0 1 64 0Z" fill="#40d6c8" />
|
||||||
<path
|
<path
|
||||||
d="M32 34c1.2-7.2 4.8-12.3 10-16"
|
d="M32 34c1.2-7.2 4.8-12.3 10-16"
|
||||||
fill="none"
|
fill="none"
|
||||||
|
|
@ -18,14 +18,14 @@
|
||||||
<path
|
<path
|
||||||
d="M32 34c1.2-7.2 4.8-12.3 10-16"
|
d="M32 34c1.2-7.2 4.8-12.3 10-16"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="#ff2fa3"
|
stroke="#ff5da2"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-width="4"
|
stroke-width="4"
|
||||||
/>
|
/>
|
||||||
<ellipse cx="42" cy="11.5" rx="4.2" ry="6.4" fill="#ff2fa3" />
|
<ellipse cx="42" cy="11.5" rx="4.2" ry="6.4" fill="#ff5da2" />
|
||||||
<ellipse cx="48.5" cy="18" rx="6.4" ry="4.2" fill="#ff2fa3" />
|
<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="#ff2fa3" />
|
<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="#ff2fa3" />
|
<ellipse cx="35.5" cy="18" rx="6.4" ry="4.2" fill="#ff5da2" />
|
||||||
<circle cx="42" cy="18" r="3.2" fill="#10151f" />
|
<circle cx="42" cy="18" r="3.2" fill="#10151f" />
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 950 B After Width: | Height: | Size: 950 B |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 709 B After Width: | Height: | Size: 690 B |
|
|
@ -33,16 +33,23 @@ const resolveModule = (fromFile, specifier) => {
|
||||||
base.endsWith('.ts') ? base : null,
|
base.endsWith('.ts') ? base : null,
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|
||||||
return candidates.find((candidate) => existsSync(candidate) && fileSet.has(candidate)) ?? null;
|
return (
|
||||||
|
candidates.find((candidate) => existsSync(candidate) && fileSet.has(candidate)) ??
|
||||||
|
null
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const exportKey = (file, name) => `${path.resolve(file)}:${name}`;
|
const exportKey = (file, name) => `${path.resolve(file)}:${name}`;
|
||||||
const isExported = (node) =>
|
const isExported = (node) =>
|
||||||
ts.canHaveModifiers(node) &&
|
ts.canHaveModifiers(node) &&
|
||||||
(ts.getModifiers(node) ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword);
|
(ts.getModifiers(node) ?? []).some(
|
||||||
|
(modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword
|
||||||
|
);
|
||||||
const isDefaultExported = (node) =>
|
const isDefaultExported = (node) =>
|
||||||
ts.canHaveModifiers(node) &&
|
ts.canHaveModifiers(node) &&
|
||||||
(ts.getModifiers(node) ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword);
|
(ts.getModifiers(node) ?? []).some(
|
||||||
|
(modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword
|
||||||
|
);
|
||||||
|
|
||||||
const exportedDeclarations = new Map();
|
const exportedDeclarations = new Map();
|
||||||
const usedExports = new Set();
|
const usedExports = new Set();
|
||||||
|
|
@ -167,10 +174,15 @@ const parsedFiles = files.map((file) => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
parsedFiles.forEach(({ file, sourceFile }) => collectImportUsage(file, sourceFile));
|
parsedFiles.forEach(({ file, sourceFile }) => collectImportUsage(file, sourceFile));
|
||||||
parsedFiles.forEach(({ file, sourceFile }) => collectExportedDeclarations(file, sourceFile));
|
parsedFiles.forEach(({ file, sourceFile }) =>
|
||||||
|
collectExportedDeclarations(file, sourceFile)
|
||||||
|
);
|
||||||
|
|
||||||
const unusedExports = Array.from(exportedDeclarations.entries())
|
const unusedExports = Array.from(exportedDeclarations.entries())
|
||||||
.filter(([key, declaration]) => !usedExports.has(key) && !wildcardUsedFiles.has(declaration.file))
|
.filter(
|
||||||
|
([key, declaration]) =>
|
||||||
|
!usedExports.has(key) && !wildcardUsedFiles.has(declaration.file)
|
||||||
|
)
|
||||||
.map(([, declaration]) => declaration)
|
.map(([, declaration]) => declaration)
|
||||||
.sort((left, right) =>
|
.sort((left, right) =>
|
||||||
`${left.file}:${left.name}`.localeCompare(`${right.file}:${right.name}`)
|
`${left.file}:${left.name}`.localeCompare(`${right.file}:${right.name}`)
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { appConfig } from '../config';
|
||||||
import { GardenAudioEnergy } from './garden-audio-energy';
|
import { GardenAudioEnergy } from './garden-audio-energy';
|
||||||
|
|
||||||
describe('GardenAudioEnergy', () => {
|
describe('GardenAudioEnergy', () => {
|
||||||
it('suspends activity but keeps a fading level when the gesture ends', () => {
|
it('suspends activity but keeps a fading level when the gesture ends', () => {
|
||||||
const energy = new GardenAudioEnergy();
|
const energy = new GardenAudioEnergy(appConfig.audioEngine);
|
||||||
|
|
||||||
energy.beginGesture(0);
|
energy.beginGesture(0);
|
||||||
energy.recordStroke(0.8, 0.1);
|
energy.recordStroke(0.8, 0.1);
|
||||||
|
|
@ -24,7 +25,7 @@ describe('GardenAudioEnergy', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses recent stroke intensity rather than gesture duration alone', () => {
|
it('uses recent stroke intensity rather than gesture duration alone', () => {
|
||||||
const energy = new GardenAudioEnergy();
|
const energy = new GardenAudioEnergy(appConfig.audioEngine);
|
||||||
|
|
||||||
energy.beginGesture(0);
|
energy.beginGesture(0);
|
||||||
energy.recordStroke(1, 0.1);
|
energy.recordStroke(1, 0.1);
|
||||||
|
|
@ -38,7 +39,7 @@ describe('GardenAudioEnergy', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('raises activity immediately when a stroke is recorded', () => {
|
it('raises activity immediately when a stroke is recorded', () => {
|
||||||
const energy = new GardenAudioEnergy();
|
const energy = new GardenAudioEnergy(appConfig.audioEngine);
|
||||||
|
|
||||||
energy.beginGesture(0);
|
energy.beginGesture(0);
|
||||||
energy.recordStroke(0.12, 0.05);
|
energy.recordStroke(0.12, 0.05);
|
||||||
|
|
|
||||||
385
src/audio/garden-audio-gesture-state.ts
Normal file
|
|
@ -0,0 +1,385 @@
|
||||||
|
import type { GardenAudioEngineConfig } from '../config';
|
||||||
|
import { clamp, clamp01 } from '../utils/clamp';
|
||||||
|
import type {
|
||||||
|
GardenAudioColorIndex,
|
||||||
|
GardenAudioStroke,
|
||||||
|
GardenAudioTouchDown,
|
||||||
|
} from './garden-audio-types';
|
||||||
|
import type { GardenAudioStrokeMetrics } from './garden-audio-input';
|
||||||
|
|
||||||
|
type GardenAudioGestureMode = 'calm' | 'active' | 'manic' | 'afterglow';
|
||||||
|
|
||||||
|
interface GardenAudioGestureFrame {
|
||||||
|
mode: GardenAudioGestureMode;
|
||||||
|
activity: number;
|
||||||
|
maniaAmount: number;
|
||||||
|
panBias: number;
|
||||||
|
registerBias: number;
|
||||||
|
brightnessBias: number;
|
||||||
|
contour: number;
|
||||||
|
pressure: number;
|
||||||
|
pressureDelta: number;
|
||||||
|
mirrorAmount: number;
|
||||||
|
speedAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GestureSample {
|
||||||
|
at: number;
|
||||||
|
speed: number;
|
||||||
|
acceleration: number;
|
||||||
|
distancePixels: number;
|
||||||
|
turned: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WINDOW_SECONDS = 0.75;
|
||||||
|
const BIN_SECONDS = 0.05;
|
||||||
|
const MIN_TURN_DEGREES = 55;
|
||||||
|
const MIN_TURN_DISTANCE_PIXELS = 6;
|
||||||
|
|
||||||
|
const DEFAULT_FRAME: GardenAudioGestureFrame = {
|
||||||
|
mode: 'calm',
|
||||||
|
activity: 0,
|
||||||
|
maniaAmount: 0,
|
||||||
|
panBias: 0,
|
||||||
|
registerBias: 0,
|
||||||
|
brightnessBias: 0,
|
||||||
|
contour: 0,
|
||||||
|
pressure: 0,
|
||||||
|
pressureDelta: 0,
|
||||||
|
mirrorAmount: 0,
|
||||||
|
speedAmount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export class GardenAudioGestureState {
|
||||||
|
private readonly samples: Array<GestureSample> = [];
|
||||||
|
private gestureClockSeconds = 0;
|
||||||
|
private isGestureActive = false;
|
||||||
|
private previousPressure = 0;
|
||||||
|
private previousVelocityPixelsPerSecond = 0;
|
||||||
|
private previousVector: [number, number] | null = null;
|
||||||
|
private maniaAmount = 0;
|
||||||
|
private peakActivity = 0;
|
||||||
|
private lastFrame: GardenAudioGestureFrame = DEFAULT_FRAME;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly speedForFullEnergyPixelsPerSecond: number,
|
||||||
|
private readonly inputConfig: GardenAudioEngineConfig['input']
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public beginGesture(): void {
|
||||||
|
this.samples.length = 0;
|
||||||
|
this.gestureClockSeconds = 0;
|
||||||
|
this.isGestureActive = true;
|
||||||
|
this.previousPressure = 0;
|
||||||
|
this.previousVelocityPixelsPerSecond = 0;
|
||||||
|
this.previousVector = null;
|
||||||
|
this.maniaAmount = 0;
|
||||||
|
this.peakActivity = 0;
|
||||||
|
this.lastFrame = DEFAULT_FRAME;
|
||||||
|
}
|
||||||
|
|
||||||
|
public endGesture(): GardenAudioGestureFrame {
|
||||||
|
this.isGestureActive = false;
|
||||||
|
this.samples.length = 0;
|
||||||
|
this.previousVector = null;
|
||||||
|
this.previousVelocityPixelsPerSecond = 0;
|
||||||
|
this.maniaAmount = 0;
|
||||||
|
this.lastFrame = {
|
||||||
|
...this.lastFrame,
|
||||||
|
mode: this.peakActivity >= 0.42 ? 'afterglow' : 'calm',
|
||||||
|
activity: 0,
|
||||||
|
maniaAmount: 0,
|
||||||
|
speedAmount: 0,
|
||||||
|
};
|
||||||
|
return this.lastFrame;
|
||||||
|
}
|
||||||
|
|
||||||
|
public recordTouchDown({
|
||||||
|
touch,
|
||||||
|
colorIndex,
|
||||||
|
mirrorAmount,
|
||||||
|
pressure,
|
||||||
|
strength,
|
||||||
|
}: {
|
||||||
|
touch: GardenAudioTouchDown;
|
||||||
|
colorIndex: GardenAudioColorIndex;
|
||||||
|
mirrorAmount: number;
|
||||||
|
pressure: number;
|
||||||
|
strength: number;
|
||||||
|
}): GardenAudioGestureFrame {
|
||||||
|
const spatial = getSpatialBias(touch.position, touch.canvasSize);
|
||||||
|
const normalizedStrength = clamp01(strength);
|
||||||
|
|
||||||
|
this.previousPressure = pressure;
|
||||||
|
this.peakActivity = Math.max(this.peakActivity, normalizedStrength);
|
||||||
|
this.lastFrame = {
|
||||||
|
mode: normalizedStrength >= 0.38 ? 'active' : 'calm',
|
||||||
|
activity: normalizedStrength,
|
||||||
|
maniaAmount: 0,
|
||||||
|
panBias: spatial.panBias,
|
||||||
|
registerBias: spatial.registerBias,
|
||||||
|
brightnessBias: spatial.brightnessBias,
|
||||||
|
contour: colorIndex === 2 ? 0.25 : colorIndex === 0 ? -0.15 : 0,
|
||||||
|
pressure,
|
||||||
|
pressureDelta: 0,
|
||||||
|
mirrorAmount,
|
||||||
|
speedAmount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.lastFrame;
|
||||||
|
}
|
||||||
|
|
||||||
|
public recordStroke({
|
||||||
|
stroke,
|
||||||
|
metrics,
|
||||||
|
mirrorAmount,
|
||||||
|
}: {
|
||||||
|
stroke: GardenAudioStroke;
|
||||||
|
metrics: GardenAudioStrokeMetrics;
|
||||||
|
mirrorAmount: number;
|
||||||
|
}): GardenAudioGestureFrame {
|
||||||
|
const elapsedSeconds = this.getElapsedSeconds(stroke);
|
||||||
|
this.gestureClockSeconds += elapsedSeconds;
|
||||||
|
|
||||||
|
const dx = stroke.to[0] - stroke.from[0];
|
||||||
|
const dy = stroke.to[1] - stroke.from[1];
|
||||||
|
const distancePixels = metrics.distancePixels;
|
||||||
|
const speedRatio =
|
||||||
|
metrics.speedPixelsPerSecond /
|
||||||
|
Math.max(1, this.speedForFullEnergyPixelsPerSecond);
|
||||||
|
const speed = smoothstep(0.45, 1.2, speedRatio);
|
||||||
|
const acceleration = smoothstep(
|
||||||
|
3,
|
||||||
|
12,
|
||||||
|
Math.abs(metrics.speedPixelsPerSecond - this.previousVelocityPixelsPerSecond) /
|
||||||
|
(Math.max(1, this.speedForFullEnergyPixelsPerSecond) * elapsedSeconds)
|
||||||
|
);
|
||||||
|
const currentVector: [number, number] =
|
||||||
|
distancePixels > 0.001 ? [dx / distancePixels, dy / distancePixels] : [0, 0];
|
||||||
|
const turned = this.getTurned(currentVector, distancePixels, metrics.speedAmount);
|
||||||
|
const spatial = getSpatialBias(stroke.to, stroke.canvasSize);
|
||||||
|
const pressureDelta = clamp(metrics.pressure - this.previousPressure, -1, 1);
|
||||||
|
const contour = distancePixels > 0.001 ? clamp(-dy / distancePixels, -1, 1) : 0;
|
||||||
|
|
||||||
|
if (distancePixels > 0.5) {
|
||||||
|
this.samples.push({
|
||||||
|
at: this.gestureClockSeconds,
|
||||||
|
speed,
|
||||||
|
acceleration,
|
||||||
|
distancePixels,
|
||||||
|
turned,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.trimSamples();
|
||||||
|
|
||||||
|
const features = this.getWindowFeatures();
|
||||||
|
const distanceFeature = smoothstep(10, 90, metrics.distancePixels);
|
||||||
|
const normalIntensity = clamp01(
|
||||||
|
0.1 +
|
||||||
|
features.speed * 0.46 +
|
||||||
|
metrics.pressure * 0.2 +
|
||||||
|
distanceFeature * 0.16 +
|
||||||
|
mirrorAmount * 0.08
|
||||||
|
);
|
||||||
|
const hasKineticChange = features.acceleration > 0.35 || features.turns > 0.35;
|
||||||
|
const maniaGate =
|
||||||
|
!stroke.isErasing &&
|
||||||
|
this.isGestureActive &&
|
||||||
|
this.gestureClockSeconds > 0.2 &&
|
||||||
|
features.pathPixels > 60 &&
|
||||||
|
features.speed > 0.45 &&
|
||||||
|
hasKineticChange;
|
||||||
|
const maniaEvidence = maniaGate
|
||||||
|
? clamp01(
|
||||||
|
features.speed * 0.34 +
|
||||||
|
features.acceleration * 0.26 +
|
||||||
|
features.strokeFrequency * 0.2 +
|
||||||
|
features.turns * 0.2
|
||||||
|
) *
|
||||||
|
(1 + mirrorAmount * 0.22)
|
||||||
|
: 0;
|
||||||
|
const maniaTarget = smoothstep(0.55, 0.85, maniaEvidence);
|
||||||
|
const timeConstant = maniaTarget > this.maniaAmount ? 0.12 : 0.65;
|
||||||
|
const maniaMove = 1 - Math.exp(-elapsedSeconds / timeConstant);
|
||||||
|
|
||||||
|
this.maniaAmount += (maniaTarget - this.maniaAmount) * maniaMove;
|
||||||
|
this.previousPressure = metrics.pressure;
|
||||||
|
this.previousVelocityPixelsPerSecond = metrics.speedPixelsPerSecond;
|
||||||
|
this.previousVector = currentVector;
|
||||||
|
|
||||||
|
const activity = clamp01(normalIntensity + this.maniaAmount * 0.28);
|
||||||
|
this.peakActivity = Math.max(this.peakActivity, activity);
|
||||||
|
this.lastFrame = {
|
||||||
|
mode: this.getMode(activity, this.maniaAmount),
|
||||||
|
activity,
|
||||||
|
maniaAmount: clamp01(this.maniaAmount),
|
||||||
|
panBias: spatial.panBias,
|
||||||
|
registerBias: spatial.registerBias,
|
||||||
|
brightnessBias: clamp01(
|
||||||
|
spatial.brightnessBias * 0.65 + metrics.pressure * 0.2 + speed * 0.15
|
||||||
|
),
|
||||||
|
contour,
|
||||||
|
pressure: metrics.pressure,
|
||||||
|
pressureDelta,
|
||||||
|
mirrorAmount,
|
||||||
|
speedAmount: metrics.speedAmount,
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.lastFrame;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getFrame(): GardenAudioGestureFrame {
|
||||||
|
return this.lastFrame;
|
||||||
|
}
|
||||||
|
|
||||||
|
public reset(): void {
|
||||||
|
this.samples.length = 0;
|
||||||
|
this.gestureClockSeconds = 0;
|
||||||
|
this.isGestureActive = false;
|
||||||
|
this.previousPressure = 0;
|
||||||
|
this.previousVelocityPixelsPerSecond = 0;
|
||||||
|
this.previousVector = null;
|
||||||
|
this.maniaAmount = 0;
|
||||||
|
this.peakActivity = 0;
|
||||||
|
this.lastFrame = DEFAULT_FRAME;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getElapsedSeconds(stroke: GardenAudioStroke): number {
|
||||||
|
if (
|
||||||
|
stroke.elapsedSeconds !== undefined &&
|
||||||
|
Number.isFinite(stroke.elapsedSeconds) &&
|
||||||
|
stroke.elapsedSeconds > 0
|
||||||
|
) {
|
||||||
|
return clamp(stroke.elapsedSeconds, 0.001, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.inputConfig.fallbackFrameSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTurned(
|
||||||
|
currentVector: [number, number],
|
||||||
|
distancePixels: number,
|
||||||
|
speedAmount: number
|
||||||
|
): boolean {
|
||||||
|
if (
|
||||||
|
!this.previousVector ||
|
||||||
|
distancePixels <= MIN_TURN_DISTANCE_PIXELS ||
|
||||||
|
speedAmount <= 0.35
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dot = clamp(
|
||||||
|
this.previousVector[0] * currentVector[0] +
|
||||||
|
this.previousVector[1] * currentVector[1],
|
||||||
|
-1,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
const degrees = (Math.acos(dot) * 180) / Math.PI;
|
||||||
|
return degrees > MIN_TURN_DEGREES;
|
||||||
|
}
|
||||||
|
|
||||||
|
private trimSamples(): void {
|
||||||
|
const earliest = this.gestureClockSeconds - WINDOW_SECONDS;
|
||||||
|
while (this.samples.length > 0 && this.samples[0].at < earliest) {
|
||||||
|
this.samples.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getWindowFeatures(): {
|
||||||
|
speed: number;
|
||||||
|
acceleration: number;
|
||||||
|
strokeFrequency: number;
|
||||||
|
turns: number;
|
||||||
|
pathPixels: number;
|
||||||
|
} {
|
||||||
|
if (this.samples.length === 0) {
|
||||||
|
return {
|
||||||
|
speed: 0,
|
||||||
|
acceleration: 0,
|
||||||
|
strokeFrequency: 0,
|
||||||
|
turns: 0,
|
||||||
|
pathPixels: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = this.samples[0];
|
||||||
|
const last = this.samples[this.samples.length - 1];
|
||||||
|
const spanSeconds = clamp(last.at - first.at, 0.2, WINDOW_SECONDS);
|
||||||
|
const bins = new Set<number>();
|
||||||
|
let pathPixels = 0;
|
||||||
|
let turnCount = 0;
|
||||||
|
|
||||||
|
this.samples.forEach((sample) => {
|
||||||
|
if (sample.distancePixels > 1) {
|
||||||
|
bins.add(Math.floor(sample.at / BIN_SECONDS));
|
||||||
|
}
|
||||||
|
if (sample.turned) {
|
||||||
|
turnCount += 1;
|
||||||
|
}
|
||||||
|
pathPixels += sample.distancePixels;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
speed: percentile(this.samples.map((sample) => sample.speed), 0.75),
|
||||||
|
acceleration: percentile(
|
||||||
|
this.samples.map((sample) => sample.acceleration),
|
||||||
|
0.75
|
||||||
|
),
|
||||||
|
strokeFrequency: smoothstep(6, 14, bins.size / spanSeconds),
|
||||||
|
turns: smoothstep(2, 7, turnCount / spanSeconds),
|
||||||
|
pathPixels,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMode(activity: number, maniaAmount: number): GardenAudioGestureMode {
|
||||||
|
if (maniaAmount >= 0.72) {
|
||||||
|
return 'manic';
|
||||||
|
}
|
||||||
|
|
||||||
|
return activity >= 0.38 ? 'active' : 'calm';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSpatialBias = (
|
||||||
|
position: ArrayLike<number> | undefined,
|
||||||
|
canvasSize: ArrayLike<number> | undefined
|
||||||
|
): {
|
||||||
|
panBias: number;
|
||||||
|
registerBias: number;
|
||||||
|
brightnessBias: number;
|
||||||
|
} => {
|
||||||
|
if (!position || !canvasSize) {
|
||||||
|
return {
|
||||||
|
panBias: 0,
|
||||||
|
registerBias: 0,
|
||||||
|
brightnessBias: 0.5,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = Math.max(1, canvasSize[0]);
|
||||||
|
const height = Math.max(1, canvasSize[1]);
|
||||||
|
const x = clamp01(position[0] / width);
|
||||||
|
const y = clamp01(position[1] / height);
|
||||||
|
|
||||||
|
return {
|
||||||
|
panBias: clamp(x * 2 - 1, -1, 1),
|
||||||
|
registerBias: clamp(1 - y * 2, -1, 1),
|
||||||
|
brightnessBias: clamp01(1 - y * 0.72),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const percentile = (values: Array<number>, amount: number): number => {
|
||||||
|
if (values.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...values].sort((a, b) => a - b);
|
||||||
|
const index = clamp(Math.floor((sorted.length - 1) * amount), 0, sorted.length - 1);
|
||||||
|
return sorted[index];
|
||||||
|
};
|
||||||
|
|
||||||
|
const smoothstep = (edge0: number, edge1: number, value: number): number => {
|
||||||
|
const amount = clamp01((value - edge0) / (edge1 - edge0));
|
||||||
|
return amount * amount * (3 - 2 * amount);
|
||||||
|
};
|
||||||
|
|
@ -5,6 +5,7 @@ import { GardenAudioStroke } from './garden-audio-types';
|
||||||
export interface GardenAudioStrokeMetrics {
|
export interface GardenAudioStrokeMetrics {
|
||||||
distancePixels: number;
|
distancePixels: number;
|
||||||
pressure: number;
|
pressure: number;
|
||||||
|
speedPixelsPerSecond: number;
|
||||||
speedAmount: number;
|
speedAmount: number;
|
||||||
effectiveEnergy: number;
|
effectiveEnergy: number;
|
||||||
}
|
}
|
||||||
|
|
@ -35,6 +36,7 @@ export const getStrokeMetrics = (
|
||||||
return {
|
return {
|
||||||
distancePixels,
|
distancePixels,
|
||||||
pressure,
|
pressure,
|
||||||
|
speedPixelsPerSecond,
|
||||||
speedAmount,
|
speedAmount,
|
||||||
effectiveEnergy,
|
effectiveEnergy,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ export interface GardenAudioStroke {
|
||||||
isErasing: boolean;
|
isErasing: boolean;
|
||||||
pressure?: number;
|
pressure?: number;
|
||||||
velocityPixelsPerSecond?: number;
|
velocityPixelsPerSecond?: number;
|
||||||
|
elapsedSeconds?: number;
|
||||||
eraserSizePixels?: number;
|
eraserSizePixels?: number;
|
||||||
mirrorSegmentCount?: number;
|
mirrorSegmentCount?: number;
|
||||||
pointerType?: string;
|
pointerType?: string;
|
||||||
|
|
@ -26,6 +27,8 @@ export interface GardenAudioStroke {
|
||||||
export interface GardenAudioTouchDown {
|
export interface GardenAudioTouchDown {
|
||||||
vibe: VibePreset;
|
vibe: VibePreset;
|
||||||
colorIndex: number;
|
colorIndex: number;
|
||||||
|
position?: ArrayLike<number>;
|
||||||
|
canvasSize?: ArrayLike<number>;
|
||||||
mirrorSegmentCount?: number;
|
mirrorSegmentCount?: number;
|
||||||
pressure?: number;
|
pressure?: number;
|
||||||
pointerType?: string;
|
pointerType?: string;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { appConfig } from '../config';
|
||||||
import { VIBE_PRESETS } from '../vibes';
|
import { VIBE_PRESETS } from '../vibes';
|
||||||
import { GardenAudio } from './garden-audio';
|
import { GardenAudio } from './garden-audio';
|
||||||
import { gardenAudioConfig, GardenAudioConfig } from './garden-audio-config';
|
import { gardenAudioConfig, GardenAudioConfig } from './garden-audio-config';
|
||||||
|
|
@ -7,6 +8,7 @@ import { gardenAudioConfig, GardenAudioConfig } from './garden-audio-config';
|
||||||
const calls = {
|
const calls = {
|
||||||
constructed: 0,
|
constructed: 0,
|
||||||
resumed: 0,
|
resumed: 0,
|
||||||
|
sourcesStarted: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
let contextState: AudioContextState = 'suspended';
|
let contextState: AudioContextState = 'suspended';
|
||||||
|
|
@ -22,13 +24,16 @@ class FakeAudioParam {
|
||||||
class FakeAudioNode {
|
class FakeAudioNode {
|
||||||
public readonly gain = new FakeAudioParam();
|
public readonly gain = new FakeAudioParam();
|
||||||
public readonly frequency = new FakeAudioParam();
|
public readonly frequency = new FakeAudioParam();
|
||||||
|
public readonly Q = new FakeAudioParam();
|
||||||
public readonly threshold = new FakeAudioParam();
|
public readonly threshold = new FakeAudioParam();
|
||||||
public readonly knee = new FakeAudioParam();
|
public readonly knee = new FakeAudioParam();
|
||||||
public readonly ratio = new FakeAudioParam();
|
public readonly ratio = new FakeAudioParam();
|
||||||
public readonly attack = new FakeAudioParam();
|
public readonly attack = new FakeAudioParam();
|
||||||
public readonly release = new FakeAudioParam();
|
public readonly release = new FakeAudioParam();
|
||||||
public readonly delayTime = new FakeAudioParam();
|
public readonly delayTime = new FakeAudioParam();
|
||||||
|
public readonly pan = new FakeAudioParam();
|
||||||
public type = '';
|
public type = '';
|
||||||
|
public addEventListener = vi.fn();
|
||||||
public connect = vi.fn();
|
public connect = vi.fn();
|
||||||
public disconnect = vi.fn();
|
public disconnect = vi.fn();
|
||||||
}
|
}
|
||||||
|
|
@ -78,6 +83,10 @@ class FakeAudioContext {
|
||||||
return new FakeAudioNode() as unknown as DelayNode;
|
return new FakeAudioNode() as unknown as DelayNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public createStereoPanner(): StereoPannerNode {
|
||||||
|
return new FakeAudioNode() as unknown as StereoPannerNode;
|
||||||
|
}
|
||||||
|
|
||||||
public createBuffer(_channels: number, length: number): AudioBuffer {
|
public createBuffer(_channels: number, length: number): AudioBuffer {
|
||||||
return new FakeAudioBuffer(length) as unknown as AudioBuffer;
|
return new FakeAudioBuffer(length) as unknown as AudioBuffer;
|
||||||
}
|
}
|
||||||
|
|
@ -89,7 +98,9 @@ class FakeAudioContext {
|
||||||
stop: () => void;
|
stop: () => void;
|
||||||
};
|
};
|
||||||
node.buffer = null;
|
node.buffer = null;
|
||||||
node.start = vi.fn();
|
node.start = vi.fn(() => {
|
||||||
|
calls.sourcesStarted += 1;
|
||||||
|
});
|
||||||
node.stop = vi.fn();
|
node.stop = vi.fn();
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
@ -108,6 +119,7 @@ describe('GardenAudio startup policy', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
calls.constructed = 0;
|
calls.constructed = 0;
|
||||||
calls.resumed = 0;
|
calls.resumed = 0;
|
||||||
|
calls.sourcesStarted = 0;
|
||||||
contextState = 'suspended';
|
contextState = 'suspended';
|
||||||
vi.stubGlobal('AudioContext', FakeAudioContext);
|
vi.stubGlobal('AudioContext', FakeAudioContext);
|
||||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('not loaded in tests')));
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('not loaded in tests')));
|
||||||
|
|
@ -118,7 +130,11 @@ describe('GardenAudio startup policy', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not create an AudioContext from passive audio paths', () => {
|
it('does not create an AudioContext from passive audio paths', () => {
|
||||||
const audio = new GardenAudio(makeConfig());
|
const audio = new GardenAudio(
|
||||||
|
makeConfig(),
|
||||||
|
appConfig.audioEngine,
|
||||||
|
appConfig.simulation.maxMirrorSegmentCount
|
||||||
|
);
|
||||||
const vibe = VIBE_PRESETS[0];
|
const vibe = VIBE_PRESETS[0];
|
||||||
|
|
||||||
audio.start(vibe);
|
audio.start(vibe);
|
||||||
|
|
@ -135,7 +151,11 @@ describe('GardenAudio startup policy', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('only resumes a suspended context from a user gesture start', () => {
|
it('only resumes a suspended context from a user gesture start', () => {
|
||||||
const audio = new GardenAudio(makeConfig());
|
const audio = new GardenAudio(
|
||||||
|
makeConfig(),
|
||||||
|
appConfig.audioEngine,
|
||||||
|
appConfig.simulation.maxMirrorSegmentCount
|
||||||
|
);
|
||||||
const vibe = VIBE_PRESETS[0];
|
const vibe = VIBE_PRESETS[0];
|
||||||
|
|
||||||
audio.start(vibe, { userGesture: true });
|
audio.start(vibe, { userGesture: true });
|
||||||
|
|
@ -150,4 +170,51 @@ describe('GardenAudio startup policy', () => {
|
||||||
|
|
||||||
expect(calls.resumed).toBe(1);
|
expect(calls.resumed).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('skips cold piano fallback while preserving eraser noise', () => {
|
||||||
|
const audio = new GardenAudio(
|
||||||
|
makeConfig(),
|
||||||
|
appConfig.audioEngine,
|
||||||
|
appConfig.simulation.maxMirrorSegmentCount
|
||||||
|
);
|
||||||
|
const vibe = VIBE_PRESETS[0];
|
||||||
|
|
||||||
|
audio.start(vibe, { userGesture: true });
|
||||||
|
expect(calls.sourcesStarted).toBe(1);
|
||||||
|
|
||||||
|
audio.beginGesture();
|
||||||
|
audio.touchDown({
|
||||||
|
vibe,
|
||||||
|
colorIndex: 1,
|
||||||
|
position: [30, 40],
|
||||||
|
canvasSize: [100, 100],
|
||||||
|
pressure: 0.7,
|
||||||
|
});
|
||||||
|
audio.stroke({
|
||||||
|
vibe,
|
||||||
|
from: [30, 40],
|
||||||
|
to: [60, 60],
|
||||||
|
canvasSize: [100, 100],
|
||||||
|
colorIndex: 1,
|
||||||
|
isErasing: false,
|
||||||
|
pressure: 0.7,
|
||||||
|
velocityPixelsPerSecond: 1600,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(calls.sourcesStarted).toBe(1);
|
||||||
|
|
||||||
|
audio.stroke({
|
||||||
|
vibe,
|
||||||
|
from: [60, 60],
|
||||||
|
to: [75, 80],
|
||||||
|
canvasSize: [100, 100],
|
||||||
|
colorIndex: 1,
|
||||||
|
eraserSizePixels: 30,
|
||||||
|
isErasing: true,
|
||||||
|
pressure: 0.7,
|
||||||
|
velocityPixelsPerSecond: 1200,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(calls.sourcesStarted).toBe(2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { appConfig } from '../config';
|
import type { GardenAudioEngineConfig } from '../config';
|
||||||
import { clamp, clamp01 } from '../utils/clamp';
|
import { clamp, clamp01 } from '../utils/clamp';
|
||||||
import { VibePreset } from '../vibes';
|
import { VibePreset } from '../vibes';
|
||||||
import { GardenAudioConfig } from './garden-audio-config';
|
import { GardenAudioConfig } from './garden-audio-config';
|
||||||
import { GardenAudioEnergy } from './garden-audio-energy';
|
import { GardenAudioEnergy } from './garden-audio-energy';
|
||||||
|
import { GardenAudioGestureState } from './garden-audio-gesture-state';
|
||||||
import { GardenAudioGraph } from './garden-audio-graph';
|
import { GardenAudioGraph } from './garden-audio-graph';
|
||||||
import { GardenAudioStrokeMetrics, getStrokeMetrics } from './garden-audio-input';
|
import { getStrokeMetrics } from './garden-audio-input';
|
||||||
import { getVibeProfile, normalizeColorIndex } from './garden-audio-music';
|
import { getVibeProfile, normalizeColorIndex } from './garden-audio-music';
|
||||||
import type {
|
import type {
|
||||||
GardenAudioColorIndex,
|
GardenAudioColorIndex,
|
||||||
|
|
@ -29,6 +30,7 @@ export class GardenAudio {
|
||||||
private readonly piano: PianoSampler;
|
private readonly piano: PianoSampler;
|
||||||
private readonly noise: NoiseBurstPlayer;
|
private readonly noise: NoiseBurstPlayer;
|
||||||
private readonly energy: GardenAudioEnergy;
|
private readonly energy: GardenAudioEnergy;
|
||||||
|
private readonly gestureState: GardenAudioGestureState;
|
||||||
private readonly pianoEngine: GenerativePianoEngine;
|
private readonly pianoEngine: GenerativePianoEngine;
|
||||||
|
|
||||||
private currentVibeId: string | null = null;
|
private currentVibeId: string | null = null;
|
||||||
|
|
@ -41,12 +43,22 @@ export class GardenAudio {
|
||||||
private lastEraserAt = Number.NEGATIVE_INFINITY;
|
private lastEraserAt = Number.NEGATIVE_INFINITY;
|
||||||
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
||||||
|
|
||||||
public constructor(private readonly config: GardenAudioConfig) {
|
public constructor(
|
||||||
this.graph = new GardenAudioGraph(config);
|
private readonly config: GardenAudioConfig,
|
||||||
this.piano = new PianoSampler(config, this.graph);
|
private readonly engineConfig: GardenAudioEngineConfig,
|
||||||
this.noise = new NoiseBurstPlayer(this.graph);
|
private readonly maxMirrorSegmentCount: number
|
||||||
this.energy = new GardenAudioEnergy();
|
) {
|
||||||
this.pianoEngine = new GenerativePianoEngine(config, (note) => this.piano.play(note));
|
this.graph = new GardenAudioGraph(config, engineConfig);
|
||||||
|
this.piano = new PianoSampler(config, engineConfig, this.graph);
|
||||||
|
this.noise = new NoiseBurstPlayer(engineConfig, this.graph);
|
||||||
|
this.energy = new GardenAudioEnergy(engineConfig);
|
||||||
|
this.gestureState = new GardenAudioGestureState(
|
||||||
|
config.rhythm.speedForFullEnergyPixelsPerSecond,
|
||||||
|
engineConfig.input
|
||||||
|
);
|
||||||
|
this.pianoEngine = new GenerativePianoEngine(config, engineConfig, (note) =>
|
||||||
|
this.piano.play(note)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public start(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
|
public start(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
|
||||||
|
|
@ -76,7 +88,7 @@ export class GardenAudio {
|
||||||
this.graph.setMasterGain(
|
this.graph.setMasterGain(
|
||||||
this.config.masterVolume,
|
this.config.masterVolume,
|
||||||
options.userGesture === true
|
options.userGesture === true
|
||||||
? appConfig.audioEngine.muteRampSeconds
|
? this.engineConfig.muteRampSeconds
|
||||||
: this.config.fadeInSeconds
|
: this.config.fadeInSeconds
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -110,8 +122,8 @@ export class GardenAudio {
|
||||||
public setMuted(isMuted: boolean): void {
|
public setMuted(isMuted: boolean): void {
|
||||||
this.isMuted = isMuted;
|
this.isMuted = isMuted;
|
||||||
this.graph.setMasterGain(
|
this.graph.setMasterGain(
|
||||||
isMuted ? appConfig.audioEngine.muteGain : this.config.masterVolume,
|
isMuted ? this.engineConfig.muteGain : this.config.masterVolume,
|
||||||
isMuted ? appConfig.audioEngine.muteRampSeconds : this.config.fadeInSeconds
|
isMuted ? this.engineConfig.muteRampSeconds : this.config.fadeInSeconds
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,11 +134,13 @@ export class GardenAudio {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isGestureActive = true;
|
this.isGestureActive = true;
|
||||||
|
this.gestureState.beginGesture();
|
||||||
this.energy.beginGesture(context.currentTime);
|
this.energy.beginGesture(context.currentTime);
|
||||||
this.pianoEngine.beginGesture();
|
this.pianoEngine.beginGesture();
|
||||||
}
|
}
|
||||||
|
|
||||||
public endGesture(): void {
|
public endGesture(): void {
|
||||||
|
this.gestureState.endGesture();
|
||||||
this.isGestureActive = false;
|
this.isGestureActive = false;
|
||||||
this.energy.endGesture();
|
this.energy.endGesture();
|
||||||
this.pianoEngine.endGesture();
|
this.pianoEngine.endGesture();
|
||||||
|
|
@ -146,6 +160,13 @@ export class GardenAudio {
|
||||||
const mirrorAmount = this.getMirrorAmount(touch.mirrorSegmentCount ?? 1);
|
const mirrorAmount = this.getMirrorAmount(touch.mirrorSegmentCount ?? 1);
|
||||||
const pressure = this.getTouchPressure(touch.pressure, touch.pointerType);
|
const pressure = this.getTouchPressure(touch.pressure, touch.pointerType);
|
||||||
const strength = clamp01(0.36 + pressure * 0.34 + mirrorAmount * 0.22);
|
const strength = clamp01(0.36 + pressure * 0.34 + mirrorAmount * 0.22);
|
||||||
|
const frame = this.gestureState.recordTouchDown({
|
||||||
|
touch,
|
||||||
|
colorIndex: this.selectedColorIndex,
|
||||||
|
mirrorAmount,
|
||||||
|
pressure,
|
||||||
|
strength,
|
||||||
|
});
|
||||||
|
|
||||||
this.energy.recordStroke(strength, context.currentTime);
|
this.energy.recordStroke(strength, context.currentTime);
|
||||||
this.pianoEngine.recordTouchDown({
|
this.pianoEngine.recordTouchDown({
|
||||||
|
|
@ -154,6 +175,13 @@ export class GardenAudio {
|
||||||
strength,
|
strength,
|
||||||
selectedColorIndex: this.selectedColorIndex,
|
selectedColorIndex: this.selectedColorIndex,
|
||||||
mirrorAmount,
|
mirrorAmount,
|
||||||
|
panBias: frame.panBias,
|
||||||
|
registerBias: frame.registerBias,
|
||||||
|
brightnessBias: frame.brightnessBias,
|
||||||
|
contour: frame.contour,
|
||||||
|
pressureAmount: frame.pressure,
|
||||||
|
pressureDelta: frame.pressureDelta,
|
||||||
|
maniaAmount: frame.maniaAmount,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -197,7 +225,8 @@ export class GardenAudio {
|
||||||
const metrics = getStrokeMetrics(
|
const metrics = getStrokeMetrics(
|
||||||
stroke,
|
stroke,
|
||||||
this.config.rhythm.speedForFullEnergyPixelsPerSecond,
|
this.config.rhythm.speedForFullEnergyPixelsPerSecond,
|
||||||
this.config.input.pressureFallback
|
this.config.input.pressureFallback,
|
||||||
|
this.engineConfig.input
|
||||||
);
|
);
|
||||||
const now = context.currentTime;
|
const now = context.currentTime;
|
||||||
|
|
||||||
|
|
@ -210,7 +239,8 @@ export class GardenAudio {
|
||||||
}
|
}
|
||||||
|
|
||||||
const mirrorAmount = this.getMirrorAmount(stroke.mirrorSegmentCount ?? 1);
|
const mirrorAmount = this.getMirrorAmount(stroke.mirrorSegmentCount ?? 1);
|
||||||
const strokeEnergy = this.getStrokeMusicActivity(stroke, metrics, mirrorAmount);
|
const frame = this.gestureState.recordStroke({ stroke, metrics, mirrorAmount });
|
||||||
|
const strokeEnergy = frame.activity;
|
||||||
this.energy.recordStroke(strokeEnergy, now);
|
this.energy.recordStroke(strokeEnergy, now);
|
||||||
this.pianoEngine.recordStroke({
|
this.pianoEngine.recordStroke({
|
||||||
vibe: stroke.vibe,
|
vibe: stroke.vibe,
|
||||||
|
|
@ -218,6 +248,13 @@ export class GardenAudio {
|
||||||
activity: strokeEnergy,
|
activity: strokeEnergy,
|
||||||
selectedColorIndex: this.selectedColorIndex,
|
selectedColorIndex: this.selectedColorIndex,
|
||||||
mirrorAmount,
|
mirrorAmount,
|
||||||
|
panBias: frame.panBias,
|
||||||
|
registerBias: frame.registerBias,
|
||||||
|
brightnessBias: frame.brightnessBias,
|
||||||
|
contour: frame.contour,
|
||||||
|
pressureAmount: frame.pressure,
|
||||||
|
pressureDelta: frame.pressureDelta,
|
||||||
|
maniaAmount: frame.maniaAmount,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -227,6 +264,7 @@ export class GardenAudio {
|
||||||
|
|
||||||
this.piano.reset();
|
this.piano.reset();
|
||||||
this.energy.reset();
|
this.energy.reset();
|
||||||
|
this.gestureState.reset();
|
||||||
this.pianoEngine.reset();
|
this.pianoEngine.reset();
|
||||||
this.currentVibeId = null;
|
this.currentVibeId = null;
|
||||||
this.hasStarted = false;
|
this.hasStarted = false;
|
||||||
|
|
@ -246,7 +284,7 @@ export class GardenAudio {
|
||||||
const now = context.currentTime;
|
const now = context.currentTime;
|
||||||
if (
|
if (
|
||||||
now - this.lastVibeStingerAt <
|
now - this.lastVibeStingerAt <
|
||||||
appConfig.audioEngine.vibeChangeStingerMinIntervalSeconds
|
this.engineConfig.vibeChangeStingerMinIntervalSeconds
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -266,10 +304,10 @@ export class GardenAudio {
|
||||||
}
|
}
|
||||||
|
|
||||||
const sizeAmount = clamp01(
|
const sizeAmount = clamp01(
|
||||||
(stroke.eraserSizePixels ?? appConfig.audioEngine.eraser.defaultSizePixels) /
|
(stroke.eraserSizePixels ?? this.engineConfig.eraser.defaultSizePixels) /
|
||||||
Math.max(
|
Math.max(
|
||||||
1,
|
1,
|
||||||
stroke.canvasSize[0] * appConfig.audioEngine.eraser.canvasWidthRatioForFullSize
|
stroke.canvasSize[0] * this.engineConfig.eraser.canvasWidthRatioForFullSize
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const x = clamp01(stroke.to[0] / Math.max(1, stroke.canvasSize[0]));
|
const x = clamp01(stroke.to[0] / Math.max(1, stroke.canvasSize[0]));
|
||||||
|
|
@ -277,22 +315,22 @@ export class GardenAudio {
|
||||||
this.config.eraser.filterMinHz +
|
this.config.eraser.filterMinHz +
|
||||||
(this.config.eraser.filterMaxHz - this.config.eraser.filterMinHz) *
|
(this.config.eraser.filterMaxHz - this.config.eraser.filterMinHz) *
|
||||||
clamp01(
|
clamp01(
|
||||||
speedAmount * appConfig.audioEngine.eraser.filterSpeedWeight +
|
speedAmount * this.engineConfig.eraser.filterSpeedWeight +
|
||||||
pressure * appConfig.audioEngine.eraser.filterPressureWeight +
|
pressure * this.engineConfig.eraser.filterPressureWeight +
|
||||||
sizeAmount * appConfig.audioEngine.eraser.filterSizeWeight
|
sizeAmount * this.engineConfig.eraser.filterSizeWeight
|
||||||
);
|
);
|
||||||
|
|
||||||
if (now - this.lastEraserAt >= this.config.eraser.minIntervalSeconds) {
|
if (now - this.lastEraserAt >= this.config.eraser.minIntervalSeconds) {
|
||||||
this.lastEraserAt = now;
|
this.lastEraserAt = now;
|
||||||
this.noise.play({
|
this.noise.play({
|
||||||
startTime: now,
|
startTime: now,
|
||||||
durationSeconds: appConfig.audioEngine.eraser.durationSeconds,
|
durationSeconds: this.engineConfig.eraser.durationSeconds,
|
||||||
gain:
|
gain:
|
||||||
this.config.eraser.noiseGain *
|
this.config.eraser.noiseGain *
|
||||||
(appConfig.audioEngine.eraser.gainBase +
|
(this.engineConfig.eraser.gainBase +
|
||||||
speedAmount * appConfig.audioEngine.eraser.gainSpeedWeight +
|
speedAmount * this.engineConfig.eraser.gainSpeedWeight +
|
||||||
pressure * appConfig.audioEngine.eraser.gainPressureWeight +
|
pressure * this.engineConfig.eraser.gainPressureWeight +
|
||||||
sizeAmount * appConfig.audioEngine.eraser.gainSizeWeight),
|
sizeAmount * this.engineConfig.eraser.gainSizeWeight),
|
||||||
filterHz,
|
filterHz,
|
||||||
pan: clamp(x * 2 - 1, -1, 1),
|
pan: clamp(x * 2 - 1, -1, 1),
|
||||||
});
|
});
|
||||||
|
|
@ -307,7 +345,7 @@ export class GardenAudio {
|
||||||
|
|
||||||
const profile = getVibeProfile(this.config, snapshot.vibe);
|
const profile = getVibeProfile(this.config, snapshot.vibe);
|
||||||
const activity = snapshot.isErasing
|
const activity = snapshot.isErasing
|
||||||
? appConfig.audioEngine.delay.erasingActivity
|
? this.engineConfig.delay.erasingActivity
|
||||||
: this.energy.getLevel();
|
: this.energy.getLevel();
|
||||||
this.graph.updateDelay(profile, activity);
|
this.graph.updateDelay(profile, activity);
|
||||||
}
|
}
|
||||||
|
|
@ -323,7 +361,7 @@ export class GardenAudio {
|
||||||
}
|
}
|
||||||
|
|
||||||
private getMirrorAmount(mirrorSegmentCount: number): number {
|
private getMirrorAmount(mirrorSegmentCount: number): number {
|
||||||
const maxMirrorSegmentCount = Math.max(1, appConfig.simulation.maxMirrorSegmentCount);
|
const maxMirrorSegmentCount = Math.max(1, this.maxMirrorSegmentCount);
|
||||||
const segmentCount = clamp(
|
const segmentCount = clamp(
|
||||||
Number.isFinite(mirrorSegmentCount) ? mirrorSegmentCount : 1,
|
Number.isFinite(mirrorSegmentCount) ? mirrorSegmentCount : 1,
|
||||||
1,
|
1,
|
||||||
|
|
@ -337,44 +375,16 @@ export class GardenAudio {
|
||||||
return clamp01((segmentCount - 1) / (maxMirrorSegmentCount - 1));
|
return clamp01((segmentCount - 1) / (maxMirrorSegmentCount - 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
private getStrokeMusicActivity(
|
|
||||||
stroke: GardenAudioStroke,
|
|
||||||
metrics: GardenAudioStrokeMetrics,
|
|
||||||
mirrorAmount: number
|
|
||||||
): number {
|
|
||||||
const speedRatio =
|
|
||||||
(stroke.velocityPixelsPerSecond ?? 0) /
|
|
||||||
Math.max(1, this.config.rhythm.speedForFullEnergyPixelsPerSecond);
|
|
||||||
const speedDrive = smoothstep(0.35, 1.1, speedRatio);
|
|
||||||
const speedOverdrive = smoothstep(1.15, 1.8, speedRatio);
|
|
||||||
const distanceDrive = smoothstep(10, 90, metrics.distancePixels);
|
|
||||||
const baseStroke = clamp01(
|
|
||||||
0.08 + speedDrive * 0.5 + metrics.pressure * 0.2 + distanceDrive * 0.22
|
|
||||||
);
|
|
||||||
const mirrorWild = smoothstep(0.45, 0.9, mirrorAmount);
|
|
||||||
const maniaDrive = speedOverdrive * smoothstep(0.62, 0.82, baseStroke);
|
|
||||||
const maniaBoost = maniaDrive * (0.18 + mirrorWild * 0.62);
|
|
||||||
|
|
||||||
return clamp01(
|
|
||||||
baseStroke * (0.68 + mirrorAmount * 0.3) +
|
|
||||||
0.025 +
|
|
||||||
mirrorAmount * 0.045 +
|
|
||||||
maniaBoost
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getTouchPressure(pressure: number | undefined, pointerType?: string): number {
|
private getTouchPressure(pressure: number | undefined, pointerType?: string): number {
|
||||||
if (pressure !== undefined && Number.isFinite(pressure) && pressure > 0) {
|
if (pressure !== undefined && Number.isFinite(pressure) && pressure > 0) {
|
||||||
return clamp01(pressure);
|
return clamp01(pressure);
|
||||||
}
|
}
|
||||||
|
|
||||||
return pointerType === 'pen'
|
return pointerType === 'pen'
|
||||||
? Math.max(appConfig.audioEngine.input.penMinPressure, this.config.input.pressureFallback)
|
? Math.max(
|
||||||
|
this.engineConfig.input.penMinPressure,
|
||||||
|
this.config.input.pressureFallback
|
||||||
|
)
|
||||||
: this.config.input.pressureFallback;
|
: this.config.input.pressureFallback;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const smoothstep = (edge0: number, edge1: number, value: number): number => {
|
|
||||||
const amount = clamp01((value - edge0) / (edge1 - edge0));
|
|
||||||
return amount * amount * (3 - 2 * amount);
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { appConfig } from '../config';
|
||||||
import { VIBE_PRESETS } from '../vibes';
|
import { VIBE_PRESETS } from '../vibes';
|
||||||
import { gardenAudioConfig } from './garden-audio-config';
|
import { gardenAudioConfig } from './garden-audio-config';
|
||||||
import { PianoNote } from './garden-audio-types';
|
import { PianoNote } from './garden-audio-types';
|
||||||
|
|
@ -7,9 +8,13 @@ import { GenerativePianoEngine } from './generative-piano';
|
||||||
|
|
||||||
const makeEngine = () => {
|
const makeEngine = () => {
|
||||||
const notes: Array<PianoNote> = [];
|
const notes: Array<PianoNote> = [];
|
||||||
const engine = new GenerativePianoEngine(gardenAudioConfig, (note) => {
|
const engine = new GenerativePianoEngine(
|
||||||
notes.push(note);
|
gardenAudioConfig,
|
||||||
});
|
appConfig.audioEngine,
|
||||||
|
(note) => {
|
||||||
|
notes.push(note);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return { engine, notes };
|
return { engine, notes };
|
||||||
};
|
};
|
||||||
|
|
@ -17,7 +22,9 @@ const makeEngine = () => {
|
||||||
const getBeatSeconds = (): number => 60 / gardenAudioConfig.rhythm.bpm;
|
const getBeatSeconds = (): number => 60 / gardenAudioConfig.rhythm.bpm;
|
||||||
|
|
||||||
const getBeatsPerBar = (): number =>
|
const getBeatsPerBar = (): number =>
|
||||||
Math.round(gardenAudioConfig.rhythm.stepsPerBar / gardenAudioConfig.rhythm.stepsPerBeat);
|
Math.round(
|
||||||
|
gardenAudioConfig.rhythm.stepsPerBar / gardenAudioConfig.rhythm.stepsPerBeat
|
||||||
|
);
|
||||||
|
|
||||||
const renderBars = (
|
const renderBars = (
|
||||||
engine: GenerativePianoEngine,
|
engine: GenerativePianoEngine,
|
||||||
|
|
@ -45,9 +52,8 @@ const countNotesBetween = (
|
||||||
startSeconds: number,
|
startSeconds: number,
|
||||||
endSeconds: number
|
endSeconds: number
|
||||||
): number =>
|
): number =>
|
||||||
notes.filter(
|
notes.filter((note) => note.startTime >= startSeconds && note.startTime < endSeconds)
|
||||||
(note) => note.startTime >= startSeconds && note.startTime < endSeconds
|
.length;
|
||||||
).length;
|
|
||||||
|
|
||||||
describe('GenerativePianoEngine', () => {
|
describe('GenerativePianoEngine', () => {
|
||||||
it('plays quiet background music even when the garden is idle', () => {
|
it('plays quiet background music even when the garden is idle', () => {
|
||||||
|
|
@ -56,10 +62,8 @@ describe('GenerativePianoEngine', () => {
|
||||||
renderBars(engine, 0);
|
renderBars(engine, 0);
|
||||||
|
|
||||||
expect(notes.length).toBeGreaterThan(0);
|
expect(notes.length).toBeGreaterThan(0);
|
||||||
expect(notes.some((note) => note.durationSeconds > getBeatSeconds() * 12)).toBe(
|
expect(notes.some((note) => note.durationSeconds > getBeatSeconds() * 6)).toBe(true);
|
||||||
true
|
expect(Math.max(...notes.map((note) => note.velocity))).toBeLessThan(0.12);
|
||||||
);
|
|
||||||
expect(Math.max(...notes.map((note) => note.velocity))).toBeLessThan(0.16);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the background sparse instead of filling every beat', () => {
|
it('keeps the background sparse instead of filling every beat', () => {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { appConfig } from '../config';
|
import type { GardenAudioEngineConfig } from '../config';
|
||||||
import { clamp, clamp01 } from '../utils/clamp';
|
import { clamp, clamp01 } from '../utils/clamp';
|
||||||
import { VibePreset } from '../vibes';
|
import { VibePreset } from '../vibes';
|
||||||
import {
|
import {
|
||||||
|
|
@ -6,7 +6,11 @@ import {
|
||||||
GardenAudioConfig,
|
GardenAudioConfig,
|
||||||
GardenAudioVibeProfile,
|
GardenAudioVibeProfile,
|
||||||
} from './garden-audio-config';
|
} from './garden-audio-config';
|
||||||
import { degreeToSemitone, getChordIntervals, getVibeProfile } from './garden-audio-music';
|
import {
|
||||||
|
degreeToSemitone,
|
||||||
|
getChordIntervals,
|
||||||
|
getVibeProfile,
|
||||||
|
} from './garden-audio-music';
|
||||||
import { GardenAudioColorIndex, PianoNote } from './garden-audio-types';
|
import { GardenAudioColorIndex, PianoNote } from './garden-audio-types';
|
||||||
|
|
||||||
interface RenderLookaheadRequest {
|
interface RenderLookaheadRequest {
|
||||||
|
|
@ -23,6 +27,13 @@ interface StrokeAccentRequest {
|
||||||
activity: number;
|
activity: number;
|
||||||
selectedColorIndex: GardenAudioColorIndex;
|
selectedColorIndex: GardenAudioColorIndex;
|
||||||
mirrorAmount?: number;
|
mirrorAmount?: number;
|
||||||
|
panBias?: number;
|
||||||
|
registerBias?: number;
|
||||||
|
brightnessBias?: number;
|
||||||
|
contour?: number;
|
||||||
|
pressureAmount?: number;
|
||||||
|
pressureDelta?: number;
|
||||||
|
maniaAmount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TouchDownRequest {
|
interface TouchDownRequest {
|
||||||
|
|
@ -31,6 +42,13 @@ interface TouchDownRequest {
|
||||||
strength: number;
|
strength: number;
|
||||||
selectedColorIndex: GardenAudioColorIndex;
|
selectedColorIndex: GardenAudioColorIndex;
|
||||||
mirrorAmount?: number;
|
mirrorAmount?: number;
|
||||||
|
panBias?: number;
|
||||||
|
registerBias?: number;
|
||||||
|
brightnessBias?: number;
|
||||||
|
contour?: number;
|
||||||
|
pressureAmount?: number;
|
||||||
|
pressureDelta?: number;
|
||||||
|
maniaAmount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Register {
|
interface Register {
|
||||||
|
|
@ -61,6 +79,14 @@ interface BrushPhraseLayer {
|
||||||
selectedColorIndex: GardenAudioColorIndex;
|
selectedColorIndex: GardenAudioColorIndex;
|
||||||
energy: number;
|
energy: number;
|
||||||
mirrorAmount: number;
|
mirrorAmount: number;
|
||||||
|
motifOffsets: Array<number>;
|
||||||
|
panBias: number;
|
||||||
|
registerBias: number;
|
||||||
|
brightnessBias: number;
|
||||||
|
contour: number;
|
||||||
|
pressureAmount: number;
|
||||||
|
pressureDelta: number;
|
||||||
|
maniaAmount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const COLOR_POOLS: [ColorPool, ColorPool, ColorPool] = [
|
const COLOR_POOLS: [ColorPool, ColorPool, ColorPool] = [
|
||||||
|
|
@ -134,6 +160,9 @@ const BRUSH_STREAM_IDLE_INTERVAL_BEATS = 2;
|
||||||
const BRUSH_STREAM_ACTIVE_INTERVAL_BEATS = 1;
|
const BRUSH_STREAM_ACTIVE_INTERVAL_BEATS = 1;
|
||||||
const BRUSH_STREAM_INTENSE_INTERVAL_BEATS = 0.5;
|
const BRUSH_STREAM_INTENSE_INTERVAL_BEATS = 0.5;
|
||||||
const BRUSH_STREAM_MANIC_INTERVAL_BEATS = 0.25;
|
const BRUSH_STREAM_MANIC_INTERVAL_BEATS = 0.25;
|
||||||
|
const BRUSH_MOTIF_MAX_STEPS = 8;
|
||||||
|
const BRUSH_MOTIF_CANON_DELAY_SECONDS = 0.055;
|
||||||
|
const PAD_DURATION_BAR_SCALE = 0.46;
|
||||||
|
|
||||||
export class GenerativePianoEngine {
|
export class GenerativePianoEngine {
|
||||||
private nextBeatAt: number | null = null;
|
private nextBeatAt: number | null = null;
|
||||||
|
|
@ -154,22 +183,23 @@ export class GenerativePianoEngine {
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly config: GardenAudioConfig,
|
private readonly config: GardenAudioConfig,
|
||||||
|
private readonly engineConfig: GardenAudioEngineConfig,
|
||||||
private readonly playNote: (note: PianoNote) => void
|
private readonly playNote: (note: PianoNote) => void
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public prime(now: number): void {
|
public prime(now: number): void {
|
||||||
if (this.nextBeatAt === null) {
|
if (this.nextBeatAt === null) {
|
||||||
this.nextBeatAt = now + appConfig.audioEngine.startDelaySeconds;
|
this.nextBeatAt = now + this.engineConfig.startDelaySeconds;
|
||||||
}
|
}
|
||||||
this.timelineStartedAt ??= now;
|
this.timelineStartedAt ??= now;
|
||||||
this.nextBrushStreamAt ??= now + appConfig.audioEngine.startDelaySeconds;
|
this.nextBrushStreamAt ??= now + this.engineConfig.startDelaySeconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
public cue(now: number): void {
|
public cue(now: number): void {
|
||||||
this.nextBeatAt = now + appConfig.audioEngine.startDelaySeconds;
|
this.nextBeatAt = now + this.engineConfig.startDelaySeconds;
|
||||||
this.timelineStartedAt = now;
|
this.timelineStartedAt = now;
|
||||||
this.beatIndex = 0;
|
this.beatIndex = 0;
|
||||||
this.nextBrushStreamAt = now + appConfig.audioEngine.startDelaySeconds;
|
this.nextBrushStreamAt = now + this.engineConfig.startDelaySeconds;
|
||||||
this.brushStreamNoteIndex = 0;
|
this.brushStreamNoteIndex = 0;
|
||||||
this.lastBrushStreamMidi = null;
|
this.lastBrushStreamMidi = null;
|
||||||
}
|
}
|
||||||
|
|
@ -188,9 +218,25 @@ export class GenerativePianoEngine {
|
||||||
strength,
|
strength,
|
||||||
selectedColorIndex,
|
selectedColorIndex,
|
||||||
mirrorAmount = 0,
|
mirrorAmount = 0,
|
||||||
|
panBias = 0,
|
||||||
|
registerBias = 0,
|
||||||
|
brightnessBias = 0.5,
|
||||||
|
contour = 0,
|
||||||
|
pressureAmount = 0,
|
||||||
|
pressureDelta = 0,
|
||||||
|
maniaAmount = 0,
|
||||||
}: TouchDownRequest): void {
|
}: TouchDownRequest): void {
|
||||||
const normalizedStrength = clamp01(strength);
|
const normalizedStrength = clamp01(strength);
|
||||||
const normalizedMirrorAmount = clamp01(mirrorAmount);
|
const normalizedMirrorAmount = clamp01(mirrorAmount);
|
||||||
|
const normalizedMotif = this.normalizeMotif({
|
||||||
|
panBias,
|
||||||
|
registerBias,
|
||||||
|
brightnessBias,
|
||||||
|
contour,
|
||||||
|
pressureAmount,
|
||||||
|
pressureDelta,
|
||||||
|
maniaAmount,
|
||||||
|
});
|
||||||
|
|
||||||
this.isWaitingForGestureAccent = false;
|
this.isWaitingForGestureAccent = false;
|
||||||
this.lastGestureAccentAt = now;
|
this.lastGestureAccentAt = now;
|
||||||
|
|
@ -201,8 +247,17 @@ export class GenerativePianoEngine {
|
||||||
strength: normalizedStrength,
|
strength: normalizedStrength,
|
||||||
selectedColorIndex,
|
selectedColorIndex,
|
||||||
mirrorAmount: normalizedMirrorAmount,
|
mirrorAmount: normalizedMirrorAmount,
|
||||||
|
...normalizedMotif,
|
||||||
|
});
|
||||||
|
this.playTouchNote({
|
||||||
|
vibe,
|
||||||
|
now,
|
||||||
|
selectedColorIndex,
|
||||||
|
strength: normalizedStrength,
|
||||||
|
panBias: normalizedMotif.panBias,
|
||||||
|
registerBias: normalizedMotif.registerBias,
|
||||||
|
brightnessBias: normalizedMotif.brightnessBias,
|
||||||
});
|
});
|
||||||
this.playTouchNote(vibe, now, selectedColorIndex, normalizedStrength);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public recordStroke({
|
public recordStroke({
|
||||||
|
|
@ -211,9 +266,25 @@ export class GenerativePianoEngine {
|
||||||
activity,
|
activity,
|
||||||
selectedColorIndex,
|
selectedColorIndex,
|
||||||
mirrorAmount = 0,
|
mirrorAmount = 0,
|
||||||
|
panBias = 0,
|
||||||
|
registerBias = 0,
|
||||||
|
brightnessBias = 0.5,
|
||||||
|
contour = 0,
|
||||||
|
pressureAmount = 0,
|
||||||
|
pressureDelta = 0,
|
||||||
|
maniaAmount = 0,
|
||||||
}: StrokeAccentRequest): void {
|
}: StrokeAccentRequest): void {
|
||||||
const strength = clamp01(activity);
|
const strength = clamp01(activity);
|
||||||
const normalizedMirrorAmount = clamp01(mirrorAmount);
|
const normalizedMirrorAmount = clamp01(mirrorAmount);
|
||||||
|
const normalizedMotif = this.normalizeMotif({
|
||||||
|
panBias,
|
||||||
|
registerBias,
|
||||||
|
brightnessBias,
|
||||||
|
contour,
|
||||||
|
pressureAmount,
|
||||||
|
pressureDelta,
|
||||||
|
maniaAmount,
|
||||||
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.isWaitingForGestureAccent &&
|
this.isWaitingForGestureAccent &&
|
||||||
|
|
@ -225,11 +296,19 @@ export class GenerativePianoEngine {
|
||||||
strength,
|
strength,
|
||||||
selectedColorIndex,
|
selectedColorIndex,
|
||||||
mirrorAmount: normalizedMirrorAmount,
|
mirrorAmount: normalizedMirrorAmount,
|
||||||
|
...normalizedMotif,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isWaitingForGestureAccent = false;
|
this.isWaitingForGestureAccent = false;
|
||||||
|
this.updateBrushPhraseLayer({
|
||||||
|
now,
|
||||||
|
strength,
|
||||||
|
selectedColorIndex,
|
||||||
|
mirrorAmount: normalizedMirrorAmount,
|
||||||
|
...normalizedMotif,
|
||||||
|
});
|
||||||
if (
|
if (
|
||||||
strength >= STROKE_ACCENT_THRESHOLD &&
|
strength >= STROKE_ACCENT_THRESHOLD &&
|
||||||
now - this.lastStrokeAccentAt >= STROKE_ACCENT_MIN_INTERVAL_SECONDS
|
now - this.lastStrokeAccentAt >= STROKE_ACCENT_MIN_INTERVAL_SECONDS
|
||||||
|
|
@ -385,22 +464,22 @@ export class GenerativePianoEngine {
|
||||||
const chord = this.getChord(profile, barIndex);
|
const chord = this.getChord(profile, barIndex);
|
||||||
const intervals = getChordIntervals(chord, true);
|
const intervals = getChordIntervals(chord, true);
|
||||||
const rootMidi = profile.rootMidi + chord.rootOffset;
|
const rootMidi = profile.rootMidi + chord.rootOffset;
|
||||||
const durationSeconds = this.getBarDurationSeconds() * CHORD_BARS * 0.88;
|
const durationSeconds = this.getBarDurationSeconds() * CHORD_BARS * PAD_DURATION_BAR_SCALE;
|
||||||
const notes = [
|
const notes = [
|
||||||
{
|
{
|
||||||
source: { baseMidi: rootMidi, offsets: [0] },
|
source: { baseMidi: rootMidi, offsets: [0] },
|
||||||
register: PAD_REGISTERS[0],
|
register: PAD_REGISTERS[0],
|
||||||
velocity: 0.082,
|
velocity: 0.052,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: { baseMidi: rootMidi, offsets: [intervals[1]] },
|
source: { baseMidi: rootMidi, offsets: [intervals[1]] },
|
||||||
register: PAD_REGISTERS[1],
|
register: PAD_REGISTERS[1],
|
||||||
velocity: 0.064,
|
velocity: 0.041,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: { baseMidi: rootMidi, offsets: [intervals[3], intervals[2]] },
|
source: { baseMidi: rootMidi, offsets: [intervals[3], intervals[2]] },
|
||||||
register: PAD_REGISTERS[2],
|
register: PAD_REGISTERS[2],
|
||||||
velocity: 0.052,
|
velocity: 0.033,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -412,8 +491,8 @@ export class GenerativePianoEngine {
|
||||||
startTime,
|
startTime,
|
||||||
durationSeconds,
|
durationSeconds,
|
||||||
pan: register.pan,
|
pan: register.pan,
|
||||||
delaySend: 0.018,
|
delaySend: 0.008,
|
||||||
lowpassHz: this.getLowpassHz(profile, midi, expression * 0.45),
|
lowpassHz: this.getLowpassHz(profile, midi, expression * 0.28),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -519,7 +598,7 @@ export class GenerativePianoEngine {
|
||||||
this.config.colorVoices[selectedColorIndex].velocityMultiplier,
|
this.config.colorVoices[selectedColorIndex].velocityMultiplier,
|
||||||
startTime:
|
startTime:
|
||||||
now +
|
now +
|
||||||
appConfig.audioEngine.startDelaySeconds +
|
this.engineConfig.startDelaySeconds +
|
||||||
index * GESTURE_ACCENT_SPACING_SECONDS,
|
index * GESTURE_ACCENT_SPACING_SECONDS,
|
||||||
durationSeconds: 0.48 + strength * 0.22,
|
durationSeconds: 0.48 + strength * 0.22,
|
||||||
pan: this.getColorPan(selectedColorIndex),
|
pan: this.getColorPan(selectedColorIndex),
|
||||||
|
|
@ -529,14 +608,26 @@ export class GenerativePianoEngine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private playTouchNote(
|
private playTouchNote({
|
||||||
vibe: VibePreset,
|
vibe,
|
||||||
now: number,
|
now,
|
||||||
selectedColorIndex: GardenAudioColorIndex,
|
selectedColorIndex,
|
||||||
strength: number
|
strength,
|
||||||
): void {
|
panBias,
|
||||||
|
registerBias,
|
||||||
|
brightnessBias,
|
||||||
|
}: {
|
||||||
|
vibe: VibePreset;
|
||||||
|
now: number;
|
||||||
|
selectedColorIndex: GardenAudioColorIndex;
|
||||||
|
strength: number;
|
||||||
|
panBias: number;
|
||||||
|
registerBias: number;
|
||||||
|
brightnessBias: number;
|
||||||
|
}): void {
|
||||||
const profile = getVibeProfile(this.config, vibe);
|
const profile = getVibeProfile(this.config, vibe);
|
||||||
const pool = COLOR_POOLS[selectedColorIndex];
|
const pool = COLOR_POOLS[selectedColorIndex];
|
||||||
|
const register = this.getBiasedRegister(pool, registerBias, 0);
|
||||||
const chord = this.getChord(profile, this.getGlobalBarIndex(now));
|
const chord = this.getChord(profile, this.getGlobalBarIndex(now));
|
||||||
const chordIntervals = getChordIntervals(chord, false);
|
const chordIntervals = getChordIntervals(chord, false);
|
||||||
const rootMidi = profile.rootMidi + chord.rootOffset;
|
const rootMidi = profile.rootMidi + chord.rootOffset;
|
||||||
|
|
@ -545,7 +636,7 @@ export class GenerativePianoEngine {
|
||||||
baseMidi: rootMidi,
|
baseMidi: rootMidi,
|
||||||
offsets: this.getSupportOffsets(chordIntervals, selectedColorIndex),
|
offsets: this.getSupportOffsets(chordIntervals, selectedColorIndex),
|
||||||
},
|
},
|
||||||
pool,
|
register,
|
||||||
this.lastMidiByColor[selectedColorIndex],
|
this.lastMidiByColor[selectedColorIndex],
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
|
|
@ -559,9 +650,13 @@ export class GenerativePianoEngine {
|
||||||
this.config.colorVoices[selectedColorIndex].velocityMultiplier,
|
this.config.colorVoices[selectedColorIndex].velocityMultiplier,
|
||||||
startTime: now,
|
startTime: now,
|
||||||
durationSeconds: 0.55 + strength * 0.18,
|
durationSeconds: 0.55 + strength * 0.18,
|
||||||
pan: this.getColorPan(selectedColorIndex),
|
pan: this.getLayerPan(selectedColorIndex, panBias, 0, 0),
|
||||||
delaySend: 0.006,
|
delaySend: 0.006,
|
||||||
lowpassHz: this.getLowpassHz(profile, midi, clamp01(0.45 + strength * 0.45)),
|
lowpassHz: this.getLowpassHz(
|
||||||
|
profile,
|
||||||
|
midi,
|
||||||
|
clamp01(0.45 + strength * 0.35 + brightnessBias * 0.2)
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -571,12 +666,26 @@ export class GenerativePianoEngine {
|
||||||
strength,
|
strength,
|
||||||
selectedColorIndex,
|
selectedColorIndex,
|
||||||
mirrorAmount,
|
mirrorAmount,
|
||||||
|
panBias,
|
||||||
|
registerBias,
|
||||||
|
brightnessBias,
|
||||||
|
contour,
|
||||||
|
pressureAmount,
|
||||||
|
pressureDelta,
|
||||||
|
maniaAmount,
|
||||||
}: {
|
}: {
|
||||||
vibe: VibePreset;
|
vibe: VibePreset;
|
||||||
now: number;
|
now: number;
|
||||||
strength: number;
|
strength: number;
|
||||||
selectedColorIndex: GardenAudioColorIndex;
|
selectedColorIndex: GardenAudioColorIndex;
|
||||||
mirrorAmount: number;
|
mirrorAmount: number;
|
||||||
|
panBias: number;
|
||||||
|
registerBias: number;
|
||||||
|
brightnessBias: number;
|
||||||
|
contour: number;
|
||||||
|
pressureAmount: number;
|
||||||
|
pressureDelta: number;
|
||||||
|
maniaAmount: number;
|
||||||
}): void {
|
}): void {
|
||||||
const lifetimeSeconds =
|
const lifetimeSeconds =
|
||||||
BRUSH_LAYER_BASE_SECONDS +
|
BRUSH_LAYER_BASE_SECONDS +
|
||||||
|
|
@ -590,6 +699,18 @@ export class GenerativePianoEngine {
|
||||||
selectedColorIndex,
|
selectedColorIndex,
|
||||||
energy: strength,
|
energy: strength,
|
||||||
mirrorAmount,
|
mirrorAmount,
|
||||||
|
motifOffsets: this.getInitialMotifOffsets({
|
||||||
|
selectedColorIndex,
|
||||||
|
registerBias,
|
||||||
|
contour,
|
||||||
|
}),
|
||||||
|
panBias,
|
||||||
|
registerBias,
|
||||||
|
brightnessBias,
|
||||||
|
contour,
|
||||||
|
pressureAmount,
|
||||||
|
pressureDelta,
|
||||||
|
maniaAmount,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (this.brushPhraseLayers.length > MAX_BRUSH_PHRASE_LAYERS) {
|
if (this.brushPhraseLayers.length > MAX_BRUSH_PHRASE_LAYERS) {
|
||||||
|
|
@ -597,6 +718,55 @@ export class GenerativePianoEngine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private updateBrushPhraseLayer({
|
||||||
|
now,
|
||||||
|
strength,
|
||||||
|
selectedColorIndex,
|
||||||
|
mirrorAmount,
|
||||||
|
panBias,
|
||||||
|
registerBias,
|
||||||
|
brightnessBias,
|
||||||
|
contour,
|
||||||
|
pressureAmount,
|
||||||
|
pressureDelta,
|
||||||
|
maniaAmount,
|
||||||
|
}: {
|
||||||
|
now: number;
|
||||||
|
strength: number;
|
||||||
|
selectedColorIndex: GardenAudioColorIndex;
|
||||||
|
mirrorAmount: number;
|
||||||
|
panBias: number;
|
||||||
|
registerBias: number;
|
||||||
|
brightnessBias: number;
|
||||||
|
contour: number;
|
||||||
|
pressureAmount: number;
|
||||||
|
pressureDelta: number;
|
||||||
|
maniaAmount: number;
|
||||||
|
}): void {
|
||||||
|
const layer = this.brushPhraseLayers[this.brushPhraseLayers.length - 1];
|
||||||
|
if (!layer || layer.expiresAt <= now) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const followAmount = 0.24 + clamp01(strength) * 0.24;
|
||||||
|
layer.selectedColorIndex = selectedColorIndex;
|
||||||
|
layer.energy = Math.max(layer.energy * 0.94, strength);
|
||||||
|
layer.mirrorAmount = Math.max(layer.mirrorAmount * 0.96, mirrorAmount);
|
||||||
|
layer.panBias = mix(layer.panBias, panBias, followAmount);
|
||||||
|
layer.registerBias = mix(layer.registerBias, registerBias, followAmount);
|
||||||
|
layer.brightnessBias = mix(layer.brightnessBias, brightnessBias, followAmount);
|
||||||
|
layer.contour = mix(layer.contour, contour, followAmount);
|
||||||
|
layer.pressureAmount = mix(layer.pressureAmount, pressureAmount, followAmount);
|
||||||
|
layer.pressureDelta = pressureDelta;
|
||||||
|
layer.maniaAmount = Math.max(layer.maniaAmount * 0.92, maniaAmount);
|
||||||
|
layer.motifOffsets.push(
|
||||||
|
this.getMotifOffset({ registerBias, contour, pressureDelta, strength })
|
||||||
|
);
|
||||||
|
if (layer.motifOffsets.length > BRUSH_MOTIF_MAX_STEPS) {
|
||||||
|
layer.motifOffsets = layer.motifOffsets.slice(-BRUSH_MOTIF_MAX_STEPS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private renderBrushPhraseLayers({
|
private renderBrushPhraseLayers({
|
||||||
vibe,
|
vibe,
|
||||||
now,
|
now,
|
||||||
|
|
@ -610,8 +780,8 @@ export class GenerativePianoEngine {
|
||||||
activity: number;
|
activity: number;
|
||||||
selectedColorIndex: GardenAudioColorIndex;
|
selectedColorIndex: GardenAudioColorIndex;
|
||||||
}): void {
|
}): void {
|
||||||
const earliestStart = now + appConfig.audioEngine.piano.scheduleAheadSeconds;
|
const earliestStart = now + this.engineConfig.piano.scheduleAheadSeconds;
|
||||||
this.nextBrushStreamAt ??= now + appConfig.audioEngine.startDelaySeconds;
|
this.nextBrushStreamAt ??= now + this.engineConfig.startDelaySeconds;
|
||||||
|
|
||||||
this.brushPhraseLayers = this.brushPhraseLayers.filter(
|
this.brushPhraseLayers = this.brushPhraseLayers.filter(
|
||||||
(layer) => layer.expiresAt > earliestStart
|
(layer) => layer.expiresAt > earliestStart
|
||||||
|
|
@ -631,6 +801,7 @@ export class GenerativePianoEngine {
|
||||||
startTime: this.nextBrushStreamAt,
|
startTime: this.nextBrushStreamAt,
|
||||||
intensity: frame.intensity,
|
intensity: frame.intensity,
|
||||||
selectedColorIndex: frame.selectedColorIndex ?? selectedColorIndex,
|
selectedColorIndex: frame.selectedColorIndex ?? selectedColorIndex,
|
||||||
|
layer: frame.layer,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.nextBrushStreamAt += this.getBrushStreamIntervalSeconds(frame.intensity);
|
this.nextBrushStreamAt += this.getBrushStreamIntervalSeconds(frame.intensity);
|
||||||
|
|
@ -643,14 +814,22 @@ export class GenerativePianoEngine {
|
||||||
startTime,
|
startTime,
|
||||||
intensity,
|
intensity,
|
||||||
selectedColorIndex,
|
selectedColorIndex,
|
||||||
|
layer,
|
||||||
}: {
|
}: {
|
||||||
vibe: VibePreset;
|
vibe: VibePreset;
|
||||||
startTime: number;
|
startTime: number;
|
||||||
intensity: number;
|
intensity: number;
|
||||||
selectedColorIndex: GardenAudioColorIndex;
|
selectedColorIndex: GardenAudioColorIndex;
|
||||||
|
layer: BrushPhraseLayer | null;
|
||||||
}): void {
|
}): void {
|
||||||
const profile = getVibeProfile(this.config, vibe);
|
const profile = getVibeProfile(this.config, vibe);
|
||||||
const pool = COLOR_POOLS[selectedColorIndex];
|
const pool = COLOR_POOLS[selectedColorIndex];
|
||||||
|
const maniaAmount = layer?.maniaAmount ?? clamp01((intensity - 0.82) / 0.18);
|
||||||
|
const register = this.getBiasedRegister(
|
||||||
|
pool,
|
||||||
|
layer?.registerBias ?? 0,
|
||||||
|
maniaAmount * 0.45
|
||||||
|
);
|
||||||
const chord = this.getChord(profile, this.getGlobalBarIndex(startTime));
|
const chord = this.getChord(profile, this.getGlobalBarIndex(startTime));
|
||||||
const chordIntervals = getChordIntervals(chord, false);
|
const chordIntervals = getChordIntervals(chord, false);
|
||||||
const rootMidi = profile.rootMidi + chord.rootOffset;
|
const rootMidi = profile.rootMidi + chord.rootOffset;
|
||||||
|
|
@ -662,12 +841,29 @@ export class GenerativePianoEngine {
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
baseMidi: profile.rootMidi,
|
baseMidi: profile.rootMidi,
|
||||||
offsets: this.rotate(
|
offsets: this.getBrushMotifDegrees({
|
||||||
pool.scaleDegrees,
|
layer,
|
||||||
this.brushStreamNoteIndex + selectedColorIndex
|
pool,
|
||||||
).map((degree) => degreeToSemitone(profile, degree)),
|
selectedColorIndex,
|
||||||
|
}).map((degree) => degreeToSemitone(profile, degree)),
|
||||||
};
|
};
|
||||||
const midi = this.chooseMidi(source, pool, this.lastBrushStreamMidi, true);
|
const midi = this.chooseMidi(source, register, this.lastBrushStreamMidi, true);
|
||||||
|
const pan = this.getLayerPan(
|
||||||
|
selectedColorIndex,
|
||||||
|
layer?.panBias ?? 0,
|
||||||
|
maniaAmount,
|
||||||
|
layer?.mirrorAmount ?? 0
|
||||||
|
);
|
||||||
|
const durationSeconds = clamp(
|
||||||
|
0.48 + intensity * 0.08 - maniaAmount * 0.34,
|
||||||
|
0.14,
|
||||||
|
0.62
|
||||||
|
);
|
||||||
|
const delaySend = clamp(
|
||||||
|
0.012 + intensity * 0.011 + (layer?.mirrorAmount ?? 0) * 0.004 - maniaAmount * 0.006,
|
||||||
|
0.006,
|
||||||
|
0.032
|
||||||
|
);
|
||||||
|
|
||||||
this.lastBrushStreamMidi = midi;
|
this.lastBrushStreamMidi = midi;
|
||||||
this.lastMidiByColor[selectedColorIndex] = midi;
|
this.lastMidiByColor[selectedColorIndex] = midi;
|
||||||
|
|
@ -677,11 +873,38 @@ export class GenerativePianoEngine {
|
||||||
(0.1 + intensity * 0.13) *
|
(0.1 + intensity * 0.13) *
|
||||||
this.config.colorVoices[selectedColorIndex].velocityMultiplier,
|
this.config.colorVoices[selectedColorIndex].velocityMultiplier,
|
||||||
startTime,
|
startTime,
|
||||||
durationSeconds: 0.42 + intensity * 0.22,
|
durationSeconds,
|
||||||
pan: this.getColorPan(selectedColorIndex),
|
pan,
|
||||||
delaySend: 0.012 + intensity * 0.01,
|
delaySend,
|
||||||
lowpassHz: this.getLowpassHz(profile, midi, clamp01(0.35 + intensity * 0.65)),
|
lowpassHz: this.getLowpassHz(
|
||||||
|
profile,
|
||||||
|
midi,
|
||||||
|
clamp01(
|
||||||
|
0.32 +
|
||||||
|
intensity * 0.48 +
|
||||||
|
(layer?.brightnessBias ?? 0.5) * 0.14 +
|
||||||
|
maniaAmount * 0.18
|
||||||
|
)
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (maniaAmount >= 0.62 && (this.brushStreamNoteIndex % 2 === 1 || intensity >= 0.9)) {
|
||||||
|
const echoMidi = midi + 12 <= 88 ? midi + 12 : midi - 12;
|
||||||
|
this.playNote({
|
||||||
|
midi: echoMidi,
|
||||||
|
velocity:
|
||||||
|
(0.045 + intensity * 0.05) *
|
||||||
|
this.config.colorVoices[selectedColorIndex].velocityMultiplier,
|
||||||
|
startTime:
|
||||||
|
startTime +
|
||||||
|
BRUSH_MOTIF_CANON_DELAY_SECONDS +
|
||||||
|
(layer?.mirrorAmount ?? 0) * 0.04,
|
||||||
|
durationSeconds: Math.max(0.11, durationSeconds * 0.68),
|
||||||
|
pan: clamp(-pan * 0.75, -1, 1),
|
||||||
|
delaySend: Math.max(0.006, delaySend * 0.72),
|
||||||
|
lowpassHz: this.getLowpassHz(profile, echoMidi, 0.62 + maniaAmount * 0.24),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getBrushStreamFrame(
|
private getBrushStreamFrame(
|
||||||
|
|
@ -690,17 +913,19 @@ export class GenerativePianoEngine {
|
||||||
): {
|
): {
|
||||||
intensity: number;
|
intensity: number;
|
||||||
selectedColorIndex: GardenAudioColorIndex | null;
|
selectedColorIndex: GardenAudioColorIndex | null;
|
||||||
|
layer: BrushPhraseLayer | null;
|
||||||
} {
|
} {
|
||||||
const layerStates = this.brushPhraseLayers.map((layer) => ({
|
const layerStates = this.brushPhraseLayers.map((layer) => ({
|
||||||
layer,
|
layer,
|
||||||
intensity:
|
intensity:
|
||||||
layer.energy *
|
layer.energy *
|
||||||
this.getBrushPhraseFade(layer, startTime) *
|
this.getBrushPhraseFade(layer, startTime) *
|
||||||
(0.8 + layer.mirrorAmount * 0.45),
|
(0.8 + layer.mirrorAmount * 0.45 + layer.maniaAmount * 0.42),
|
||||||
}));
|
}));
|
||||||
const dominant = layerStates.reduce<
|
const dominant = layerStates.reduce<{
|
||||||
{ layer: BrushPhraseLayer; intensity: number } | null
|
layer: BrushPhraseLayer;
|
||||||
>((best, state) => {
|
intensity: number;
|
||||||
|
} | null>((best, state) => {
|
||||||
if (state.intensity <= 0) {
|
if (state.intensity <= 0) {
|
||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
@ -712,8 +937,11 @@ export class GenerativePianoEngine {
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
intensity: clamp01(activity * 0.45 + layeredIntensity),
|
intensity: clamp01(
|
||||||
|
activity * 0.42 + layeredIntensity + (dominant?.layer.maniaAmount ?? 0) * 0.18
|
||||||
|
),
|
||||||
selectedColorIndex: dominant?.layer.selectedColorIndex ?? null,
|
selectedColorIndex: dominant?.layer.selectedColorIndex ?? null,
|
||||||
|
layer: dominant?.layer ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -735,6 +963,142 @@ export class GenerativePianoEngine {
|
||||||
return clamp01(1 - ageSeconds / Math.max(0.001, lifetimeSeconds));
|
return clamp01(1 - ageSeconds / Math.max(0.001, lifetimeSeconds));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizeMotif({
|
||||||
|
panBias,
|
||||||
|
registerBias,
|
||||||
|
brightnessBias,
|
||||||
|
contour,
|
||||||
|
pressureAmount,
|
||||||
|
pressureDelta,
|
||||||
|
maniaAmount,
|
||||||
|
}: {
|
||||||
|
panBias: number;
|
||||||
|
registerBias: number;
|
||||||
|
brightnessBias: number;
|
||||||
|
contour: number;
|
||||||
|
pressureAmount: number;
|
||||||
|
pressureDelta: number;
|
||||||
|
maniaAmount: number;
|
||||||
|
}): {
|
||||||
|
panBias: number;
|
||||||
|
registerBias: number;
|
||||||
|
brightnessBias: number;
|
||||||
|
contour: number;
|
||||||
|
pressureAmount: number;
|
||||||
|
pressureDelta: number;
|
||||||
|
maniaAmount: number;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
panBias: clamp(panBias, -1, 1),
|
||||||
|
registerBias: clamp(registerBias, -1, 1),
|
||||||
|
brightnessBias: clamp01(brightnessBias),
|
||||||
|
contour: clamp(contour, -1, 1),
|
||||||
|
pressureAmount: clamp01(pressureAmount),
|
||||||
|
pressureDelta: clamp(pressureDelta, -1, 1),
|
||||||
|
maniaAmount: clamp01(maniaAmount),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getInitialMotifOffsets({
|
||||||
|
selectedColorIndex,
|
||||||
|
registerBias,
|
||||||
|
contour,
|
||||||
|
}: {
|
||||||
|
selectedColorIndex: GardenAudioColorIndex;
|
||||||
|
registerBias: number;
|
||||||
|
contour: number;
|
||||||
|
}): Array<number> {
|
||||||
|
const start = selectedColorIndex - 1 + Math.round(registerBias);
|
||||||
|
const motion = contour > 0.2 ? 1 : contour < -0.2 ? -1 : 0;
|
||||||
|
return [start, start + motion, start + motion * 2, start + motion];
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMotifOffset({
|
||||||
|
registerBias,
|
||||||
|
contour,
|
||||||
|
pressureDelta,
|
||||||
|
strength,
|
||||||
|
}: {
|
||||||
|
registerBias: number;
|
||||||
|
contour: number;
|
||||||
|
pressureDelta: number;
|
||||||
|
strength: number;
|
||||||
|
}): number {
|
||||||
|
const contourStep = contour > 0.3 ? 1 : contour < -0.3 ? -1 : 0;
|
||||||
|
const registerStep = Math.round(registerBias * 2);
|
||||||
|
const pressureStep = pressureDelta > 0.08 ? 1 : pressureDelta < -0.08 ? -1 : 0;
|
||||||
|
const energyStep = strength >= 0.82 ? 1 : strength >= 0.55 ? 0 : -1;
|
||||||
|
return clamp(contourStep + registerStep + pressureStep + energyStep, -3, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getBrushMotifDegrees({
|
||||||
|
layer,
|
||||||
|
pool,
|
||||||
|
selectedColorIndex,
|
||||||
|
}: {
|
||||||
|
layer: BrushPhraseLayer | null;
|
||||||
|
pool: ColorPool;
|
||||||
|
selectedColorIndex: GardenAudioColorIndex;
|
||||||
|
}): Array<number> {
|
||||||
|
const colorOffset = this.config.colorVoices[selectedColorIndex].scaleDegreeOffset;
|
||||||
|
if (!layer || layer.motifOffsets.length === 0) {
|
||||||
|
return this.rotate(pool.scaleDegrees, this.brushStreamNoteIndex + colorOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
const motifOffset =
|
||||||
|
layer.motifOffsets[this.brushStreamNoteIndex % layer.motifOffsets.length];
|
||||||
|
const contourOffset =
|
||||||
|
layer.contour > 0.28
|
||||||
|
? this.brushStreamNoteIndex % 3
|
||||||
|
: layer.contour < -0.28
|
||||||
|
? -(this.brushStreamNoteIndex % 3)
|
||||||
|
: 0;
|
||||||
|
const pressureLift = layer.pressureAmount > 0.68 ? 1 : 0;
|
||||||
|
const baseOffset = colorOffset + motifOffset + contourOffset + pressureLift;
|
||||||
|
|
||||||
|
return this.rotate(
|
||||||
|
pool.scaleDegrees.map((degree) => degree + baseOffset),
|
||||||
|
this.brushStreamNoteIndex
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getBiasedRegister(
|
||||||
|
register: Register,
|
||||||
|
registerBias: number,
|
||||||
|
maniaAmount: number
|
||||||
|
): Register {
|
||||||
|
const shift = Math.round(registerBias * 7 + maniaAmount * 4);
|
||||||
|
const midiMin = clamp(register.midiMin + shift, 36, 86);
|
||||||
|
const midiMax = clamp(register.midiMax + shift, midiMin + 4, 91);
|
||||||
|
|
||||||
|
return {
|
||||||
|
midiMin,
|
||||||
|
midiMax,
|
||||||
|
preferredMidi: clamp(register.preferredMidi + shift, midiMin, midiMax),
|
||||||
|
pan: register.pan,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getLayerPan(
|
||||||
|
selectedColorIndex: GardenAudioColorIndex,
|
||||||
|
panBias: number,
|
||||||
|
maniaAmount: number,
|
||||||
|
mirrorAmount: number
|
||||||
|
): number {
|
||||||
|
const shimmer =
|
||||||
|
maniaAmount > 0.4
|
||||||
|
? Math.sin(this.brushStreamNoteIndex * Math.PI * 0.5) * mirrorAmount * 0.14
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return clamp(
|
||||||
|
this.getColorPan(selectedColorIndex) +
|
||||||
|
panBias * (0.18 + maniaAmount * 0.42) +
|
||||||
|
shimmer,
|
||||||
|
-1,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private chooseMidi(
|
private chooseMidi(
|
||||||
pitchSource: PitchSource,
|
pitchSource: PitchSource,
|
||||||
register: Register,
|
register: Register,
|
||||||
|
|
@ -822,10 +1186,7 @@ export class GenerativePianoEngine {
|
||||||
return [chordIntervals[2], 12, chordIntervals[3], chordIntervals[1] + 12];
|
return [chordIntervals[2], 12, chordIntervals[3], chordIntervals[1] + 12];
|
||||||
}
|
}
|
||||||
|
|
||||||
private getChord(
|
private getChord(profile: GardenAudioVibeProfile, barIndex: number): GardenAudioChord {
|
||||||
profile: GardenAudioVibeProfile,
|
|
||||||
barIndex: number
|
|
||||||
): GardenAudioChord {
|
|
||||||
const progressionIndex =
|
const progressionIndex =
|
||||||
Math.floor(barIndex / CHORD_BARS) % profile.progression.length;
|
Math.floor(barIndex / CHORD_BARS) % profile.progression.length;
|
||||||
return profile.progression[progressionIndex];
|
return profile.progression[progressionIndex];
|
||||||
|
|
@ -852,8 +1213,8 @@ export class GenerativePianoEngine {
|
||||||
return clamp(
|
return clamp(
|
||||||
this.config.piano.lowpassHz * profile.brightness * (0.58 + expression * 0.32) +
|
this.config.piano.lowpassHz * profile.brightness * (0.58 + expression * 0.32) +
|
||||||
midiLift,
|
midiLift,
|
||||||
appConfig.audioEngine.piano.lowpassMinHz,
|
this.engineConfig.piano.lowpassMinHz,
|
||||||
appConfig.audioEngine.piano.lowpassMaxHz
|
this.engineConfig.piano.lowpassMaxHz
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -862,7 +1223,7 @@ export class GenerativePianoEngine {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const earliestStart = now + appConfig.audioEngine.piano.scheduleAheadSeconds;
|
const earliestStart = now + this.engineConfig.piano.scheduleAheadSeconds;
|
||||||
if (this.nextBeatAt >= earliestStart) {
|
if (this.nextBeatAt >= earliestStart) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -898,3 +1259,6 @@ export class GenerativePianoEngine {
|
||||||
return values.map((_, index) => values[(index + offset) % values.length]);
|
return values.map((_, index) => values[(index + offset) % values.length]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mix = (from: number, to: number, amount: number): number =>
|
||||||
|
from + (to - from) * clamp01(amount);
|
||||||
|
|
|
||||||
|
|
@ -28,10 +28,7 @@ export class NoiseBurstPlayer {
|
||||||
filter.type = 'bandpass';
|
filter.type = 'bandpass';
|
||||||
filter.frequency.setValueAtTime(filterHz, scheduledStart);
|
filter.frequency.setValueAtTime(filterHz, scheduledStart);
|
||||||
filter.Q.value = this.engineConfig.noiseBurst.filterQ;
|
filter.Q.value = this.engineConfig.noiseBurst.filterQ;
|
||||||
envelope.gain.setValueAtTime(
|
envelope.gain.setValueAtTime(this.engineConfig.noiseBurst.silentGain, scheduledStart);
|
||||||
this.engineConfig.noiseBurst.silentGain,
|
|
||||||
scheduledStart
|
|
||||||
);
|
|
||||||
envelope.gain.exponentialRampToValueAtTime(
|
envelope.gain.exponentialRampToValueAtTime(
|
||||||
Math.max(this.engineConfig.noiseBurst.silentGain, gain),
|
Math.max(this.engineConfig.noiseBurst.silentGain, gain),
|
||||||
scheduledStart + this.engineConfig.noiseBurst.attackSeconds
|
scheduledStart + this.engineConfig.noiseBurst.attackSeconds
|
||||||
|
|
|
||||||
|
|
@ -58,15 +58,6 @@ export class PianoSampler {
|
||||||
|
|
||||||
const sample = this.findNearestSample(midi);
|
const sample = this.findNearestSample(midi);
|
||||||
if (!sample) {
|
if (!sample) {
|
||||||
this.playFallbackPluck({
|
|
||||||
midi,
|
|
||||||
velocity,
|
|
||||||
startTime,
|
|
||||||
durationSeconds,
|
|
||||||
pan,
|
|
||||||
delaySend,
|
|
||||||
lowpassHz,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,7 +75,8 @@ export class PianoSampler {
|
||||||
(this.engineConfig.piano.sustainBase +
|
(this.engineConfig.piano.sustainBase +
|
||||||
noteVelocity * this.engineConfig.piano.sustainVelocityRange);
|
noteVelocity * this.engineConfig.piano.sustainVelocityRange);
|
||||||
const sustainAt =
|
const sustainAt =
|
||||||
scheduledStart + Math.max(this.engineConfig.piano.minDurationSeconds, durationSeconds);
|
scheduledStart +
|
||||||
|
Math.max(this.engineConfig.piano.minDurationSeconds, durationSeconds);
|
||||||
const releaseAt = sustainAt + sustainSeconds;
|
const releaseAt = sustainAt + sustainSeconds;
|
||||||
const releaseSeconds = this.config.piano.releaseSeconds;
|
const releaseSeconds = this.config.piano.releaseSeconds;
|
||||||
const stopAt = releaseAt + releaseSeconds;
|
const stopAt = releaseAt + releaseSeconds;
|
||||||
|
|
@ -108,10 +100,7 @@ export class PianoSampler {
|
||||||
|
|
||||||
source.buffer = sample.buffer;
|
source.buffer = sample.buffer;
|
||||||
source.playbackRate.setValueAtTime(
|
source.playbackRate.setValueAtTime(
|
||||||
Math.pow(
|
Math.pow(2, (midi - sample.midi) / this.engineConfig.piano.pitchSemitonesPerOctave),
|
||||||
2,
|
|
||||||
(midi - sample.midi) / this.engineConfig.piano.pitchSemitonesPerOctave
|
|
||||||
),
|
|
||||||
scheduledStart
|
scheduledStart
|
||||||
);
|
);
|
||||||
filter.type = 'lowpass';
|
filter.type = 'lowpass';
|
||||||
|
|
@ -140,11 +129,7 @@ export class PianoSampler {
|
||||||
sustainSeconds * this.engineConfig.piano.sustainBase
|
sustainSeconds * this.engineConfig.piano.sustainBase
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
gain.gain.setTargetAtTime(
|
gain.gain.setTargetAtTime(this.engineConfig.piano.minGain, releaseAt, releaseSeconds);
|
||||||
this.engineConfig.piano.minGain,
|
|
||||||
releaseAt,
|
|
||||||
releaseSeconds
|
|
||||||
);
|
|
||||||
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
|
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
|
||||||
|
|
||||||
source.connect(filter);
|
source.connect(filter);
|
||||||
|
|
@ -196,90 +181,4 @@ export class PianoSampler {
|
||||||
private trimActiveVoices(now: number): void {
|
private trimActiveVoices(now: number): void {
|
||||||
this.activeVoices = this.activeVoices.filter((voice) => voice.stopAt > now);
|
this.activeVoices = this.activeVoices.filter((voice) => voice.stopAt > now);
|
||||||
}
|
}
|
||||||
|
|
||||||
private playFallbackPluck({
|
|
||||||
midi,
|
|
||||||
velocity,
|
|
||||||
startTime,
|
|
||||||
durationSeconds,
|
|
||||||
pan,
|
|
||||||
delaySend = 0,
|
|
||||||
lowpassHz = this.config.piano.lowpassHz,
|
|
||||||
}: PianoNote): void {
|
|
||||||
const { context, eventBus, delayInput } = this.graph;
|
|
||||||
if (!context || !eventBus) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const scheduledStart = Math.max(
|
|
||||||
context.currentTime + this.engineConfig.piano.scheduleAheadSeconds,
|
|
||||||
startTime
|
|
||||||
);
|
|
||||||
const oscillator = context.createOscillator();
|
|
||||||
const filter = context.createBiquadFilter();
|
|
||||||
const gain = context.createGain();
|
|
||||||
const panner = context.createStereoPanner();
|
|
||||||
let sendGain: GainNode | null = null;
|
|
||||||
const noteVelocity = clamp01(velocity);
|
|
||||||
const noteGainValue = Math.max(
|
|
||||||
this.engineConfig.piano.minGain,
|
|
||||||
this.config.piano.gain * noteVelocity * 0.42
|
|
||||||
);
|
|
||||||
const releaseAt =
|
|
||||||
scheduledStart + Math.max(this.engineConfig.piano.minDurationSeconds, durationSeconds);
|
|
||||||
const stopAt = releaseAt + this.config.piano.releaseSeconds;
|
|
||||||
|
|
||||||
oscillator.type = 'triangle';
|
|
||||||
oscillator.frequency.setValueAtTime(
|
|
||||||
440 * Math.pow(2, (midi - 69) / appConfig.audioEngine.piano.pitchSemitonesPerOctave),
|
|
||||||
scheduledStart
|
|
||||||
);
|
|
||||||
filter.type = 'lowpass';
|
|
||||||
filter.frequency.setValueAtTime(
|
|
||||||
clamp(
|
|
||||||
lowpassHz * 0.72,
|
|
||||||
this.engineConfig.piano.lowpassMinHz,
|
|
||||||
this.engineConfig.piano.lowpassMaxHz
|
|
||||||
),
|
|
||||||
scheduledStart
|
|
||||||
);
|
|
||||||
filter.Q.value = this.engineConfig.piano.filterQ;
|
|
||||||
gain.gain.setValueAtTime(this.engineConfig.piano.minGain, scheduledStart);
|
|
||||||
gain.gain.exponentialRampToValueAtTime(
|
|
||||||
noteGainValue,
|
|
||||||
scheduledStart + this.engineConfig.piano.gainAttackSeconds
|
|
||||||
);
|
|
||||||
gain.gain.setTargetAtTime(
|
|
||||||
this.engineConfig.piano.minGain,
|
|
||||||
releaseAt,
|
|
||||||
this.config.piano.releaseSeconds
|
|
||||||
);
|
|
||||||
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
|
|
||||||
|
|
||||||
oscillator.connect(filter);
|
|
||||||
filter.connect(gain);
|
|
||||||
gain.connect(panner);
|
|
||||||
panner.connect(eventBus);
|
|
||||||
|
|
||||||
if (delayInput && delaySend > 0) {
|
|
||||||
sendGain = context.createGain();
|
|
||||||
sendGain.gain.value = delaySend * 0.5;
|
|
||||||
panner.connect(sendGain);
|
|
||||||
sendGain.connect(delayInput);
|
|
||||||
}
|
|
||||||
|
|
||||||
oscillator.start(scheduledStart);
|
|
||||||
oscillator.stop(stopAt + this.engineConfig.piano.tailStopExtraSeconds);
|
|
||||||
oscillator.addEventListener(
|
|
||||||
'ended',
|
|
||||||
() => {
|
|
||||||
oscillator.disconnect();
|
|
||||||
filter.disconnect();
|
|
||||||
gain.disconnect();
|
|
||||||
panner.disconnect();
|
|
||||||
sendGain?.disconnect();
|
|
||||||
},
|
|
||||||
{ once: true }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -169,8 +169,8 @@ export const appConfig = {
|
||||||
},
|
},
|
||||||
menuHider: {
|
menuHider: {
|
||||||
bottomRevealDistancePx: 96,
|
bottomRevealDistancePx: 96,
|
||||||
intervalMs: 50,
|
desktopMediaQuery: '(min-width: 600px) and (hover: hover) and (pointer: fine)',
|
||||||
timeToLiveMs: 3500,
|
hideDelayMs: 3000,
|
||||||
},
|
},
|
||||||
pipelines: {
|
pipelines: {
|
||||||
brush: {
|
brush: {
|
||||||
|
|
@ -194,9 +194,6 @@ export const appConfig = {
|
||||||
fpsHeadroom: 0.95,
|
fpsHeadroom: 0.95,
|
||||||
fpsSmoothingNew: 0.06,
|
fpsSmoothingNew: 0.06,
|
||||||
fpsSmoothingRetain: 0.94,
|
fpsSmoothingRetain: 0.94,
|
||||||
initialTargetAgentBudget: 20_000,
|
|
||||||
rampAgentsPerSecond: 20_000,
|
|
||||||
refreshTargetDecay: 0.995,
|
|
||||||
},
|
},
|
||||||
brushEffectFramesPerSecond: 60,
|
brushEffectFramesPerSecond: 60,
|
||||||
globalAgentCap: 10_000_000,
|
globalAgentCap: 10_000_000,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,4 @@
|
||||||
import type {
|
import type { AgentColorInteractionSettings, NumberControlConfig } from './types';
|
||||||
AgentColorInteractionSettings,
|
|
||||||
NumberControlConfig,
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
const agentInteractionOptions: Record<string, number> = {
|
const agentInteractionOptions: Record<string, number> = {
|
||||||
Follow: 1,
|
Follow: 1,
|
||||||
|
|
@ -46,7 +43,8 @@ export const createColorInteractionSettings = (
|
||||||
const random = createSeededRandom(hashString(seedSource));
|
const random = createSeededRandom(hashString(seedSource));
|
||||||
const values = Object.values(agentInteractionOptions);
|
const values = Object.values(agentInteractionOptions);
|
||||||
const randomInteraction = () =>
|
const randomInteraction = () =>
|
||||||
values[Math.floor(random() * values.length)] ?? defaultColorInteractionSettings.color1ToColor2;
|
values[Math.floor(random() * values.length)] ??
|
||||||
|
defaultColorInteractionSettings.color1ToColor2;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
color1ToColor1: 1,
|
color1ToColor1: 1,
|
||||||
|
|
|
||||||
|
|
@ -169,8 +169,8 @@ export interface GardenAppConfig {
|
||||||
};
|
};
|
||||||
menuHider: {
|
menuHider: {
|
||||||
bottomRevealDistancePx: number;
|
bottomRevealDistancePx: number;
|
||||||
intervalMs: number;
|
desktopMediaQuery: string;
|
||||||
timeToLiveMs: number;
|
hideDelayMs: number;
|
||||||
};
|
};
|
||||||
pipelines: {
|
pipelines: {
|
||||||
brush: {
|
brush: {
|
||||||
|
|
@ -197,9 +197,6 @@ export interface GardenAppConfig {
|
||||||
fpsHeadroom: number;
|
fpsHeadroom: number;
|
||||||
fpsSmoothingNew: number;
|
fpsSmoothingNew: number;
|
||||||
fpsSmoothingRetain: number;
|
fpsSmoothingRetain: number;
|
||||||
initialTargetAgentBudget: number;
|
|
||||||
rampAgentsPerSecond: number;
|
|
||||||
refreshTargetDecay: number;
|
|
||||||
};
|
};
|
||||||
brushEffectFramesPerSecond: number;
|
brushEffectFramesPerSecond: number;
|
||||||
globalAgentCap: number;
|
globalAgentCap: number;
|
||||||
|
|
|
||||||
|
|
@ -32,14 +32,9 @@ const createPopulation = () => {
|
||||||
return new AgentPopulation(pipeline);
|
return new AgentPopulation(pipeline);
|
||||||
};
|
};
|
||||||
|
|
||||||
const setPopulationCounts = (
|
const setPopulationActiveCount = (population: AgentPopulation, activeCount: number) => {
|
||||||
population: AgentPopulation,
|
|
||||||
activeCount: number,
|
|
||||||
targetBudget: number
|
|
||||||
) => {
|
|
||||||
Object.assign(population as unknown as Record<string, number>, {
|
Object.assign(population as unknown as Record<string, number>, {
|
||||||
activeCount,
|
activeCount,
|
||||||
targetBudget,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -60,7 +55,7 @@ describe('AgentPopulation adaptive budget', () => {
|
||||||
|
|
||||||
it('expands beyond the 1M start cap only when new agents arrive under healthy FPS', () => {
|
it('expands beyond the 1M start cap only when new agents arrive under healthy FPS', () => {
|
||||||
const population = createPopulation();
|
const population = createPopulation();
|
||||||
setPopulationCounts(population, 1_000_000, 1_000_000);
|
setPopulationActiveCount(population, 1_000_000);
|
||||||
|
|
||||||
population.growBudget(1 / 60, 60, 60);
|
population.growBudget(1 / 60, 60, 60);
|
||||||
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(16, 0));
|
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(16, 0));
|
||||||
|
|
@ -74,7 +69,7 @@ describe('AgentPopulation adaptive budget', () => {
|
||||||
|
|
||||||
it('decreases the cap and active count slowly when FPS falls below the threshold', () => {
|
it('decreases the cap and active count slowly when FPS falls below the threshold', () => {
|
||||||
const population = createPopulation();
|
const population = createPopulation();
|
||||||
setPopulationCounts(population, 1_000_000, 1_000_000);
|
setPopulationActiveCount(population, 1_000_000);
|
||||||
|
|
||||||
population.growBudget(10, 50, 60);
|
population.growBudget(10, 50, 60);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ const ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND =
|
||||||
|
|
||||||
export class AgentPopulation {
|
export class AgentPopulation {
|
||||||
private activeCount = 0;
|
private activeCount = 0;
|
||||||
private targetBudget = appConfig.simulation.budget.initialTargetAgentBudget;
|
|
||||||
private replacementCursor = 0;
|
private replacementCursor = 0;
|
||||||
private canExpandAdaptiveCap = true;
|
private canExpandAdaptiveCap = true;
|
||||||
private shouldCompactAfterErase = false;
|
private shouldCompactAfterErase = false;
|
||||||
|
|
@ -33,24 +32,16 @@ export class AgentPopulation {
|
||||||
return this.activeCount;
|
return this.activeCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
public get targetAgentBudget(): number {
|
|
||||||
return this.targetBudget;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get maxAgentCount(): number {
|
public get maxAgentCount(): number {
|
||||||
return this.pipeline.maxAgentCount;
|
return this.pipeline.maxAgentCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
public initializeIntroAgents(canvasSize: vec2): void {
|
public initializeIntroAgents(canvasSize: vec2): void {
|
||||||
settings.agentBudgetMax = this.clampAdaptiveCap(settings.agentBudgetMax);
|
settings.agentBudgetMax = this.clampAdaptiveCap(settings.agentBudgetMax);
|
||||||
this.targetBudget = Math.min(
|
const introAgentCount = Math.min(settings.agentBudgetMax, INITIAL_AGENT_COUNT);
|
||||||
this.pipeline.maxAgentCount,
|
|
||||||
settings.agentBudgetMax,
|
|
||||||
INITIAL_AGENT_COUNT
|
|
||||||
);
|
|
||||||
this.writeAgentBatch(
|
this.writeAgentBatch(
|
||||||
createIntroTitleAgents({
|
createIntroTitleAgents({
|
||||||
count: this.targetBudget,
|
count: introAgentCount,
|
||||||
width: canvasSize[0],
|
width: canvasSize[0],
|
||||||
height: canvasSize[1],
|
height: canvasSize[1],
|
||||||
})
|
})
|
||||||
|
|
@ -59,11 +50,7 @@ export class AgentPopulation {
|
||||||
|
|
||||||
public onVibeChanged(): void {
|
public onVibeChanged(): void {
|
||||||
settings.agentBudgetMax = this.clampAdaptiveCap(settings.agentBudgetMax);
|
settings.agentBudgetMax = this.clampAdaptiveCap(settings.agentBudgetMax);
|
||||||
this.targetBudget = Math.min(
|
this.trimActiveCountToBudget();
|
||||||
this.targetBudget,
|
|
||||||
settings.agentBudgetMax,
|
|
||||||
this.pipeline.maxAgentCount
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public growBudget(
|
public growBudget(
|
||||||
|
|
@ -72,18 +59,6 @@ export class AgentPopulation {
|
||||||
refreshTargetFps: number
|
refreshTargetFps: number
|
||||||
): void {
|
): void {
|
||||||
this.updateAdaptiveCap(deltaTime, smoothedFps, refreshTargetFps);
|
this.updateAdaptiveCap(deltaTime, smoothedFps, refreshTargetFps);
|
||||||
|
|
||||||
const cap = this.clampAdaptiveCap(settings.agentBudgetMax);
|
|
||||||
if (
|
|
||||||
this.targetBudget < cap &&
|
|
||||||
smoothedFps > refreshTargetFps * appConfig.simulation.budget.fpsHeadroom
|
|
||||||
) {
|
|
||||||
this.targetBudget = Math.min(
|
|
||||||
cap,
|
|
||||||
this.targetBudget +
|
|
||||||
Math.ceil(appConfig.simulation.budget.rampAgentsPerSecond * deltaTime)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public resizeAgents(scale: vec2): void {
|
public resizeAgents(scale: vec2): void {
|
||||||
|
|
@ -110,7 +85,6 @@ export class AgentPopulation {
|
||||||
this.activeCount = compactedAgentCount;
|
this.activeCount = compactedAgentCount;
|
||||||
this.replacementCursor =
|
this.replacementCursor =
|
||||||
compactedAgentCount === 0 ? 0 : this.replacementCursor % compactedAgentCount;
|
compactedAgentCount === 0 ? 0 : this.replacementCursor % compactedAgentCount;
|
||||||
this.targetBudget = Math.max(this.targetBudget, compactedAgentCount);
|
|
||||||
} finally {
|
} finally {
|
||||||
this.isCompacting = false;
|
this.isCompacting = false;
|
||||||
}
|
}
|
||||||
|
|
@ -157,7 +131,7 @@ export class AgentPopulation {
|
||||||
const count = data.length / AGENT_FLOAT_COUNT;
|
const count = data.length / AGENT_FLOAT_COUNT;
|
||||||
this.expandAdaptiveCapForPendingAgents(count);
|
this.expandAdaptiveCapForPendingAgents(count);
|
||||||
|
|
||||||
const available = Math.max(0, this.targetBudget - this.activeCount);
|
const available = Math.max(0, settings.agentBudgetMax - this.activeCount);
|
||||||
const appendCount = Math.min(count, available);
|
const appendCount = Math.min(count, available);
|
||||||
|
|
||||||
if (appendCount > 0) {
|
if (appendCount > 0) {
|
||||||
|
|
@ -196,10 +170,12 @@ export class AgentPopulation {
|
||||||
): void {
|
): void {
|
||||||
const previousCap = this.clampAdaptiveCap(settings.agentBudgetMax);
|
const previousCap = this.clampAdaptiveCap(settings.agentBudgetMax);
|
||||||
this.canExpandAdaptiveCap =
|
this.canExpandAdaptiveCap =
|
||||||
|
refreshTargetFps <= 0 ||
|
||||||
smoothedFps >= refreshTargetFps * appConfig.simulation.budget.fpsHeadroom;
|
smoothedFps >= refreshTargetFps * appConfig.simulation.budget.fpsHeadroom;
|
||||||
|
|
||||||
if (this.canExpandAdaptiveCap) {
|
if (this.canExpandAdaptiveCap) {
|
||||||
settings.agentBudgetMax = previousCap;
|
settings.agentBudgetMax = previousCap;
|
||||||
|
this.trimActiveCountToBudget();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -209,33 +185,31 @@ export class AgentPopulation {
|
||||||
);
|
);
|
||||||
const nextCap = this.clampAdaptiveCap(previousCap - decrease);
|
const nextCap = this.clampAdaptiveCap(previousCap - decrease);
|
||||||
settings.agentBudgetMax = nextCap;
|
settings.agentBudgetMax = nextCap;
|
||||||
this.targetBudget = Math.min(this.targetBudget, nextCap);
|
this.trimActiveCountToBudget(decrease);
|
||||||
|
|
||||||
if (this.activeCount > this.targetBudget) {
|
|
||||||
this.activeCount = Math.max(this.targetBudget, this.activeCount - decrease);
|
|
||||||
this.replacementCursor =
|
|
||||||
this.activeCount === 0 ? 0 : this.replacementCursor % this.activeCount;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private expandAdaptiveCapForPendingAgents(requestedAgentCount: number): void {
|
private expandAdaptiveCapForPendingAgents(requestedAgentCount: number): void {
|
||||||
const available = Math.max(0, this.targetBudget - this.activeCount);
|
const available = Math.max(0, settings.agentBudgetMax - this.activeCount);
|
||||||
if (requestedAgentCount <= available || !this.canExpandAdaptiveCap) {
|
if (requestedAgentCount <= available || !this.canExpandAdaptiveCap) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentCap = this.clampAdaptiveCap(settings.agentBudgetMax);
|
const currentCap = this.clampAdaptiveCap(settings.agentBudgetMax);
|
||||||
if (this.targetBudget < currentCap) {
|
const pendingAgentCount = requestedAgentCount - available;
|
||||||
|
settings.agentBudgetMax = this.clampAdaptiveCap(currentCap + pendingAgentCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private trimActiveCountToBudget(maxDecrease = Number.POSITIVE_INFINITY): void {
|
||||||
|
if (this.activeCount <= settings.agentBudgetMax) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pendingAgentCount = requestedAgentCount - available;
|
this.activeCount = Math.max(
|
||||||
const nextCap = this.clampAdaptiveCap(currentCap + pendingAgentCount);
|
settings.agentBudgetMax,
|
||||||
settings.agentBudgetMax = nextCap;
|
this.activeCount - Math.max(1, Math.ceil(maxDecrease))
|
||||||
this.targetBudget = Math.max(
|
|
||||||
this.targetBudget,
|
|
||||||
Math.min(nextCap, this.activeCount + requestedAgentCount)
|
|
||||||
);
|
);
|
||||||
|
this.replacementCursor =
|
||||||
|
this.activeCount === 0 ? 0 : this.replacementCursor % this.activeCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
private clampAdaptiveCap(value: number): number {
|
private clampAdaptiveCap(value: number): number {
|
||||||
|
|
|
||||||
47
src/game-loop/frame-performance.test.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { FramePerformance } from './frame-performance';
|
||||||
|
|
||||||
|
describe('FramePerformance refresh target', () => {
|
||||||
|
it('uses 60 FPS as the fixed adaptive budget target', () => {
|
||||||
|
const performance = new FramePerformance();
|
||||||
|
|
||||||
|
[123, 126, 130, 121, 60, 30].forEach((fps) => performance.update(1 / fps));
|
||||||
|
|
||||||
|
expect(performance.refreshTargetFps).toBe(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps latest and smoothed FPS separate from the fixed target', () => {
|
||||||
|
const performance = new FramePerformance();
|
||||||
|
|
||||||
|
performance.update(1 / 120);
|
||||||
|
|
||||||
|
expect(performance.latestFps).toBe(120);
|
||||||
|
expect(performance.smoothedFps).toBeGreaterThan(60);
|
||||||
|
expect(performance.refreshTargetFps).toBe(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('snaps the display refresh estimate to a stable screen frequency', () => {
|
||||||
|
const performance = new FramePerformance();
|
||||||
|
|
||||||
|
[123, 126, 130, 121, 124, 127, 125, 122].forEach((fps) =>
|
||||||
|
performance.update(1 / fps)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(performance.refreshTargetFps).toBe(60);
|
||||||
|
expect(performance.displayRefreshFps).toBe(120);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a single startup spike before settling the display refresh estimate', () => {
|
||||||
|
const performance = new FramePerformance();
|
||||||
|
|
||||||
|
performance.update(1 / 240);
|
||||||
|
|
||||||
|
expect(performance.displayRefreshFps).toBe(60);
|
||||||
|
|
||||||
|
Array.from({ length: 8 }).forEach(() => performance.update(1 / 120));
|
||||||
|
|
||||||
|
expect(performance.refreshTargetFps).toBe(60);
|
||||||
|
expect(performance.displayRefreshFps).toBe(120);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -4,18 +4,28 @@ interface TelemetrySnapshot {
|
||||||
frameCpuStartedAt: number;
|
frameCpuStartedAt: number;
|
||||||
encodeCpuMs: number;
|
encodeCpuMs: number;
|
||||||
activeAgentCount: number;
|
activeAgentCount: number;
|
||||||
targetAgentBudget: number;
|
agentBudgetMax: number;
|
||||||
canvas: HTMLCanvasElement;
|
canvas: HTMLCanvasElement;
|
||||||
devicePixelRatio: number;
|
devicePixelRatio: number;
|
||||||
renderSpeed: number;
|
renderSpeed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const COMMON_DISPLAY_REFRESH_RATES = [
|
||||||
|
50, 60, 72, 75, 90, 100, 120, 144, 165, 180, 240,
|
||||||
|
] as const;
|
||||||
|
const DISPLAY_REFRESH_CONFIRMATION_FRAMES = 8;
|
||||||
|
const DISPLAY_REFRESH_SNAP_TOLERANCE = 0.15;
|
||||||
|
|
||||||
export class FramePerformance {
|
export class FramePerformance {
|
||||||
public latestFps = 60;
|
public latestFps = 60;
|
||||||
public smoothedFps = 60;
|
public smoothedFps = 60;
|
||||||
public refreshTargetFps = 60;
|
public displayRefreshFps = 60;
|
||||||
|
public readonly refreshTargetFps = 60;
|
||||||
|
|
||||||
private lastTelemetryAt = 0;
|
private lastTelemetryAt = 0;
|
||||||
|
private hasConfirmedDisplayRefreshFps = false;
|
||||||
|
private pendingDisplayRefreshFps = 0;
|
||||||
|
private pendingDisplayRefreshFrameCount = 0;
|
||||||
|
|
||||||
public markCpuStart(): number {
|
public markCpuStart(): number {
|
||||||
return appConfig.telemetry.enabled ? performance.now() : 0;
|
return appConfig.telemetry.enabled ? performance.now() : 0;
|
||||||
|
|
@ -28,10 +38,7 @@ export class FramePerformance {
|
||||||
public update(deltaTime: number): void {
|
public update(deltaTime: number): void {
|
||||||
const fps = 1 / Math.max(deltaTime, appConfig.deltaTime.minDeltaTimeSeconds);
|
const fps = 1 / Math.max(deltaTime, appConfig.deltaTime.minDeltaTimeSeconds);
|
||||||
this.latestFps = fps;
|
this.latestFps = fps;
|
||||||
this.refreshTargetFps = Math.max(
|
this.updateDisplayRefreshEstimate(fps);
|
||||||
this.refreshTargetFps * appConfig.simulation.budget.refreshTargetDecay,
|
|
||||||
fps
|
|
||||||
);
|
|
||||||
this.smoothedFps =
|
this.smoothedFps =
|
||||||
this.smoothedFps * appConfig.simulation.budget.fpsSmoothingRetain +
|
this.smoothedFps * appConfig.simulation.budget.fpsSmoothingRetain +
|
||||||
fps * appConfig.simulation.budget.fpsSmoothingNew;
|
fps * appConfig.simulation.budget.fpsSmoothingNew;
|
||||||
|
|
@ -41,7 +48,7 @@ export class FramePerformance {
|
||||||
frameCpuStartedAt,
|
frameCpuStartedAt,
|
||||||
encodeCpuMs,
|
encodeCpuMs,
|
||||||
activeAgentCount,
|
activeAgentCount,
|
||||||
targetAgentBudget,
|
agentBudgetMax,
|
||||||
canvas,
|
canvas,
|
||||||
devicePixelRatio,
|
devicePixelRatio,
|
||||||
renderSpeed,
|
renderSpeed,
|
||||||
|
|
@ -60,8 +67,9 @@ export class FramePerformance {
|
||||||
fps: Math.round(this.latestFps),
|
fps: Math.round(this.latestFps),
|
||||||
smoothedFps: Math.round(this.smoothedFps),
|
smoothedFps: Math.round(this.smoothedFps),
|
||||||
refreshTargetFps: Math.round(this.refreshTargetFps),
|
refreshTargetFps: Math.round(this.refreshTargetFps),
|
||||||
|
displayRefreshFps: Math.round(this.displayRefreshFps),
|
||||||
activeAgentCount,
|
activeAgentCount,
|
||||||
targetAgentBudget,
|
agentBudgetMax,
|
||||||
canvasWidth: canvas.width,
|
canvasWidth: canvas.width,
|
||||||
canvasHeight: canvas.height,
|
canvasHeight: canvas.height,
|
||||||
dpr: devicePixelRatio,
|
dpr: devicePixelRatio,
|
||||||
|
|
@ -70,4 +78,61 @@ export class FramePerformance {
|
||||||
encodeCpuMs,
|
encodeCpuMs,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private updateDisplayRefreshEstimate(fps: number): void {
|
||||||
|
const displayRefreshFps = this.snapDisplayRefreshRate(fps);
|
||||||
|
if (displayRefreshFps === null) {
|
||||||
|
this.resetPendingDisplayRefreshEstimate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.hasConfirmedDisplayRefreshFps &&
|
||||||
|
displayRefreshFps < this.displayRefreshFps
|
||||||
|
) {
|
||||||
|
this.resetPendingDisplayRefreshEstimate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displayRefreshFps !== this.pendingDisplayRefreshFps) {
|
||||||
|
this.pendingDisplayRefreshFps = displayRefreshFps;
|
||||||
|
this.pendingDisplayRefreshFrameCount = 1;
|
||||||
|
} else {
|
||||||
|
this.pendingDisplayRefreshFrameCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.pendingDisplayRefreshFrameCount < DISPLAY_REFRESH_CONFIRMATION_FRAMES) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.displayRefreshFps = displayRefreshFps;
|
||||||
|
this.hasConfirmedDisplayRefreshFps = true;
|
||||||
|
this.resetPendingDisplayRefreshEstimate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private snapDisplayRefreshRate(fps: number): number | null {
|
||||||
|
if (!Number.isFinite(fps) || fps <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nearestRefreshRate: number = COMMON_DISPLAY_REFRESH_RATES[0];
|
||||||
|
let nearestDifference = Math.abs(fps - nearestRefreshRate);
|
||||||
|
|
||||||
|
COMMON_DISPLAY_REFRESH_RATES.forEach((refreshRate) => {
|
||||||
|
const difference = Math.abs(fps - refreshRate);
|
||||||
|
if (difference < nearestDifference) {
|
||||||
|
nearestRefreshRate = refreshRate;
|
||||||
|
nearestDifference = difference;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return nearestDifference / nearestRefreshRate <= DISPLAY_REFRESH_SNAP_TOLERANCE
|
||||||
|
? nearestRefreshRate
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetPendingDisplayRefreshEstimate(): void {
|
||||||
|
this.pendingDisplayRefreshFps = 0;
|
||||||
|
this.pendingDisplayRefreshFrameCount = 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,11 @@ export default class GameLoop {
|
||||||
private static readonly DEV_STATS_INTERVAL_MS = 250;
|
private static readonly DEV_STATS_INTERVAL_MS = 250;
|
||||||
|
|
||||||
private readonly resources: GameLoopResources;
|
private readonly resources: GameLoopResources;
|
||||||
private readonly audio = new GardenAudio(gardenAudioConfig);
|
private readonly audio = new GardenAudio(
|
||||||
|
gardenAudioConfig,
|
||||||
|
appConfig.audioEngine,
|
||||||
|
appConfig.simulation.maxMirrorSegmentCount
|
||||||
|
);
|
||||||
private readonly renderInputs = new RenderInputCache();
|
private readonly renderInputs = new RenderInputCache();
|
||||||
private readonly introPrompt: IntroPrompt;
|
private readonly introPrompt: IntroPrompt;
|
||||||
private readonly eraserPreview: EraserPreview;
|
private readonly eraserPreview: EraserPreview;
|
||||||
|
|
@ -30,12 +34,13 @@ export default class GameLoop {
|
||||||
private readonly agentPopulation: AgentPopulation;
|
private readonly agentPopulation: AgentPopulation;
|
||||||
private readonly export4KRenderer: Export4KRenderer;
|
private readonly export4KRenderer: Export4KRenderer;
|
||||||
private readonly framePerformance = new FramePerformance();
|
private readonly framePerformance = new FramePerformance();
|
||||||
private readonly devStatsElement: HTMLDivElement | null = null;
|
private readonly devStatsElement: HTMLDivElement | null;
|
||||||
private readonly seed = Math.floor(Math.random() * 0xffffffff).toString(16);
|
private readonly seed = Math.floor(Math.random() * 0xffffffff).toString(16);
|
||||||
private readonly resizeListener = this.resize.bind(this);
|
private readonly resizeListener = this.resize.bind(this);
|
||||||
private readonly keydownListener: (event: KeyboardEvent) => void;
|
private readonly keydownListener: (event: KeyboardEvent) => void;
|
||||||
|
|
||||||
private lastDevStatsUpdateAt = 0;
|
private lastDevStatsUpdateAt = 0;
|
||||||
|
private isStatsOverlayPinned = false;
|
||||||
private hasFinished = false;
|
private hasFinished = false;
|
||||||
private readonly finished = Promise.withResolvers<void>();
|
private readonly finished = Promise.withResolvers<void>();
|
||||||
|
|
||||||
|
|
@ -46,9 +51,8 @@ export default class GameLoop {
|
||||||
ui: GardenUi
|
ui: GardenUi
|
||||||
) {
|
) {
|
||||||
this.resize();
|
this.resize();
|
||||||
if (import.meta.env.DEV) {
|
this.devStatsElement = this.createDevStatsElement();
|
||||||
this.devStatsElement = this.createDevStatsElement();
|
this.syncDevStatsVisibility();
|
||||||
}
|
|
||||||
this.resources = new GameLoopResources(canvas, device, this.canvasSize);
|
this.resources = new GameLoopResources(canvas, device, this.canvasSize);
|
||||||
this.introPrompt = new IntroPrompt(ui.prompt);
|
this.introPrompt = new IntroPrompt(ui.prompt);
|
||||||
this.eraserPreview = new EraserPreview(canvas, ui.eraserPreview);
|
this.eraserPreview = new EraserPreview(canvas, ui.eraserPreview);
|
||||||
|
|
@ -108,6 +112,17 @@ export default class GameLoop {
|
||||||
this.audio.setMuted(isMuted);
|
this.audio.setMuted(isMuted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public setStatsOverlayPinned(isPinned: boolean): void {
|
||||||
|
const wasVisible = this.shouldShowDevStats;
|
||||||
|
this.isStatsOverlayPinned = isPinned;
|
||||||
|
this.syncDevStatsVisibility();
|
||||||
|
|
||||||
|
if (!wasVisible && this.shouldShowDevStats) {
|
||||||
|
this.lastDevStatsUpdateAt = Number.NEGATIVE_INFINITY;
|
||||||
|
this.updateDevStats(performance.now());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public startAudio(userGesture = false): void {
|
public startAudio(userGesture = false): void {
|
||||||
this.audio.start(activeVibe, { userGesture });
|
this.audio.start(activeVibe, { userGesture });
|
||||||
}
|
}
|
||||||
|
|
@ -205,7 +220,7 @@ export default class GameLoop {
|
||||||
frameCpuStartedAt,
|
frameCpuStartedAt,
|
||||||
encodeCpuMs,
|
encodeCpuMs,
|
||||||
activeAgentCount: this.agentPopulation.activeAgentCount,
|
activeAgentCount: this.agentPopulation.activeAgentCount,
|
||||||
targetAgentBudget: this.agentPopulation.targetAgentBudget,
|
agentBudgetMax: settings.agentBudgetMax,
|
||||||
canvas: this.canvas,
|
canvas: this.canvas,
|
||||||
devicePixelRatio: this.devicePixelRatio,
|
devicePixelRatio: this.devicePixelRatio,
|
||||||
renderSpeed: settings.renderSpeed,
|
renderSpeed: settings.renderSpeed,
|
||||||
|
|
@ -235,22 +250,31 @@ export default class GameLoop {
|
||||||
private updateDevStats(time: DOMHighResTimeStamp): void {
|
private updateDevStats(time: DOMHighResTimeStamp): void {
|
||||||
if (
|
if (
|
||||||
!this.devStatsElement ||
|
!this.devStatsElement ||
|
||||||
|
!this.shouldShowDevStats ||
|
||||||
time - this.lastDevStatsUpdateAt < GameLoop.DEV_STATS_INTERVAL_MS
|
time - this.lastDevStatsUpdateAt < GameLoop.DEV_STATS_INTERVAL_MS
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.lastDevStatsUpdateAt = time;
|
this.lastDevStatsUpdateAt = time;
|
||||||
|
const displayRefreshFps = Math.round(this.framePerformance.displayRefreshFps);
|
||||||
this.devStatsElement.textContent = [
|
this.devStatsElement.textContent = [
|
||||||
`FPS ${this.framePerformance.smoothedFps.toFixed(1)} / ${Math.round(
|
`FPS ${this.framePerformance.smoothedFps.toFixed(1)} / ${displayRefreshFps}`,
|
||||||
this.framePerformance.refreshTargetFps
|
|
||||||
)}`,
|
|
||||||
`Agents ${this.formatDevStatNumber(this.agentPopulation.activeAgentCount)}`,
|
`Agents ${this.formatDevStatNumber(this.agentPopulation.activeAgentCount)}`,
|
||||||
`Target ${this.formatDevStatNumber(this.agentPopulation.targetAgentBudget)}`,
|
|
||||||
`Cap ${this.formatDevStatNumber(settings.agentBudgetMax)}`,
|
`Cap ${this.formatDevStatNumber(settings.agentBudgetMax)}`,
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private syncDevStatsVisibility(): void {
|
||||||
|
if (!this.devStatsElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isVisible = this.shouldShowDevStats;
|
||||||
|
this.devStatsElement.hidden = !isVisible;
|
||||||
|
this.devStatsElement.setAttribute('aria-hidden', String(!isVisible));
|
||||||
|
}
|
||||||
|
|
||||||
private formatDevStatNumber(value: number): string {
|
private formatDevStatNumber(value: number): string {
|
||||||
return Math.max(0, Math.round(value)).toLocaleString('en-US');
|
return Math.max(0, Math.round(value)).toLocaleString('en-US');
|
||||||
}
|
}
|
||||||
|
|
@ -298,4 +322,8 @@ export default class GameLoop {
|
||||||
: 1;
|
: 1;
|
||||||
return Math.min(GameLoop.MAX_MIRROR_SEGMENT_COUNT, Math.max(1, Math.round(count)));
|
return Math.min(GameLoop.MAX_MIRROR_SEGMENT_COUNT, Math.max(1, Math.round(count)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private get shouldShowDevStats(): boolean {
|
||||||
|
return import.meta.env.DEV || this.isStatsOverlayPinned;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -183,6 +183,14 @@ describe('GardenPointerInput drawing startup', () => {
|
||||||
expect(onStartDrawing).toHaveBeenCalledTimes(1);
|
expect(onStartDrawing).toHaveBeenCalledTimes(1);
|
||||||
expect(audio.start).toHaveBeenCalledWith(expect.anything(), { userGesture: true });
|
expect(audio.start).toHaveBeenCalledWith(expect.anything(), { userGesture: true });
|
||||||
expect(audio.beginGesture).toHaveBeenCalledTimes(1);
|
expect(audio.beginGesture).toHaveBeenCalledTimes(1);
|
||||||
|
expect(audio.touchDown).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
canvasSize: [300, 200],
|
||||||
|
colorIndex: 0,
|
||||||
|
position: expect.any(Float32Array),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(audio.stroke).not.toHaveBeenCalled();
|
||||||
expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(1);
|
expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(1);
|
||||||
expect(spawnStrokeAgents).toHaveBeenCalledTimes(1);
|
expect(spawnStrokeAgents).toHaveBeenCalledTimes(1);
|
||||||
expect(canvas.capturedPointerIds).toEqual([9]);
|
expect(canvas.capturedPointerIds).toEqual([9]);
|
||||||
|
|
|
||||||
|
|
@ -110,11 +110,14 @@ export class GardenPointerInput {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const position = this.getCanvasPointerPosition(event);
|
||||||
this.options.audio.start(activeVibe, { userGesture: event.isTrusted });
|
this.options.audio.start(activeVibe, { userGesture: event.isTrusted });
|
||||||
this.options.audio.beginGesture();
|
this.options.audio.beginGesture();
|
||||||
this.options.audio.touchDown({
|
this.options.audio.touchDown({
|
||||||
vibe: activeVibe,
|
vibe: activeVibe,
|
||||||
colorIndex: settings.selectedColorIndex,
|
colorIndex: settings.selectedColorIndex,
|
||||||
|
position,
|
||||||
|
canvasSize: this.options.getCanvasSize(),
|
||||||
mirrorSegmentCount: this.options.getMirrorSegmentCount(),
|
mirrorSegmentCount: this.options.getMirrorSegmentCount(),
|
||||||
pressure: this.getPointerPressure(event),
|
pressure: this.getPointerPressure(event),
|
||||||
pointerType: event.pointerType,
|
pointerType: event.pointerType,
|
||||||
|
|
@ -174,12 +177,8 @@ export class GardenPointerInput {
|
||||||
};
|
};
|
||||||
|
|
||||||
private addSwipeAt(event: PointerEvent, options: { emitAudio?: boolean } = {}): void {
|
private addSwipeAt(event: PointerEvent, options: { emitAudio?: boolean } = {}): void {
|
||||||
const rect = this.canvas.getBoundingClientRect();
|
|
||||||
const devicePixelRatio = this.options.getDevicePixelRatio();
|
const devicePixelRatio = this.options.getDevicePixelRatio();
|
||||||
const position = vec2.fromValues(
|
const position = this.getCanvasPointerPosition(event);
|
||||||
(event.clientX - rect.left) * devicePixelRatio,
|
|
||||||
(event.clientY - rect.top) * devicePixelRatio
|
|
||||||
);
|
|
||||||
const previousPosition = this.lastPointerPosition ?? position;
|
const previousPosition = this.lastPointerPosition ?? position;
|
||||||
const previousTimeMs = this.lastPointerEventTimeMs ?? event.timeStamp;
|
const previousTimeMs = this.lastPointerEventTimeMs ?? event.timeStamp;
|
||||||
const elapsedSeconds = Math.max(
|
const elapsedSeconds = Math.max(
|
||||||
|
|
@ -219,6 +218,7 @@ export class GardenPointerInput {
|
||||||
isErasing: this.isErasing,
|
isErasing: this.isErasing,
|
||||||
pressure: pressure > 0 ? pressure : this.lastPointerPressure,
|
pressure: pressure > 0 ? pressure : this.lastPointerPressure,
|
||||||
velocityPixelsPerSecond,
|
velocityPixelsPerSecond,
|
||||||
|
elapsedSeconds,
|
||||||
eraserSizePixels: settings.eraserSize * devicePixelRatio,
|
eraserSizePixels: settings.eraserSize * devicePixelRatio,
|
||||||
mirrorSegmentCount: this.options.getMirrorSegmentCount(),
|
mirrorSegmentCount: this.options.getMirrorSegmentCount(),
|
||||||
pointerType: event.pointerType,
|
pointerType: event.pointerType,
|
||||||
|
|
@ -228,6 +228,15 @@ export class GardenPointerInput {
|
||||||
this.lastPointerEventTimeMs = event.timeStamp;
|
this.lastPointerEventTimeMs = event.timeStamp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getCanvasPointerPosition(event: PointerEvent): vec2 {
|
||||||
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
|
const devicePixelRatio = this.options.getDevicePixelRatio();
|
||||||
|
return vec2.fromValues(
|
||||||
|
(event.clientX - rect.left) * devicePixelRatio,
|
||||||
|
(event.clientY - rect.top) * devicePixelRatio
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private addSmoothedBrushSample(position: vec2): void {
|
private addSmoothedBrushSample(position: vec2): void {
|
||||||
const previousSample =
|
const previousSample =
|
||||||
this.smoothedStrokePoints[this.smoothedStrokePoints.length - 1];
|
this.smoothedStrokePoints[this.smoothedStrokePoints.length - 1];
|
||||||
|
|
|
||||||
39
src/index.ts
|
|
@ -68,9 +68,9 @@ const renderRuntimeMessage = (
|
||||||
};
|
};
|
||||||
|
|
||||||
const elements = {
|
const elements = {
|
||||||
aside: queryRequiredElement('aside', HTMLDivElement),
|
aside: queryRequiredElement('aside', HTMLElement),
|
||||||
infoButton: queryRequiredElement('button.info', HTMLButtonElement),
|
infoButton: queryRequiredElement('button.info', HTMLButtonElement),
|
||||||
infoElement: queryRequiredElement('.info-page', HTMLDivElement),
|
infoElement: queryRequiredElement('.info-page', HTMLElement),
|
||||||
minimizeFullScreenButton: queryRequiredElement(
|
minimizeFullScreenButton: queryRequiredElement(
|
||||||
'button.minimize-full-screen',
|
'button.minimize-full-screen',
|
||||||
HTMLButtonElement
|
HTMLButtonElement
|
||||||
|
|
@ -84,20 +84,14 @@ const elements = {
|
||||||
restartButton: queryRequiredElement('button.restart', HTMLButtonElement),
|
restartButton: queryRequiredElement('button.restart', HTMLButtonElement),
|
||||||
canvas: queryRequiredElement('canvas', HTMLCanvasElement),
|
canvas: queryRequiredElement('canvas', HTMLCanvasElement),
|
||||||
eraserPreview: queryRequiredElement('.eraser-preview', HTMLDivElement),
|
eraserPreview: queryRequiredElement('.eraser-preview', HTMLDivElement),
|
||||||
errorContainer: queryRequiredElement('.errors-container', HTMLDivElement),
|
errorContainer: queryRequiredElement('.errors-container', HTMLElement),
|
||||||
previousVibe: queryRequiredElement('.previous-vibe', HTMLButtonElement),
|
previousVibe: queryRequiredElement('.previous-vibe', HTMLButtonElement),
|
||||||
nextVibe: queryRequiredElement('.next-vibe', HTMLButtonElement),
|
nextVibe: queryRequiredElement('.next-vibe', HTMLButtonElement),
|
||||||
swatches: queryRequiredElements('.color-swatch', HTMLButtonElement),
|
swatches: queryRequiredElements('.color-swatch', HTMLButtonElement),
|
||||||
eraserSizeControl: queryRequiredElement('.eraser-size-control', HTMLLabelElement),
|
eraserSizeControl: queryRequiredElement('.eraser-size-control', HTMLLabelElement),
|
||||||
eraserSizeSlider: queryRequiredElement('.eraser-size-slider', HTMLInputElement),
|
eraserSizeSlider: queryRequiredElement('.eraser-size-slider', HTMLInputElement),
|
||||||
mirrorSegmentControl: queryRequiredElement(
|
mirrorSegmentControl: queryRequiredElement('.mirror-segment-control', HTMLLabelElement),
|
||||||
'.mirror-segment-control',
|
mirrorSegmentSlider: queryRequiredElement('.mirror-segment-slider', HTMLInputElement),
|
||||||
HTMLLabelElement
|
|
||||||
),
|
|
||||||
mirrorSegmentSlider: queryRequiredElement(
|
|
||||||
'.mirror-segment-slider',
|
|
||||||
HTMLInputElement
|
|
||||||
),
|
|
||||||
export4k: queryRequiredElement('.export-4k', HTMLButtonElement),
|
export4k: queryRequiredElement('.export-4k', HTMLButtonElement),
|
||||||
exportStatus: queryRequiredElement('.export-status', HTMLSpanElement),
|
exportStatus: queryRequiredElement('.export-status', HTMLSpanElement),
|
||||||
prompt: queryRequiredElement('.garden-prompt', HTMLDivElement),
|
prompt: queryRequiredElement('.garden-prompt', HTMLDivElement),
|
||||||
|
|
@ -222,6 +216,7 @@ const main = async () => {
|
||||||
const configPane = new ConfigPane({
|
const configPane = new ConfigPane({
|
||||||
settingsButton: elements.settingsButton,
|
settingsButton: elements.settingsButton,
|
||||||
onConfigChange: syncRuntimeUi,
|
onConfigChange: syncRuntimeUi,
|
||||||
|
onOpenChange: (isOpen) => game?.setStatsOverlayPinned(isOpen),
|
||||||
onRuntimeChange: syncRuntimeUi,
|
onRuntimeChange: syncRuntimeUi,
|
||||||
onRuntimeReset: () => {
|
onRuntimeReset: () => {
|
||||||
resetSettings();
|
resetSettings();
|
||||||
|
|
@ -241,8 +236,7 @@ const main = async () => {
|
||||||
() =>
|
() =>
|
||||||
FullScreenHandler.isInFullScreenMode() &&
|
FullScreenHandler.isInFullScreenMode() &&
|
||||||
!configPane.isOpen &&
|
!configPane.isOpen &&
|
||||||
!infoPageHandler.isOpen,
|
!infoPageHandler.isOpen
|
||||||
{ persistentElement: elements.settingsButton }
|
|
||||||
);
|
);
|
||||||
new FullScreenHandler(
|
new FullScreenHandler(
|
||||||
elements.minimizeFullScreenButton,
|
elements.minimizeFullScreenButton,
|
||||||
|
|
@ -250,13 +244,6 @@ const main = async () => {
|
||||||
document.body
|
document.body
|
||||||
);
|
);
|
||||||
|
|
||||||
const fontsReady = document.fonts.ready.catch(() => undefined);
|
|
||||||
setLoadingStage('Connecting to GPU…', 0.1);
|
|
||||||
const gpu = await initializeGpu();
|
|
||||||
setLoadingStage('Loading fonts…', 0.4);
|
|
||||||
await fontsReady;
|
|
||||||
setLoadingStage('Compiling shaders…', 0.7);
|
|
||||||
|
|
||||||
elements.restartButton.addEventListener('click', () => game?.destroy());
|
elements.restartButton.addEventListener('click', () => game?.destroy());
|
||||||
elements.soundButton.addEventListener('click', (event) => {
|
elements.soundButton.addEventListener('click', (event) => {
|
||||||
isAudioMuted = !isAudioMuted;
|
isAudioMuted = !isAudioMuted;
|
||||||
|
|
@ -267,8 +254,6 @@ const main = async () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const deltaTimeCalculator = new DeltaTimeCalculator();
|
|
||||||
|
|
||||||
elements.previousVibe.addEventListener('click', (event) => {
|
elements.previousVibe.addEventListener('click', (event) => {
|
||||||
const current = VIBE_PRESETS.findIndex((vibe) => vibe.id === activeVibe.id);
|
const current = VIBE_PRESETS.findIndex((vibe) => vibe.id === activeVibe.id);
|
||||||
const vibe =
|
const vibe =
|
||||||
|
|
@ -345,6 +330,15 @@ const main = async () => {
|
||||||
renderMirrorSegmentUi();
|
renderMirrorSegmentUi();
|
||||||
renderAudioUi(game);
|
renderAudioUi(game);
|
||||||
|
|
||||||
|
const fontsReady = document.fonts.ready.catch(() => undefined);
|
||||||
|
setLoadingStage('Connecting to GPU…', 0.1);
|
||||||
|
const gpu = await initializeGpu();
|
||||||
|
setLoadingStage('Loading fonts…', 0.4);
|
||||||
|
await fontsReady;
|
||||||
|
setLoadingStage('Compiling shaders…', 0.7);
|
||||||
|
|
||||||
|
const deltaTimeCalculator = new DeltaTimeCalculator();
|
||||||
|
|
||||||
let isFirstStart = true;
|
let isFirstStart = true;
|
||||||
while (!shouldStop) {
|
while (!shouldStop) {
|
||||||
game = new GameLoop(elements.canvas, gpu, deltaTimeCalculator, {
|
game = new GameLoop(elements.canvas, gpu, deltaTimeCalculator, {
|
||||||
|
|
@ -352,6 +346,7 @@ const main = async () => {
|
||||||
eraserPreview: elements.eraserPreview,
|
eraserPreview: elements.eraserPreview,
|
||||||
exportStatus: elements.exportStatus,
|
exportStatus: elements.exportStatus,
|
||||||
});
|
});
|
||||||
|
game.setStatsOverlayPinned(configPane.isOpen);
|
||||||
renderPaletteUi(game);
|
renderPaletteUi(game);
|
||||||
renderEraserSizeUi(game);
|
renderEraserSizeUi(game);
|
||||||
renderMirrorSegmentUi();
|
renderMirrorSegmentUi();
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ const isColorReactionKey = (key: string): key is ColorReactionKey =>
|
||||||
|
|
||||||
interface ConfigPaneOptions {
|
interface ConfigPaneOptions {
|
||||||
onConfigChange: () => void;
|
onConfigChange: () => void;
|
||||||
|
onOpenChange?: (isOpen: boolean) => void;
|
||||||
onRestart: () => void;
|
onRestart: () => void;
|
||||||
onRuntimeChange: () => void;
|
onRuntimeChange: () => void;
|
||||||
onRuntimeReset: () => void;
|
onRuntimeReset: () => void;
|
||||||
|
|
@ -90,10 +91,7 @@ const getNumberBindingParams = (
|
||||||
export class ConfigPane {
|
export class ConfigPane {
|
||||||
private readonly container: HTMLDivElement;
|
private readonly container: HTMLDivElement;
|
||||||
private readonly pane: Pane;
|
private readonly pane: Pane;
|
||||||
private readonly colorReactionSelects = new Map<
|
private readonly colorReactionSelects = new Map<ColorReactionKey, HTMLSelectElement>();
|
||||||
ColorReactionKey,
|
|
||||||
HTMLSelectElement
|
|
||||||
>();
|
|
||||||
private readonly colorReactionSwatches: Array<{
|
private readonly colorReactionSwatches: Array<{
|
||||||
colorIndex: number;
|
colorIndex: number;
|
||||||
element: HTMLElement;
|
element: HTMLElement;
|
||||||
|
|
@ -139,7 +137,7 @@ export class ConfigPane {
|
||||||
|
|
||||||
this.setUpRuntimeTab(tabs.pages[0]);
|
this.setUpRuntimeTab(tabs.pages[0]);
|
||||||
this.setUpConfigTab(tabs.pages[1]);
|
this.setUpConfigTab(tabs.pages[1]);
|
||||||
this.syncButton();
|
this.syncOpenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
public get isOpen(): boolean {
|
public get isOpen(): boolean {
|
||||||
|
|
@ -150,17 +148,17 @@ export class ConfigPane {
|
||||||
this.state.activeVibeId = activeVibe.id;
|
this.state.activeVibeId = activeVibe.id;
|
||||||
this.pane.refresh();
|
this.pane.refresh();
|
||||||
this.syncColorReactionMatrix();
|
this.syncColorReactionMatrix();
|
||||||
this.syncButton();
|
this.syncOpenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly toggle = () => {
|
private readonly toggle = () => {
|
||||||
this.pane.hidden = !this.pane.hidden;
|
this.pane.hidden = !this.pane.hidden;
|
||||||
this.syncButton();
|
this.syncOpenState();
|
||||||
};
|
};
|
||||||
|
|
||||||
private setHidden(isHidden: boolean): void {
|
private setHidden(isHidden: boolean): void {
|
||||||
this.pane.hidden = isHidden;
|
this.pane.hidden = isHidden;
|
||||||
this.syncButton();
|
this.syncOpenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
private setUpRuntimeTab(container: PaneContainer): void {
|
private setUpRuntimeTab(container: PaneContainer): void {
|
||||||
|
|
@ -428,6 +426,11 @@ export class ConfigPane {
|
||||||
: 'Show config overlay';
|
: 'Show config overlay';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private syncOpenState(): void {
|
||||||
|
this.syncButton();
|
||||||
|
this.options.onOpenChange?.(this.isOpen);
|
||||||
|
}
|
||||||
|
|
||||||
public close(): void {
|
public close(): void {
|
||||||
this.setHidden(true);
|
this.setHidden(true);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,107 +1,144 @@
|
||||||
import { appConfig } from '../config';
|
import { appConfig } from '../config';
|
||||||
|
|
||||||
interface MenuHiderOptions {
|
|
||||||
persistentElement?: HTMLElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MenuHider {
|
export class MenuHider {
|
||||||
private static readonly DEFAULT_TIME_TO_LIVE = appConfig.menuHider.timeToLiveMs;
|
private readonly desktopMediaQuery = window.matchMedia(
|
||||||
private static readonly INTERVAL = appConfig.menuHider.intervalMs;
|
appConfig.menuHider.desktopMediaQuery
|
||||||
private static readonly BOTTOM_REVEAL_DISTANCE =
|
);
|
||||||
appConfig.menuHider.bottomRevealDistancePx;
|
private hideTimeout: number | undefined;
|
||||||
private readonly interactiveElements: Array<HTMLElement>;
|
|
||||||
private timeToLive = MenuHider.DEFAULT_TIME_TO_LIVE;
|
|
||||||
private isHidden = false;
|
private isHidden = false;
|
||||||
|
private pointerInside = false;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly element: HTMLElement,
|
private readonly element: HTMLElement,
|
||||||
private readonly shouldBeHidden: () => boolean,
|
private readonly shouldBeHidden: () => boolean
|
||||||
private readonly options: MenuHiderOptions = {}
|
|
||||||
) {
|
) {
|
||||||
this.interactiveElements = Array.from(
|
element.addEventListener('pointerenter', this.onPointerEnter);
|
||||||
element.querySelectorAll<HTMLElement>(
|
element.addEventListener('pointerleave', this.onPointerLeave);
|
||||||
'a[href], button, input, select, textarea, [tabindex]'
|
element.addEventListener('focusin', this.onFocusIn);
|
||||||
)
|
element.addEventListener('focusout', this.onFocusOut);
|
||||||
|
window.addEventListener('pointermove', this.onPointerMove, { passive: true });
|
||||||
|
document.addEventListener('fullscreenchange', this.onVisibilityContextChange);
|
||||||
|
this.desktopMediaQuery.addEventListener('change', this.onVisibilityContextChange);
|
||||||
|
|
||||||
|
this.reveal();
|
||||||
|
}
|
||||||
|
|
||||||
|
private get canAutoHide(): boolean {
|
||||||
|
return (
|
||||||
|
this.desktopMediaQuery.matches &&
|
||||||
|
this.shouldBeHidden() &&
|
||||||
|
!this.pointerInside &&
|
||||||
|
!this.element.contains(document.activeElement)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (options.persistentElement) {
|
|
||||||
element.classList.add('has-persistent-settings');
|
|
||||||
}
|
|
||||||
|
|
||||||
setInterval(() => {
|
|
||||||
this.timeToLive = Math.max(0, this.timeToLive - MenuHider.INTERVAL);
|
|
||||||
this.updateVisibility();
|
|
||||||
}, MenuHider.INTERVAL);
|
|
||||||
|
|
||||||
element.addEventListener('mouseover', this.wakeUp);
|
|
||||||
element.addEventListener('focusin', this.wakeUp);
|
|
||||||
element.addEventListener('pointerdown', this.wakeUp);
|
|
||||||
window.addEventListener('pointermove', this.wakeUpNearViewportBottom, {
|
|
||||||
passive: true,
|
|
||||||
});
|
|
||||||
window.addEventListener('pointerdown', this.wakeUp, {
|
|
||||||
capture: true,
|
|
||||||
passive: true,
|
|
||||||
});
|
|
||||||
window.addEventListener('touchstart', this.wakeUp, {
|
|
||||||
capture: true,
|
|
||||||
passive: true,
|
|
||||||
});
|
|
||||||
window.addEventListener('keydown', this.wakeUp, { capture: true });
|
|
||||||
window.addEventListener('focusin', this.wakeUp, { capture: true });
|
|
||||||
|
|
||||||
this.updateVisibility();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly wakeUp = () => {
|
private readonly onPointerEnter = () => {
|
||||||
this.timeToLive = MenuHider.DEFAULT_TIME_TO_LIVE;
|
this.pointerInside = true;
|
||||||
this.updateVisibility();
|
this.reveal();
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly wakeUpNearViewportBottom = (event: PointerEvent) => {
|
private readonly onPointerLeave = () => {
|
||||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
this.pointerInside = false;
|
||||||
const revealStart = viewportHeight - MenuHider.BOTTOM_REVEAL_DISTANCE;
|
this.scheduleHide();
|
||||||
|
|
||||||
if (event.clientY >= revealStart) {
|
|
||||||
this.wakeUp();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private updateVisibility() {
|
private readonly onFocusIn = () => {
|
||||||
const focusWithin = this.element.contains(document.activeElement);
|
this.reveal();
|
||||||
const shouldHide = this.timeToLive === 0 && this.shouldBeHidden() && !focusWithin;
|
};
|
||||||
|
|
||||||
if (this.isHidden === shouldHide) {
|
private readonly onFocusOut = () => {
|
||||||
|
window.setTimeout(() => this.scheduleHide(), 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly onPointerMove = (event: PointerEvent) => {
|
||||||
|
if (!this.desktopMediaQuery.matches || !this.shouldBeHidden()) {
|
||||||
|
this.reveal();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isHidden = shouldHide;
|
if (this.isPointerOverDock(event.clientX, event.clientY)) {
|
||||||
this.element.classList.toggle('menu-hidden', shouldHide);
|
this.pointerInside = true;
|
||||||
this.syncAccessibility(shouldHide);
|
this.reveal();
|
||||||
}
|
|
||||||
|
|
||||||
private syncAccessibility(shouldHide: boolean): void {
|
|
||||||
const persistentElement = this.options.persistentElement;
|
|
||||||
|
|
||||||
if (!persistentElement) {
|
|
||||||
this.element.style.opacity = shouldHide ? '0' : '1';
|
|
||||||
this.element.setAttribute('aria-hidden', String(shouldHide));
|
|
||||||
this.element.inert = shouldHide;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.element.style.opacity = '';
|
this.pointerInside = false;
|
||||||
|
|
||||||
|
if (this.isHidden) {
|
||||||
|
if (this.isNearViewportBottom(event.clientY)) {
|
||||||
|
this.reveal();
|
||||||
|
this.scheduleHide();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.scheduleHide();
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly onVisibilityContextChange = () => {
|
||||||
|
if (!this.desktopMediaQuery.matches || !this.shouldBeHidden()) {
|
||||||
|
this.reveal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.scheduleHide();
|
||||||
|
};
|
||||||
|
|
||||||
|
private scheduleHide(): void {
|
||||||
|
if (!this.canAutoHide) {
|
||||||
|
this.clearHideTimeout();
|
||||||
|
this.reveal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.hideTimeout !== undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.hideTimeout = window.setTimeout(() => {
|
||||||
|
this.hideTimeout = undefined;
|
||||||
|
if (this.canAutoHide) {
|
||||||
|
this.hide();
|
||||||
|
}
|
||||||
|
}, appConfig.menuHider.hideDelayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private reveal(): void {
|
||||||
|
this.clearHideTimeout();
|
||||||
|
this.isHidden = false;
|
||||||
|
this.element.classList.remove('menu-hidden');
|
||||||
this.element.setAttribute('aria-hidden', 'false');
|
this.element.setAttribute('aria-hidden', 'false');
|
||||||
this.element.inert = false;
|
this.element.inert = false;
|
||||||
|
}
|
||||||
|
|
||||||
this.interactiveElements.forEach((interactiveElement) => {
|
private hide(): void {
|
||||||
const isPersistentElement = interactiveElement === persistentElement;
|
this.isHidden = true;
|
||||||
|
this.element.classList.add('menu-hidden');
|
||||||
|
this.element.setAttribute('aria-hidden', 'true');
|
||||||
|
this.element.inert = true;
|
||||||
|
}
|
||||||
|
|
||||||
interactiveElement.inert = shouldHide && !isPersistentElement;
|
private clearHideTimeout(): void {
|
||||||
interactiveElement.toggleAttribute(
|
if (this.hideTimeout === undefined) {
|
||||||
'aria-hidden',
|
return;
|
||||||
shouldHide && !isPersistentElement
|
}
|
||||||
);
|
|
||||||
});
|
window.clearTimeout(this.hideTimeout);
|
||||||
|
this.hideTimeout = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isPointerOverDock(clientX: number, clientY: number): boolean {
|
||||||
|
const rect = this.element.getBoundingClientRect();
|
||||||
|
return (
|
||||||
|
clientX >= rect.left &&
|
||||||
|
clientX <= rect.right &&
|
||||||
|
clientY >= rect.top &&
|
||||||
|
clientY <= rect.bottom
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isNearViewportBottom(clientY: number): boolean {
|
||||||
|
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
||||||
|
return clientY >= viewportHeight - appConfig.menuHider.bottomRevealDistancePx;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,10 @@ export class AgentPipeline {
|
||||||
trailMapOut: GPUTextureView,
|
trailMapOut: GPUTextureView,
|
||||||
sourceMap: GPUTextureView
|
sourceMap: GPUTextureView
|
||||||
) {
|
) {
|
||||||
|
if (this.agentCount <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const bindGroup = this.getBindGroup(trailMapIn, trailMapOut, sourceMap);
|
const bindGroup = this.getBindGroup(trailMapIn, trailMapOut, sourceMap);
|
||||||
|
|
||||||
const passEncoder = commandEncoder.beginComputePass();
|
const passEncoder = commandEncoder.beginComputePass();
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ export class BrushPipeline {
|
||||||
private static readonly MAX_LINE_COUNT = appConfig.pipelines.brush.maxLineCount;
|
private static readonly MAX_LINE_COUNT = appConfig.pipelines.brush.maxLineCount;
|
||||||
private static readonly VERTICES_PER_LINE_SEGMENT = 6;
|
private static readonly VERTICES_PER_LINE_SEGMENT = 6;
|
||||||
private static readonly ATTRIBUTES_PER_LINE_SEGMENT = 6;
|
private static readonly ATTRIBUTES_PER_LINE_SEGMENT = 6;
|
||||||
|
private static readonly FEATHER_RADIUS_RATIO = 0.22;
|
||||||
|
|
||||||
private readonly bindGroupLayout: GPUBindGroupLayout;
|
private readonly bindGroupLayout: GPUBindGroupLayout;
|
||||||
private readonly bindGroup: GPUBindGroup;
|
private readonly bindGroup: GPUBindGroup;
|
||||||
|
|
@ -92,6 +93,18 @@ export class BrushPipeline {
|
||||||
targets: [
|
targets: [
|
||||||
{
|
{
|
||||||
format: 'rgba16float',
|
format: 'rgba16float',
|
||||||
|
blend: {
|
||||||
|
color: {
|
||||||
|
operation: 'max',
|
||||||
|
srcFactor: 'one',
|
||||||
|
dstFactor: 'one',
|
||||||
|
},
|
||||||
|
alpha: {
|
||||||
|
operation: 'max',
|
||||||
|
srcFactor: 'one',
|
||||||
|
dstFactor: 'one',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
@ -143,8 +156,14 @@ export class BrushPipeline {
|
||||||
selectedColorIndex,
|
selectedColorIndex,
|
||||||
isErasing,
|
isErasing,
|
||||||
}: BrushSettings & { selectedColorIndex: number; isErasing: boolean }) {
|
}: BrushSettings & { selectedColorIndex: number; isErasing: boolean }) {
|
||||||
this.uniformValues[0] = brushSize / 2;
|
const brushRadius = brushSize / 2;
|
||||||
this.uniformValues[1] = Math.floor((brushSize / 2) * brushSizeVariation);
|
const brushRadiusVariation = Math.floor(brushRadius * brushSizeVariation);
|
||||||
|
const brushFeather = Math.max(1, brushRadius * BrushPipeline.FEATHER_RADIUS_RATIO);
|
||||||
|
const brushGeometryRadius =
|
||||||
|
brushRadius + Math.max(0, brushRadiusVariation) + brushFeather;
|
||||||
|
|
||||||
|
this.uniformValues[0] = brushRadius;
|
||||||
|
this.uniformValues[1] = brushRadiusVariation;
|
||||||
this.uniformValues[2] = 0;
|
this.uniformValues[2] = 0;
|
||||||
this.uniformValues[3] = 0;
|
this.uniformValues[3] = 0;
|
||||||
this.uniformValues[4] = !isErasing && selectedColorIndex === 0 ? 1 : 0;
|
this.uniformValues[4] = !isErasing && selectedColorIndex === 0 ? 1 : 0;
|
||||||
|
|
@ -178,7 +197,7 @@ export class BrushPipeline {
|
||||||
floatOffset,
|
floatOffset,
|
||||||
segment.from,
|
segment.from,
|
||||||
segment.to,
|
segment.to,
|
||||||
brushSize / 2
|
brushGeometryRadius
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,12 +39,13 @@ fn fragment(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
|
||||||
strengths.r * settings.colorA
|
strengths.r * settings.colorA
|
||||||
+ strengths.g * settings.colorB
|
+ strengths.g * settings.colorB
|
||||||
+ strengths.b * settings.colorC;
|
+ strengths.b * settings.colorC;
|
||||||
|
let normalizedTraceColor = traceColor / max(1.0, strengths.r + strengths.g + strengths.b);
|
||||||
let brushColor =
|
let brushColor =
|
||||||
sourceStrengths.r * settings.colorA
|
sourceStrengths.r * settings.colorA
|
||||||
+ sourceStrengths.g * settings.colorB
|
+ sourceStrengths.g * settings.colorB
|
||||||
+ sourceStrengths.b * settings.colorC;
|
+ sourceStrengths.b * settings.colorC;
|
||||||
let brushStrength = clamp(max(max(sourceStrengths.r, sourceStrengths.g), sourceStrengths.b), 0, 1);
|
let brushStrength = clamp(max(max(sourceStrengths.r, sourceStrengths.g), sourceStrengths.b), 0, 1);
|
||||||
let color = max(traceColor, brushColor * (1.2 + brushStrength * 1.6));
|
let color = max(normalizedTraceColor, brushColor * (1.2 + brushStrength * 1.6));
|
||||||
|
|
||||||
let strength = clamp(max(max(max(strengths.r, strengths.g), strengths.b), brushStrength), 0, 1);
|
let strength = clamp(max(max(max(strengths.r, strengths.g), strengths.b), brushStrength), 0, 1);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,10 @@ html > body {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
|
|
||||||
|
&[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
> .errors-container {
|
> .errors-container {
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,19 @@
|
||||||
html > body > aside.control-dock {
|
html > body > aside.control-dock {
|
||||||
|
--dock-hidden-translate-y: calc(100% + env(safe-area-inset-bottom, 0px) + 16px);
|
||||||
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
left: 0;
|
||||||
bottom: env(safe-area-inset-bottom);
|
right: 0;
|
||||||
|
bottom: env(safe-area-inset-bottom, 0px);
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
width: min(calc(100vw - 1rem), 980px);
|
width: min(calc(100vw - 1rem), 980px);
|
||||||
transform: translate(-50%, 0);
|
margin: 0 auto;
|
||||||
translate: 0 0;
|
transform: translateY(0);
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition:
|
transition:
|
||||||
opacity var(--transition-time-long),
|
opacity var(--transition-time-long),
|
||||||
transform var(--transition-time-long),
|
transform var(--transition-time-long),
|
||||||
translate var(--transition-time-long),
|
|
||||||
visibility 0s;
|
visibility 0s;
|
||||||
|
|
||||||
> .toolbar-row,
|
> .toolbar-row,
|
||||||
|
|
@ -22,7 +24,7 @@ html > body > aside.control-dock {
|
||||||
&.menu-hidden {
|
&.menu-hidden {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
transform: translate(-50%, 10px);
|
transform: translateY(var(--dock-hidden-translate-y));
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition:
|
transition:
|
||||||
opacity var(--transition-time-long),
|
opacity var(--transition-time-long),
|
||||||
|
|
@ -34,32 +36,4 @@ html > body > aside.control-dock {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.menu-hidden.has-persistent-settings {
|
|
||||||
opacity: 1;
|
|
||||||
visibility: visible;
|
|
||||||
transform: translate(-50%, 0);
|
|
||||||
|
|
||||||
> .pages,
|
|
||||||
> .toolbar-row > .vibe-button,
|
|
||||||
> .toolbar-row > .toolbar-shell > .garden-controls,
|
|
||||||
> .toolbar-row > .toolbar-shell > nav.buttons > button:not(.settings),
|
|
||||||
> .toolbar-row > .toolbar-shell > nav.buttons > .export-status {
|
|
||||||
opacity: 0;
|
|
||||||
visibility: hidden;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
> .toolbar-row,
|
|
||||||
> .toolbar-row > .toolbar-shell,
|
|
||||||
> .toolbar-row > .toolbar-shell > nav.buttons {
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
> .toolbar-row > .toolbar-shell > nav.buttons > button.settings {
|
|
||||||
visibility: visible;
|
|
||||||
opacity: 1;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,11 +71,7 @@
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: var(--loading-progress);
|
width: var(--loading-progress);
|
||||||
border-radius: inherit;
|
border-radius: inherit;
|
||||||
background: linear-gradient(
|
background: linear-gradient(90deg, rgb(255 255 255 / 72%), rgb(255 255 255 / 96%));
|
||||||
90deg,
|
|
||||||
rgb(255 255 255 / 72%),
|
|
||||||
rgb(255 255 255 / 96%)
|
|
||||||
);
|
|
||||||
box-shadow: 0 0 12px rgb(255 255 255 / 38%);
|
box-shadow: 0 0 12px rgb(255 255 255 / 38%);
|
||||||
transition: width var(--transition-time-long) ease-out;
|
transition: width var(--transition-time-long) ease-out;
|
||||||
}
|
}
|
||||||
|
|
@ -94,7 +90,7 @@ html > body.is-loading {
|
||||||
aside.control-dock {
|
aside.control-dock {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
translate: 0 36px;
|
transform: translateY(var(--dock-hidden-translate-y));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
> aside.control-dock {
|
> aside.control-dock {
|
||||||
&,
|
transform: translateY(0);
|
||||||
&.menu-hidden {
|
|
||||||
transform: translateX(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
> .toolbar-row {
|
> .toolbar-row {
|
||||||
button:hover,
|
button:hover,
|
||||||
|
|
@ -30,5 +27,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.is-loading aside.control-dock {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
34
src/utils/graphics/get-workgroup-counts.test.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { getWorkgroupCounts } from './get-workgroup-counts';
|
||||||
|
|
||||||
|
const makeDevice = (maxComputeWorkgroupsPerDimension: number): GPUDevice =>
|
||||||
|
({
|
||||||
|
limits: {
|
||||||
|
maxComputeWorkgroupsPerDimension,
|
||||||
|
},
|
||||||
|
}) as GPUDevice;
|
||||||
|
|
||||||
|
describe('getWorkgroupCounts', () => {
|
||||||
|
it('returns at least one workgroup for positive invocation counts', () => {
|
||||||
|
expect(getWorkgroupCounts(makeDevice(65_535), 1, 64)).toEqual([1, 1, 1]);
|
||||||
|
expect(getWorkgroupCounts(makeDevice(65_535), 65, 64)).toEqual([2, 1, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects zero and non-finite dispatch inputs', () => {
|
||||||
|
const device = makeDevice(65_535);
|
||||||
|
|
||||||
|
expect(() => getWorkgroupCounts(device, 0, 64)).toThrow(/positive finite/);
|
||||||
|
expect(() => getWorkgroupCounts(device, -1, 64)).toThrow(/positive finite/);
|
||||||
|
expect(() => getWorkgroupCounts(device, Number.POSITIVE_INFINITY, 64)).toThrow(
|
||||||
|
/positive finite/
|
||||||
|
);
|
||||||
|
expect(() => getWorkgroupCounts(device, 1, 0)).toThrow(/positive finite/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invocation counts that exceed device workgroup limits', () => {
|
||||||
|
expect(() => getWorkgroupCounts(makeDevice(2), 9, 1)).toThrow(
|
||||||
|
'Cannot have this many invocations'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -3,6 +3,17 @@ export const getWorkgroupCounts = (
|
||||||
invocationCount: number,
|
invocationCount: number,
|
||||||
workgroupSize: number
|
workgroupSize: number
|
||||||
): [number, number, number] => {
|
): [number, number, number] => {
|
||||||
|
if (
|
||||||
|
!Number.isFinite(invocationCount) ||
|
||||||
|
!Number.isFinite(workgroupSize) ||
|
||||||
|
invocationCount <= 0 ||
|
||||||
|
workgroupSize <= 0
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'Invocation count and workgroup size must be positive finite numbers'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const workgroupCount = Math.ceil(invocationCount / workgroupSize);
|
const workgroupCount = Math.ceil(invocationCount / workgroupSize);
|
||||||
|
|
||||||
const workgroupCountX = Math.min(
|
const workgroupCountX = Math.min(
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ export const initializeContext = ({
|
||||||
device: GPUDevice;
|
device: GPUDevice;
|
||||||
canvas: HTMLCanvasElement;
|
canvas: HTMLCanvasElement;
|
||||||
}): GPUCanvasContext => {
|
}): GPUCanvasContext => {
|
||||||
const context = canvas.getContext('webgpu' as any) as GPUCanvasContext | null;
|
const context = canvas.getContext('webgpu');
|
||||||
|
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new RuntimeError(
|
throw new RuntimeError(
|
||||||
|
|
|
||||||
253
src/utils/graphics/initialize-gpu.test.ts
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { ErrorCode, ErrorHandler, RuntimeError, Severity } from '../error-handler';
|
||||||
|
import { initializeGpu } from './initialize-gpu';
|
||||||
|
|
||||||
|
const gpuLimits = {
|
||||||
|
maxBufferSize: 256 * 1024 * 1024,
|
||||||
|
maxComputeWorkgroupsPerDimension: 65_535,
|
||||||
|
maxStorageBufferBindingSize: 128 * 1024 * 1024,
|
||||||
|
} as GPUSupportedLimits;
|
||||||
|
|
||||||
|
const observedErrors: Array<{
|
||||||
|
code?: string;
|
||||||
|
message: string;
|
||||||
|
severity: Severity;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
ErrorHandler.addOnErrorListener((error) => {
|
||||||
|
observedErrors.push(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
const defer = <T>() => {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
const promise = new Promise<T>((nextResolve) => {
|
||||||
|
resolve = nextResolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { promise, resolve };
|
||||||
|
};
|
||||||
|
|
||||||
|
const stubBrowser = ({
|
||||||
|
gpu,
|
||||||
|
isSecureContext = true,
|
||||||
|
}: {
|
||||||
|
gpu?: GPU;
|
||||||
|
isSecureContext?: boolean;
|
||||||
|
}) => {
|
||||||
|
vi.stubGlobal('window', { isSecureContext });
|
||||||
|
vi.stubGlobal('navigator', { gpu });
|
||||||
|
};
|
||||||
|
|
||||||
|
const createDevice = (
|
||||||
|
lost: Promise<GPUDeviceLostInfo> = new Promise<GPUDeviceLostInfo>(() => {})
|
||||||
|
) => {
|
||||||
|
const listeners = new Map<string, EventListener>();
|
||||||
|
const device = {
|
||||||
|
addEventListener: vi.fn((type: string, listener: EventListener) => {
|
||||||
|
listeners.set(type, listener);
|
||||||
|
}),
|
||||||
|
lost,
|
||||||
|
} as unknown as GPUDevice;
|
||||||
|
|
||||||
|
return { device, listeners };
|
||||||
|
};
|
||||||
|
|
||||||
|
const createAdapter = ({
|
||||||
|
requestDevice = vi.fn(),
|
||||||
|
}: {
|
||||||
|
requestDevice?: ReturnType<typeof vi.fn>;
|
||||||
|
} = {}) =>
|
||||||
|
({
|
||||||
|
features: new Set(),
|
||||||
|
info: {
|
||||||
|
architecture: 'test',
|
||||||
|
description: 'unit-test adapter',
|
||||||
|
device: 'test-device',
|
||||||
|
isFallbackAdapter: false,
|
||||||
|
subgroupMaxSize: 0,
|
||||||
|
subgroupMinSize: 0,
|
||||||
|
vendor: 'test-vendor',
|
||||||
|
},
|
||||||
|
limits: gpuLimits,
|
||||||
|
requestDevice,
|
||||||
|
}) as unknown as GPUAdapter;
|
||||||
|
|
||||||
|
const captureInitializeGpuError = async (): Promise<RuntimeError> => {
|
||||||
|
try {
|
||||||
|
await initializeGpu();
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(RuntimeError);
|
||||||
|
return error as RuntimeError;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Expected initializeGpu to reject.');
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('initializeGpu', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
observedErrors.length = 0;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects insecure contexts before touching WebGPU', async () => {
|
||||||
|
stubBrowser({ isSecureContext: false });
|
||||||
|
|
||||||
|
const error = await captureInitializeGpuError();
|
||||||
|
|
||||||
|
expect(error.code).toBe(ErrorCode.WEBGPU_INSECURE_CONTEXT);
|
||||||
|
expect(error.message).toContain('WebGPU requires a secure context');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects browsers without navigator.gpu', async () => {
|
||||||
|
stubBrowser({});
|
||||||
|
|
||||||
|
const error = await captureInitializeGpuError();
|
||||||
|
|
||||||
|
expect(error.code).toBe(ErrorCode.WEBGPU_UNSUPPORTED);
|
||||||
|
expect(error.message).toContain('Fleeting Garden needs WebGPU');
|
||||||
|
expect(error.details).toMatchObject({
|
||||||
|
hasNavigatorGpu: false,
|
||||||
|
isSecureContext: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps adapter request exceptions with adapter diagnostics', async () => {
|
||||||
|
const requestAdapter = vi.fn(async () => {
|
||||||
|
throw new Error('adapter request failed');
|
||||||
|
});
|
||||||
|
stubBrowser({ gpu: { requestAdapter } as unknown as GPU });
|
||||||
|
|
||||||
|
const error = await captureInitializeGpuError();
|
||||||
|
|
||||||
|
expect(requestAdapter).toHaveBeenCalledOnce();
|
||||||
|
expect(requestAdapter).toHaveBeenCalledWith({ powerPreference: 'high-performance' });
|
||||||
|
expect(error.code).toBe(ErrorCode.WEBGPU_ADAPTER_UNAVAILABLE);
|
||||||
|
expect(error.message).toBe('Could not request a WebGPU adapter.');
|
||||||
|
expect(error.details).toMatchObject({
|
||||||
|
causeMessage: 'adapter request failed',
|
||||||
|
powerPreference: 'high-performance',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tries the default adapter before reporting adapter unavailability', async () => {
|
||||||
|
const requestAdapter = vi.fn(async () => null);
|
||||||
|
stubBrowser({ gpu: { requestAdapter } as unknown as GPU });
|
||||||
|
|
||||||
|
const error = await captureInitializeGpuError();
|
||||||
|
|
||||||
|
expect(requestAdapter).toHaveBeenNthCalledWith(1, {
|
||||||
|
powerPreference: 'high-performance',
|
||||||
|
});
|
||||||
|
expect(requestAdapter).toHaveBeenNthCalledWith(2, undefined);
|
||||||
|
expect(error.code).toBe(ErrorCode.WEBGPU_ADAPTER_UNAVAILABLE);
|
||||||
|
expect(error.message).toContain('could not provide a compatible GPU adapter');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requests the device with the adapter limits needed by the pipelines', async () => {
|
||||||
|
const { device } = createDevice();
|
||||||
|
const requestDevice = vi.fn(async () => device);
|
||||||
|
const adapter = createAdapter({ requestDevice });
|
||||||
|
stubBrowser({
|
||||||
|
gpu: { requestAdapter: vi.fn(async () => adapter) } as unknown as GPU,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(initializeGpu()).resolves.toBe(device);
|
||||||
|
|
||||||
|
expect(requestDevice).toHaveBeenCalledWith({
|
||||||
|
requiredLimits: {
|
||||||
|
maxBufferSize: gpuLimits.maxBufferSize,
|
||||||
|
maxComputeWorkgroupsPerDimension: gpuLimits.maxComputeWorkgroupsPerDimension,
|
||||||
|
maxStorageBufferBindingSize: gpuLimits.maxStorageBufferBindingSize,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(device.addEventListener).toHaveBeenCalledWith(
|
||||||
|
'uncapturederror',
|
||||||
|
expect.any(Function)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps device request failures with required limit details', async () => {
|
||||||
|
const requestDevice = vi.fn(async () => {
|
||||||
|
throw new Error('device request failed');
|
||||||
|
});
|
||||||
|
const adapter = createAdapter({ requestDevice });
|
||||||
|
stubBrowser({
|
||||||
|
gpu: { requestAdapter: vi.fn(async () => adapter) } as unknown as GPU,
|
||||||
|
});
|
||||||
|
|
||||||
|
const error = await captureInitializeGpuError();
|
||||||
|
|
||||||
|
expect(error.code).toBe(ErrorCode.WEBGPU_DEVICE_UNAVAILABLE);
|
||||||
|
expect(error.message).toBe('Could not create a WebGPU device for this adapter.');
|
||||||
|
expect(error.details).toMatchObject({
|
||||||
|
causeMessage: 'device request failed',
|
||||||
|
requiredLimits: {
|
||||||
|
maxBufferSize: gpuLimits.maxBufferSize,
|
||||||
|
maxComputeWorkgroupsPerDimension: gpuLimits.maxComputeWorkgroupsPerDimension,
|
||||||
|
maxStorageBufferBindingSize: gpuLimits.maxStorageBufferBindingSize,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes uncaptured GPU errors through the runtime error handler', async () => {
|
||||||
|
const { device, listeners } = createDevice();
|
||||||
|
const adapter = createAdapter({ requestDevice: vi.fn(async () => device) });
|
||||||
|
stubBrowser({
|
||||||
|
gpu: { requestAdapter: vi.fn(async () => adapter) } as unknown as GPU,
|
||||||
|
});
|
||||||
|
|
||||||
|
await initializeGpu();
|
||||||
|
listeners.get('uncapturederror')?.({
|
||||||
|
error: new Error('uncaptured GPU validation failure'),
|
||||||
|
} as unknown as GPUUncapturedErrorEvent);
|
||||||
|
|
||||||
|
expect(observedErrors.at(-1)).toMatchObject({
|
||||||
|
code: ErrorCode.WEBGPU_UNCAPTURED_ERROR,
|
||||||
|
message: 'uncaptured GPU validation failure',
|
||||||
|
severity: Severity.ERROR,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports unexpected device loss but ignores intentional destruction', async () => {
|
||||||
|
const unexpectedLoss = defer<GPUDeviceLostInfo>();
|
||||||
|
const { device } = createDevice(unexpectedLoss.promise);
|
||||||
|
const adapter = createAdapter({ requestDevice: vi.fn(async () => device) });
|
||||||
|
stubBrowser({
|
||||||
|
gpu: { requestAdapter: vi.fn(async () => adapter) } as unknown as GPU,
|
||||||
|
});
|
||||||
|
|
||||||
|
await initializeGpu();
|
||||||
|
unexpectedLoss.resolve({
|
||||||
|
message: 'device lost during rendering',
|
||||||
|
reason: 'unknown',
|
||||||
|
} as GPUDeviceLostInfo);
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(observedErrors.at(-1)).toMatchObject({
|
||||||
|
code: ErrorCode.WEBGPU_DEVICE_LOST,
|
||||||
|
message: 'device lost during rendering',
|
||||||
|
severity: Severity.ERROR,
|
||||||
|
});
|
||||||
|
|
||||||
|
observedErrors.length = 0;
|
||||||
|
const destroyedLoss = defer<GPUDeviceLostInfo>();
|
||||||
|
const { device: destroyedDevice } = createDevice(destroyedLoss.promise);
|
||||||
|
const destroyedAdapter = createAdapter({
|
||||||
|
requestDevice: vi.fn(async () => destroyedDevice),
|
||||||
|
});
|
||||||
|
stubBrowser({
|
||||||
|
gpu: { requestAdapter: vi.fn(async () => destroyedAdapter) } as unknown as GPU,
|
||||||
|
});
|
||||||
|
|
||||||
|
await initializeGpu();
|
||||||
|
destroyedLoss.resolve({
|
||||||
|
message: 'device destroyed intentionally',
|
||||||
|
reason: 'destroyed',
|
||||||
|
} as GPUDeviceLostInfo);
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(observedErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -15,8 +15,8 @@
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": false,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": false
|
"noUnusedParameters": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*", "definitions.d.ts", "vite.config.ts"]
|
"include": ["src/**/*", "definitions.d.ts", "vite.config.ts"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||