4
definitions.d.ts
vendored
|
|
@ -2,3 +2,7 @@ declare module '*.wgsl?raw' {
|
|||
const content: string;
|
||||
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(() => {
|
||||
Object.defineProperty(navigator, 'gpu', {
|
||||
configurable: true,
|
||||
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('/');
|
||||
|
||||
|
|
@ -21,3 +86,224 @@ test('loads the app shell and WebGPU fallback in Chromium', async ({ page }) =>
|
|||
await page.getByRole('button', { name: 'About' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Fleeting Garden' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('keeps fallback controls interactive and accessible', async ({ page }) => {
|
||||
await disableWebGpu(page);
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
|
||||
|
||||
const aboutButton = page.getByRole('button', { name: 'About' });
|
||||
const aboutPanel = page.locator('#info-panel');
|
||||
await expect(aboutButton).toHaveAttribute('aria-expanded', 'false');
|
||||
await aboutButton.click();
|
||||
await expect(aboutButton).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(aboutPanel).toHaveAttribute('aria-hidden', 'false');
|
||||
await expect(aboutPanel).not.toHaveAttribute('inert', '');
|
||||
await expect(page.getByRole('heading', { name: 'Fleeting Garden' })).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(aboutButton).toHaveAttribute('aria-expanded', 'false');
|
||||
await expect(aboutPanel).toHaveAttribute('aria-hidden', 'true');
|
||||
await expect(aboutPanel).toHaveAttribute('inert', '');
|
||||
|
||||
const settingsButton = page.getByRole('button', { name: 'Show config overlay' });
|
||||
await settingsButton.click();
|
||||
await expect(page.getByRole('button', { name: 'Hide config overlay' })).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'true'
|
||||
);
|
||||
await expect(page.locator('.config-pane')).toBeVisible();
|
||||
|
||||
const soundButton = page.locator('button.sound');
|
||||
await expect(soundButton).toHaveAttribute('aria-pressed', 'false');
|
||||
await soundButton.click();
|
||||
await expect(soundButton).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(soundButton).toHaveAttribute('aria-label', 'Unmute audio');
|
||||
await page.reload();
|
||||
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
|
||||
await expect(page.locator('button.sound')).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
const initialSwatchColor = await getFirstSwatchColor(page);
|
||||
const initialBackground = await getGardenBackground(page);
|
||||
await page.getByRole('button', { name: 'Next vibe' }).click();
|
||||
await expect.poll(() => getFirstSwatchColor(page)).not.toBe(initialSwatchColor);
|
||||
await expect.poll(() => getGardenBackground(page)).not.toBe(initialBackground);
|
||||
|
||||
await page.getByRole('button', { name: 'Draw colour 2' }).click();
|
||||
await expect(page.locator('.color-swatch').nth(1)).toHaveClass(/active/);
|
||||
await expect(page.locator('.color-swatch').first()).not.toHaveClass(/active/);
|
||||
|
||||
const mirrorSlider = page.locator('.mirror-segment-slider');
|
||||
await mirrorSlider.evaluate((input) => {
|
||||
const slider = input as HTMLInputElement;
|
||||
slider.value = '3';
|
||||
slider.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
await expect(page.locator('.mirror-segment-control')).toHaveAttribute(
|
||||
'title',
|
||||
'3 thirds'
|
||||
);
|
||||
await expect(page.locator('.mirror-segment-control')).toHaveClass(/active/);
|
||||
});
|
||||
|
||||
(
|
||||
[
|
||||
{
|
||||
expectedCode: 'webgpu-adapter-unavailable',
|
||||
expectedMessage:
|
||||
'WebGPU is available, but this browser could not provide a compatible GPU adapter.',
|
||||
mode: 'adapter-null',
|
||||
},
|
||||
{
|
||||
expectedCode: 'webgpu-adapter-unavailable',
|
||||
expectedMessage: 'Could not request a WebGPU adapter.',
|
||||
mode: 'adapter-rejects',
|
||||
},
|
||||
{
|
||||
expectedCode: 'webgpu-device-unavailable',
|
||||
expectedMessage: 'Could not create a WebGPU device for this adapter.',
|
||||
mode: 'device-rejects',
|
||||
},
|
||||
] satisfies Array<{
|
||||
expectedCode: string;
|
||||
expectedMessage: string;
|
||||
mode: WebGpuFailureMode;
|
||||
}>
|
||||
).forEach(({ expectedCode, expectedMessage, mode }) => {
|
||||
test(`reports ${mode} startup failures without leaving the shell loading`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await emulateWebGpuFailure(page, mode);
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
|
||||
await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible();
|
||||
await expect(page.getByRole('alert')).toContainText(expectedMessage);
|
||||
await expect(page.getByRole('alert')).toContainText(expectedCode);
|
||||
});
|
||||
});
|
||||
|
||||
test('serves the production bundle without missing browser assets', async ({ page }) => {
|
||||
const browserFailures: Array<string> = [];
|
||||
|
||||
page.on('requestfailed', (request) => {
|
||||
const failure = request.failure();
|
||||
browserFailures.push(`${request.method()} ${request.url()} ${failure?.errorText}`);
|
||||
});
|
||||
page.on('response', (response) => {
|
||||
if (response.status() >= 400) {
|
||||
browserFailures.push(`${response.status()} ${response.url()}`);
|
||||
}
|
||||
});
|
||||
|
||||
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)">
|
||||
<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
|
||||
d="M32 34c1.2-7.2 4.8-12.3 10-16"
|
||||
fill="none"
|
||||
|
|
@ -18,14 +18,14 @@
|
|||
<path
|
||||
d="M32 34c1.2-7.2 4.8-12.3 10-16"
|
||||
fill="none"
|
||||
stroke="#ff2fa3"
|
||||
stroke="#ff5da2"
|
||||
stroke-linecap="round"
|
||||
stroke-width="4"
|
||||
/>
|
||||
<ellipse cx="42" cy="11.5" rx="4.2" ry="6.4" fill="#ff2fa3" />
|
||||
<ellipse cx="48.5" cy="18" rx="6.4" ry="4.2" fill="#ff2fa3" />
|
||||
<ellipse cx="42" cy="24.5" rx="4.2" ry="6.4" fill="#ff2fa3" />
|
||||
<ellipse cx="35.5" cy="18" rx="6.4" ry="4.2" 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="#ff5da2" />
|
||||
<ellipse cx="42" cy="24.5" rx="4.2" ry="6.4" fill="#ff5da2" />
|
||||
<ellipse cx="35.5" cy="18" rx="6.4" ry="4.2" fill="#ff5da2" />
|
||||
<circle cx="42" cy="18" r="3.2" fill="#10151f" />
|
||||
</g>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 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,
|
||||
].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 isExported = (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) =>
|
||||
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 usedExports = new Set();
|
||||
|
|
@ -167,10 +174,15 @@ const parsedFiles = files.map((file) => ({
|
|||
}));
|
||||
|
||||
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())
|
||||
.filter(([key, declaration]) => !usedExports.has(key) && !wildcardUsedFiles.has(declaration.file))
|
||||
.filter(
|
||||
([key, declaration]) =>
|
||||
!usedExports.has(key) && !wildcardUsedFiles.has(declaration.file)
|
||||
)
|
||||
.map(([, declaration]) => declaration)
|
||||
.sort((left, right) =>
|
||||
`${left.file}:${left.name}`.localeCompare(`${right.file}:${right.name}`)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { appConfig } from '../config';
|
||||
import { GardenAudioEnergy } from './garden-audio-energy';
|
||||
|
||||
describe('GardenAudioEnergy', () => {
|
||||
it('suspends activity but keeps a fading level when the gesture ends', () => {
|
||||
const energy = new GardenAudioEnergy();
|
||||
const energy = new GardenAudioEnergy(appConfig.audioEngine);
|
||||
|
||||
energy.beginGesture(0);
|
||||
energy.recordStroke(0.8, 0.1);
|
||||
|
|
@ -24,7 +25,7 @@ describe('GardenAudioEnergy', () => {
|
|||
});
|
||||
|
||||
it('uses recent stroke intensity rather than gesture duration alone', () => {
|
||||
const energy = new GardenAudioEnergy();
|
||||
const energy = new GardenAudioEnergy(appConfig.audioEngine);
|
||||
|
||||
energy.beginGesture(0);
|
||||
energy.recordStroke(1, 0.1);
|
||||
|
|
@ -38,7 +39,7 @@ describe('GardenAudioEnergy', () => {
|
|||
});
|
||||
|
||||
it('raises activity immediately when a stroke is recorded', () => {
|
||||
const energy = new GardenAudioEnergy();
|
||||
const energy = new GardenAudioEnergy(appConfig.audioEngine);
|
||||
|
||||
energy.beginGesture(0);
|
||||
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 {
|
||||
distancePixels: number;
|
||||
pressure: number;
|
||||
speedPixelsPerSecond: number;
|
||||
speedAmount: number;
|
||||
effectiveEnergy: number;
|
||||
}
|
||||
|
|
@ -35,6 +36,7 @@ export const getStrokeMetrics = (
|
|||
return {
|
||||
distancePixels,
|
||||
pressure,
|
||||
speedPixelsPerSecond,
|
||||
speedAmount,
|
||||
effectiveEnergy,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export interface GardenAudioStroke {
|
|||
isErasing: boolean;
|
||||
pressure?: number;
|
||||
velocityPixelsPerSecond?: number;
|
||||
elapsedSeconds?: number;
|
||||
eraserSizePixels?: number;
|
||||
mirrorSegmentCount?: number;
|
||||
pointerType?: string;
|
||||
|
|
@ -26,6 +27,8 @@ export interface GardenAudioStroke {
|
|||
export interface GardenAudioTouchDown {
|
||||
vibe: VibePreset;
|
||||
colorIndex: number;
|
||||
position?: ArrayLike<number>;
|
||||
canvasSize?: ArrayLike<number>;
|
||||
mirrorSegmentCount?: number;
|
||||
pressure?: number;
|
||||
pointerType?: string;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { appConfig } from '../config';
|
||||
import { VIBE_PRESETS } from '../vibes';
|
||||
import { GardenAudio } from './garden-audio';
|
||||
import { gardenAudioConfig, GardenAudioConfig } from './garden-audio-config';
|
||||
|
|
@ -7,6 +8,7 @@ import { gardenAudioConfig, GardenAudioConfig } from './garden-audio-config';
|
|||
const calls = {
|
||||
constructed: 0,
|
||||
resumed: 0,
|
||||
sourcesStarted: 0,
|
||||
};
|
||||
|
||||
let contextState: AudioContextState = 'suspended';
|
||||
|
|
@ -22,13 +24,16 @@ class FakeAudioParam {
|
|||
class FakeAudioNode {
|
||||
public readonly gain = new FakeAudioParam();
|
||||
public readonly frequency = new FakeAudioParam();
|
||||
public readonly Q = new FakeAudioParam();
|
||||
public readonly threshold = new FakeAudioParam();
|
||||
public readonly knee = new FakeAudioParam();
|
||||
public readonly ratio = new FakeAudioParam();
|
||||
public readonly attack = new FakeAudioParam();
|
||||
public readonly release = new FakeAudioParam();
|
||||
public readonly delayTime = new FakeAudioParam();
|
||||
public readonly pan = new FakeAudioParam();
|
||||
public type = '';
|
||||
public addEventListener = vi.fn();
|
||||
public connect = vi.fn();
|
||||
public disconnect = vi.fn();
|
||||
}
|
||||
|
|
@ -78,6 +83,10 @@ class FakeAudioContext {
|
|||
return new FakeAudioNode() as unknown as DelayNode;
|
||||
}
|
||||
|
||||
public createStereoPanner(): StereoPannerNode {
|
||||
return new FakeAudioNode() as unknown as StereoPannerNode;
|
||||
}
|
||||
|
||||
public createBuffer(_channels: number, length: number): AudioBuffer {
|
||||
return new FakeAudioBuffer(length) as unknown as AudioBuffer;
|
||||
}
|
||||
|
|
@ -89,7 +98,9 @@ class FakeAudioContext {
|
|||
stop: () => void;
|
||||
};
|
||||
node.buffer = null;
|
||||
node.start = vi.fn();
|
||||
node.start = vi.fn(() => {
|
||||
calls.sourcesStarted += 1;
|
||||
});
|
||||
node.stop = vi.fn();
|
||||
return node;
|
||||
}
|
||||
|
|
@ -108,6 +119,7 @@ describe('GardenAudio startup policy', () => {
|
|||
beforeEach(() => {
|
||||
calls.constructed = 0;
|
||||
calls.resumed = 0;
|
||||
calls.sourcesStarted = 0;
|
||||
contextState = 'suspended';
|
||||
vi.stubGlobal('AudioContext', FakeAudioContext);
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('not loaded in tests')));
|
||||
|
|
@ -118,7 +130,11 @@ describe('GardenAudio startup policy', () => {
|
|||
});
|
||||
|
||||
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];
|
||||
|
||||
audio.start(vibe);
|
||||
|
|
@ -135,7 +151,11 @@ describe('GardenAudio startup policy', () => {
|
|||
});
|
||||
|
||||
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];
|
||||
|
||||
audio.start(vibe, { userGesture: true });
|
||||
|
|
@ -150,4 +170,51 @@ describe('GardenAudio startup policy', () => {
|
|||
|
||||
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 { VibePreset } from '../vibes';
|
||||
import { GardenAudioConfig } from './garden-audio-config';
|
||||
import { GardenAudioEnergy } from './garden-audio-energy';
|
||||
import { GardenAudioGestureState } from './garden-audio-gesture-state';
|
||||
import { GardenAudioGraph } from './garden-audio-graph';
|
||||
import { GardenAudioStrokeMetrics, getStrokeMetrics } from './garden-audio-input';
|
||||
import { getStrokeMetrics } from './garden-audio-input';
|
||||
import { getVibeProfile, normalizeColorIndex } from './garden-audio-music';
|
||||
import type {
|
||||
GardenAudioColorIndex,
|
||||
|
|
@ -29,6 +30,7 @@ export class GardenAudio {
|
|||
private readonly piano: PianoSampler;
|
||||
private readonly noise: NoiseBurstPlayer;
|
||||
private readonly energy: GardenAudioEnergy;
|
||||
private readonly gestureState: GardenAudioGestureState;
|
||||
private readonly pianoEngine: GenerativePianoEngine;
|
||||
|
||||
private currentVibeId: string | null = null;
|
||||
|
|
@ -41,12 +43,22 @@ export class GardenAudio {
|
|||
private lastEraserAt = Number.NEGATIVE_INFINITY;
|
||||
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
||||
|
||||
public constructor(private readonly config: GardenAudioConfig) {
|
||||
this.graph = new GardenAudioGraph(config);
|
||||
this.piano = new PianoSampler(config, this.graph);
|
||||
this.noise = new NoiseBurstPlayer(this.graph);
|
||||
this.energy = new GardenAudioEnergy();
|
||||
this.pianoEngine = new GenerativePianoEngine(config, (note) => this.piano.play(note));
|
||||
public constructor(
|
||||
private readonly config: GardenAudioConfig,
|
||||
private readonly engineConfig: GardenAudioEngineConfig,
|
||||
private readonly maxMirrorSegmentCount: number
|
||||
) {
|
||||
this.graph = new GardenAudioGraph(config, engineConfig);
|
||||
this.piano = new PianoSampler(config, engineConfig, this.graph);
|
||||
this.noise = new NoiseBurstPlayer(engineConfig, this.graph);
|
||||
this.energy = new GardenAudioEnergy(engineConfig);
|
||||
this.gestureState = new GardenAudioGestureState(
|
||||
config.rhythm.speedForFullEnergyPixelsPerSecond,
|
||||
engineConfig.input
|
||||
);
|
||||
this.pianoEngine = new GenerativePianoEngine(config, engineConfig, (note) =>
|
||||
this.piano.play(note)
|
||||
);
|
||||
}
|
||||
|
||||
public start(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
|
||||
|
|
@ -76,7 +88,7 @@ export class GardenAudio {
|
|||
this.graph.setMasterGain(
|
||||
this.config.masterVolume,
|
||||
options.userGesture === true
|
||||
? appConfig.audioEngine.muteRampSeconds
|
||||
? this.engineConfig.muteRampSeconds
|
||||
: this.config.fadeInSeconds
|
||||
);
|
||||
|
||||
|
|
@ -110,8 +122,8 @@ export class GardenAudio {
|
|||
public setMuted(isMuted: boolean): void {
|
||||
this.isMuted = isMuted;
|
||||
this.graph.setMasterGain(
|
||||
isMuted ? appConfig.audioEngine.muteGain : this.config.masterVolume,
|
||||
isMuted ? appConfig.audioEngine.muteRampSeconds : this.config.fadeInSeconds
|
||||
isMuted ? this.engineConfig.muteGain : this.config.masterVolume,
|
||||
isMuted ? this.engineConfig.muteRampSeconds : this.config.fadeInSeconds
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -122,11 +134,13 @@ export class GardenAudio {
|
|||
}
|
||||
|
||||
this.isGestureActive = true;
|
||||
this.gestureState.beginGesture();
|
||||
this.energy.beginGesture(context.currentTime);
|
||||
this.pianoEngine.beginGesture();
|
||||
}
|
||||
|
||||
public endGesture(): void {
|
||||
this.gestureState.endGesture();
|
||||
this.isGestureActive = false;
|
||||
this.energy.endGesture();
|
||||
this.pianoEngine.endGesture();
|
||||
|
|
@ -146,6 +160,13 @@ export class GardenAudio {
|
|||
const mirrorAmount = this.getMirrorAmount(touch.mirrorSegmentCount ?? 1);
|
||||
const pressure = this.getTouchPressure(touch.pressure, touch.pointerType);
|
||||
const strength = clamp01(0.36 + pressure * 0.34 + mirrorAmount * 0.22);
|
||||
const frame = this.gestureState.recordTouchDown({
|
||||
touch,
|
||||
colorIndex: this.selectedColorIndex,
|
||||
mirrorAmount,
|
||||
pressure,
|
||||
strength,
|
||||
});
|
||||
|
||||
this.energy.recordStroke(strength, context.currentTime);
|
||||
this.pianoEngine.recordTouchDown({
|
||||
|
|
@ -154,6 +175,13 @@ export class GardenAudio {
|
|||
strength,
|
||||
selectedColorIndex: this.selectedColorIndex,
|
||||
mirrorAmount,
|
||||
panBias: frame.panBias,
|
||||
registerBias: frame.registerBias,
|
||||
brightnessBias: frame.brightnessBias,
|
||||
contour: frame.contour,
|
||||
pressureAmount: frame.pressure,
|
||||
pressureDelta: frame.pressureDelta,
|
||||
maniaAmount: frame.maniaAmount,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -197,7 +225,8 @@ export class GardenAudio {
|
|||
const metrics = getStrokeMetrics(
|
||||
stroke,
|
||||
this.config.rhythm.speedForFullEnergyPixelsPerSecond,
|
||||
this.config.input.pressureFallback
|
||||
this.config.input.pressureFallback,
|
||||
this.engineConfig.input
|
||||
);
|
||||
const now = context.currentTime;
|
||||
|
||||
|
|
@ -210,7 +239,8 @@ export class GardenAudio {
|
|||
}
|
||||
|
||||
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.pianoEngine.recordStroke({
|
||||
vibe: stroke.vibe,
|
||||
|
|
@ -218,6 +248,13 @@ export class GardenAudio {
|
|||
activity: strokeEnergy,
|
||||
selectedColorIndex: this.selectedColorIndex,
|
||||
mirrorAmount,
|
||||
panBias: frame.panBias,
|
||||
registerBias: frame.registerBias,
|
||||
brightnessBias: frame.brightnessBias,
|
||||
contour: frame.contour,
|
||||
pressureAmount: frame.pressure,
|
||||
pressureDelta: frame.pressureDelta,
|
||||
maniaAmount: frame.maniaAmount,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -227,6 +264,7 @@ export class GardenAudio {
|
|||
|
||||
this.piano.reset();
|
||||
this.energy.reset();
|
||||
this.gestureState.reset();
|
||||
this.pianoEngine.reset();
|
||||
this.currentVibeId = null;
|
||||
this.hasStarted = false;
|
||||
|
|
@ -246,7 +284,7 @@ export class GardenAudio {
|
|||
const now = context.currentTime;
|
||||
if (
|
||||
now - this.lastVibeStingerAt <
|
||||
appConfig.audioEngine.vibeChangeStingerMinIntervalSeconds
|
||||
this.engineConfig.vibeChangeStingerMinIntervalSeconds
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -266,10 +304,10 @@ export class GardenAudio {
|
|||
}
|
||||
|
||||
const sizeAmount = clamp01(
|
||||
(stroke.eraserSizePixels ?? appConfig.audioEngine.eraser.defaultSizePixels) /
|
||||
(stroke.eraserSizePixels ?? this.engineConfig.eraser.defaultSizePixels) /
|
||||
Math.max(
|
||||
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]));
|
||||
|
|
@ -277,22 +315,22 @@ export class GardenAudio {
|
|||
this.config.eraser.filterMinHz +
|
||||
(this.config.eraser.filterMaxHz - this.config.eraser.filterMinHz) *
|
||||
clamp01(
|
||||
speedAmount * appConfig.audioEngine.eraser.filterSpeedWeight +
|
||||
pressure * appConfig.audioEngine.eraser.filterPressureWeight +
|
||||
sizeAmount * appConfig.audioEngine.eraser.filterSizeWeight
|
||||
speedAmount * this.engineConfig.eraser.filterSpeedWeight +
|
||||
pressure * this.engineConfig.eraser.filterPressureWeight +
|
||||
sizeAmount * this.engineConfig.eraser.filterSizeWeight
|
||||
);
|
||||
|
||||
if (now - this.lastEraserAt >= this.config.eraser.minIntervalSeconds) {
|
||||
this.lastEraserAt = now;
|
||||
this.noise.play({
|
||||
startTime: now,
|
||||
durationSeconds: appConfig.audioEngine.eraser.durationSeconds,
|
||||
durationSeconds: this.engineConfig.eraser.durationSeconds,
|
||||
gain:
|
||||
this.config.eraser.noiseGain *
|
||||
(appConfig.audioEngine.eraser.gainBase +
|
||||
speedAmount * appConfig.audioEngine.eraser.gainSpeedWeight +
|
||||
pressure * appConfig.audioEngine.eraser.gainPressureWeight +
|
||||
sizeAmount * appConfig.audioEngine.eraser.gainSizeWeight),
|
||||
(this.engineConfig.eraser.gainBase +
|
||||
speedAmount * this.engineConfig.eraser.gainSpeedWeight +
|
||||
pressure * this.engineConfig.eraser.gainPressureWeight +
|
||||
sizeAmount * this.engineConfig.eraser.gainSizeWeight),
|
||||
filterHz,
|
||||
pan: clamp(x * 2 - 1, -1, 1),
|
||||
});
|
||||
|
|
@ -307,7 +345,7 @@ export class GardenAudio {
|
|||
|
||||
const profile = getVibeProfile(this.config, snapshot.vibe);
|
||||
const activity = snapshot.isErasing
|
||||
? appConfig.audioEngine.delay.erasingActivity
|
||||
? this.engineConfig.delay.erasingActivity
|
||||
: this.energy.getLevel();
|
||||
this.graph.updateDelay(profile, activity);
|
||||
}
|
||||
|
|
@ -323,7 +361,7 @@ export class GardenAudio {
|
|||
}
|
||||
|
||||
private getMirrorAmount(mirrorSegmentCount: number): number {
|
||||
const maxMirrorSegmentCount = Math.max(1, appConfig.simulation.maxMirrorSegmentCount);
|
||||
const maxMirrorSegmentCount = Math.max(1, this.maxMirrorSegmentCount);
|
||||
const segmentCount = clamp(
|
||||
Number.isFinite(mirrorSegmentCount) ? mirrorSegmentCount : 1,
|
||||
1,
|
||||
|
|
@ -337,44 +375,16 @@ export class GardenAudio {
|
|||
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 {
|
||||
if (pressure !== undefined && Number.isFinite(pressure) && pressure > 0) {
|
||||
return clamp01(pressure);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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 { appConfig } from '../config';
|
||||
import { VIBE_PRESETS } from '../vibes';
|
||||
import { gardenAudioConfig } from './garden-audio-config';
|
||||
import { PianoNote } from './garden-audio-types';
|
||||
|
|
@ -7,9 +8,13 @@ import { GenerativePianoEngine } from './generative-piano';
|
|||
|
||||
const makeEngine = () => {
|
||||
const notes: Array<PianoNote> = [];
|
||||
const engine = new GenerativePianoEngine(gardenAudioConfig, (note) => {
|
||||
notes.push(note);
|
||||
});
|
||||
const engine = new GenerativePianoEngine(
|
||||
gardenAudioConfig,
|
||||
appConfig.audioEngine,
|
||||
(note) => {
|
||||
notes.push(note);
|
||||
}
|
||||
);
|
||||
|
||||
return { engine, notes };
|
||||
};
|
||||
|
|
@ -17,7 +22,9 @@ const makeEngine = () => {
|
|||
const getBeatSeconds = (): number => 60 / gardenAudioConfig.rhythm.bpm;
|
||||
|
||||
const getBeatsPerBar = (): number =>
|
||||
Math.round(gardenAudioConfig.rhythm.stepsPerBar / gardenAudioConfig.rhythm.stepsPerBeat);
|
||||
Math.round(
|
||||
gardenAudioConfig.rhythm.stepsPerBar / gardenAudioConfig.rhythm.stepsPerBeat
|
||||
);
|
||||
|
||||
const renderBars = (
|
||||
engine: GenerativePianoEngine,
|
||||
|
|
@ -45,9 +52,8 @@ const countNotesBetween = (
|
|||
startSeconds: number,
|
||||
endSeconds: number
|
||||
): number =>
|
||||
notes.filter(
|
||||
(note) => note.startTime >= startSeconds && note.startTime < endSeconds
|
||||
).length;
|
||||
notes.filter((note) => note.startTime >= startSeconds && note.startTime < endSeconds)
|
||||
.length;
|
||||
|
||||
describe('GenerativePianoEngine', () => {
|
||||
it('plays quiet background music even when the garden is idle', () => {
|
||||
|
|
@ -56,10 +62,8 @@ describe('GenerativePianoEngine', () => {
|
|||
renderBars(engine, 0);
|
||||
|
||||
expect(notes.length).toBeGreaterThan(0);
|
||||
expect(notes.some((note) => note.durationSeconds > getBeatSeconds() * 12)).toBe(
|
||||
true
|
||||
);
|
||||
expect(Math.max(...notes.map((note) => note.velocity))).toBeLessThan(0.16);
|
||||
expect(notes.some((note) => note.durationSeconds > getBeatSeconds() * 6)).toBe(true);
|
||||
expect(Math.max(...notes.map((note) => note.velocity))).toBeLessThan(0.12);
|
||||
});
|
||||
|
||||
it('keeps the background sparse instead of filling every beat', () => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { appConfig } from '../config';
|
||||
import type { GardenAudioEngineConfig } from '../config';
|
||||
import { clamp, clamp01 } from '../utils/clamp';
|
||||
import { VibePreset } from '../vibes';
|
||||
import {
|
||||
|
|
@ -6,7 +6,11 @@ import {
|
|||
GardenAudioConfig,
|
||||
GardenAudioVibeProfile,
|
||||
} 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';
|
||||
|
||||
interface RenderLookaheadRequest {
|
||||
|
|
@ -23,6 +27,13 @@ interface StrokeAccentRequest {
|
|||
activity: number;
|
||||
selectedColorIndex: GardenAudioColorIndex;
|
||||
mirrorAmount?: number;
|
||||
panBias?: number;
|
||||
registerBias?: number;
|
||||
brightnessBias?: number;
|
||||
contour?: number;
|
||||
pressureAmount?: number;
|
||||
pressureDelta?: number;
|
||||
maniaAmount?: number;
|
||||
}
|
||||
|
||||
interface TouchDownRequest {
|
||||
|
|
@ -31,6 +42,13 @@ interface TouchDownRequest {
|
|||
strength: number;
|
||||
selectedColorIndex: GardenAudioColorIndex;
|
||||
mirrorAmount?: number;
|
||||
panBias?: number;
|
||||
registerBias?: number;
|
||||
brightnessBias?: number;
|
||||
contour?: number;
|
||||
pressureAmount?: number;
|
||||
pressureDelta?: number;
|
||||
maniaAmount?: number;
|
||||
}
|
||||
|
||||
interface Register {
|
||||
|
|
@ -61,6 +79,14 @@ interface BrushPhraseLayer {
|
|||
selectedColorIndex: GardenAudioColorIndex;
|
||||
energy: 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] = [
|
||||
|
|
@ -134,6 +160,9 @@ const BRUSH_STREAM_IDLE_INTERVAL_BEATS = 2;
|
|||
const BRUSH_STREAM_ACTIVE_INTERVAL_BEATS = 1;
|
||||
const BRUSH_STREAM_INTENSE_INTERVAL_BEATS = 0.5;
|
||||
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 {
|
||||
private nextBeatAt: number | null = null;
|
||||
|
|
@ -154,22 +183,23 @@ export class GenerativePianoEngine {
|
|||
|
||||
public constructor(
|
||||
private readonly config: GardenAudioConfig,
|
||||
private readonly engineConfig: GardenAudioEngineConfig,
|
||||
private readonly playNote: (note: PianoNote) => void
|
||||
) {}
|
||||
|
||||
public prime(now: number): void {
|
||||
if (this.nextBeatAt === null) {
|
||||
this.nextBeatAt = now + appConfig.audioEngine.startDelaySeconds;
|
||||
this.nextBeatAt = now + this.engineConfig.startDelaySeconds;
|
||||
}
|
||||
this.timelineStartedAt ??= now;
|
||||
this.nextBrushStreamAt ??= now + appConfig.audioEngine.startDelaySeconds;
|
||||
this.nextBrushStreamAt ??= now + this.engineConfig.startDelaySeconds;
|
||||
}
|
||||
|
||||
public cue(now: number): void {
|
||||
this.nextBeatAt = now + appConfig.audioEngine.startDelaySeconds;
|
||||
this.nextBeatAt = now + this.engineConfig.startDelaySeconds;
|
||||
this.timelineStartedAt = now;
|
||||
this.beatIndex = 0;
|
||||
this.nextBrushStreamAt = now + appConfig.audioEngine.startDelaySeconds;
|
||||
this.nextBrushStreamAt = now + this.engineConfig.startDelaySeconds;
|
||||
this.brushStreamNoteIndex = 0;
|
||||
this.lastBrushStreamMidi = null;
|
||||
}
|
||||
|
|
@ -188,9 +218,25 @@ export class GenerativePianoEngine {
|
|||
strength,
|
||||
selectedColorIndex,
|
||||
mirrorAmount = 0,
|
||||
panBias = 0,
|
||||
registerBias = 0,
|
||||
brightnessBias = 0.5,
|
||||
contour = 0,
|
||||
pressureAmount = 0,
|
||||
pressureDelta = 0,
|
||||
maniaAmount = 0,
|
||||
}: TouchDownRequest): void {
|
||||
const normalizedStrength = clamp01(strength);
|
||||
const normalizedMirrorAmount = clamp01(mirrorAmount);
|
||||
const normalizedMotif = this.normalizeMotif({
|
||||
panBias,
|
||||
registerBias,
|
||||
brightnessBias,
|
||||
contour,
|
||||
pressureAmount,
|
||||
pressureDelta,
|
||||
maniaAmount,
|
||||
});
|
||||
|
||||
this.isWaitingForGestureAccent = false;
|
||||
this.lastGestureAccentAt = now;
|
||||
|
|
@ -201,8 +247,17 @@ export class GenerativePianoEngine {
|
|||
strength: normalizedStrength,
|
||||
selectedColorIndex,
|
||||
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({
|
||||
|
|
@ -211,9 +266,25 @@ export class GenerativePianoEngine {
|
|||
activity,
|
||||
selectedColorIndex,
|
||||
mirrorAmount = 0,
|
||||
panBias = 0,
|
||||
registerBias = 0,
|
||||
brightnessBias = 0.5,
|
||||
contour = 0,
|
||||
pressureAmount = 0,
|
||||
pressureDelta = 0,
|
||||
maniaAmount = 0,
|
||||
}: StrokeAccentRequest): void {
|
||||
const strength = clamp01(activity);
|
||||
const normalizedMirrorAmount = clamp01(mirrorAmount);
|
||||
const normalizedMotif = this.normalizeMotif({
|
||||
panBias,
|
||||
registerBias,
|
||||
brightnessBias,
|
||||
contour,
|
||||
pressureAmount,
|
||||
pressureDelta,
|
||||
maniaAmount,
|
||||
});
|
||||
|
||||
if (
|
||||
this.isWaitingForGestureAccent &&
|
||||
|
|
@ -225,11 +296,19 @@ export class GenerativePianoEngine {
|
|||
strength,
|
||||
selectedColorIndex,
|
||||
mirrorAmount: normalizedMirrorAmount,
|
||||
...normalizedMotif,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.isWaitingForGestureAccent = false;
|
||||
this.updateBrushPhraseLayer({
|
||||
now,
|
||||
strength,
|
||||
selectedColorIndex,
|
||||
mirrorAmount: normalizedMirrorAmount,
|
||||
...normalizedMotif,
|
||||
});
|
||||
if (
|
||||
strength >= STROKE_ACCENT_THRESHOLD &&
|
||||
now - this.lastStrokeAccentAt >= STROKE_ACCENT_MIN_INTERVAL_SECONDS
|
||||
|
|
@ -385,22 +464,22 @@ export class GenerativePianoEngine {
|
|||
const chord = this.getChord(profile, barIndex);
|
||||
const intervals = getChordIntervals(chord, true);
|
||||
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 = [
|
||||
{
|
||||
source: { baseMidi: rootMidi, offsets: [0] },
|
||||
register: PAD_REGISTERS[0],
|
||||
velocity: 0.082,
|
||||
velocity: 0.052,
|
||||
},
|
||||
{
|
||||
source: { baseMidi: rootMidi, offsets: [intervals[1]] },
|
||||
register: PAD_REGISTERS[1],
|
||||
velocity: 0.064,
|
||||
velocity: 0.041,
|
||||
},
|
||||
{
|
||||
source: { baseMidi: rootMidi, offsets: [intervals[3], intervals[2]] },
|
||||
register: PAD_REGISTERS[2],
|
||||
velocity: 0.052,
|
||||
velocity: 0.033,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -412,8 +491,8 @@ export class GenerativePianoEngine {
|
|||
startTime,
|
||||
durationSeconds,
|
||||
pan: register.pan,
|
||||
delaySend: 0.018,
|
||||
lowpassHz: this.getLowpassHz(profile, midi, expression * 0.45),
|
||||
delaySend: 0.008,
|
||||
lowpassHz: this.getLowpassHz(profile, midi, expression * 0.28),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -519,7 +598,7 @@ export class GenerativePianoEngine {
|
|||
this.config.colorVoices[selectedColorIndex].velocityMultiplier,
|
||||
startTime:
|
||||
now +
|
||||
appConfig.audioEngine.startDelaySeconds +
|
||||
this.engineConfig.startDelaySeconds +
|
||||
index * GESTURE_ACCENT_SPACING_SECONDS,
|
||||
durationSeconds: 0.48 + strength * 0.22,
|
||||
pan: this.getColorPan(selectedColorIndex),
|
||||
|
|
@ -529,14 +608,26 @@ export class GenerativePianoEngine {
|
|||
}
|
||||
}
|
||||
|
||||
private playTouchNote(
|
||||
vibe: VibePreset,
|
||||
now: number,
|
||||
selectedColorIndex: GardenAudioColorIndex,
|
||||
strength: number
|
||||
): void {
|
||||
private playTouchNote({
|
||||
vibe,
|
||||
now,
|
||||
selectedColorIndex,
|
||||
strength,
|
||||
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 pool = COLOR_POOLS[selectedColorIndex];
|
||||
const register = this.getBiasedRegister(pool, registerBias, 0);
|
||||
const chord = this.getChord(profile, this.getGlobalBarIndex(now));
|
||||
const chordIntervals = getChordIntervals(chord, false);
|
||||
const rootMidi = profile.rootMidi + chord.rootOffset;
|
||||
|
|
@ -545,7 +636,7 @@ export class GenerativePianoEngine {
|
|||
baseMidi: rootMidi,
|
||||
offsets: this.getSupportOffsets(chordIntervals, selectedColorIndex),
|
||||
},
|
||||
pool,
|
||||
register,
|
||||
this.lastMidiByColor[selectedColorIndex],
|
||||
true
|
||||
);
|
||||
|
|
@ -559,9 +650,13 @@ export class GenerativePianoEngine {
|
|||
this.config.colorVoices[selectedColorIndex].velocityMultiplier,
|
||||
startTime: now,
|
||||
durationSeconds: 0.55 + strength * 0.18,
|
||||
pan: this.getColorPan(selectedColorIndex),
|
||||
pan: this.getLayerPan(selectedColorIndex, panBias, 0, 0),
|
||||
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,
|
||||
selectedColorIndex,
|
||||
mirrorAmount,
|
||||
panBias,
|
||||
registerBias,
|
||||
brightnessBias,
|
||||
contour,
|
||||
pressureAmount,
|
||||
pressureDelta,
|
||||
maniaAmount,
|
||||
}: {
|
||||
vibe: VibePreset;
|
||||
now: number;
|
||||
strength: number;
|
||||
selectedColorIndex: GardenAudioColorIndex;
|
||||
mirrorAmount: number;
|
||||
panBias: number;
|
||||
registerBias: number;
|
||||
brightnessBias: number;
|
||||
contour: number;
|
||||
pressureAmount: number;
|
||||
pressureDelta: number;
|
||||
maniaAmount: number;
|
||||
}): void {
|
||||
const lifetimeSeconds =
|
||||
BRUSH_LAYER_BASE_SECONDS +
|
||||
|
|
@ -590,6 +699,18 @@ export class GenerativePianoEngine {
|
|||
selectedColorIndex,
|
||||
energy: strength,
|
||||
mirrorAmount,
|
||||
motifOffsets: this.getInitialMotifOffsets({
|
||||
selectedColorIndex,
|
||||
registerBias,
|
||||
contour,
|
||||
}),
|
||||
panBias,
|
||||
registerBias,
|
||||
brightnessBias,
|
||||
contour,
|
||||
pressureAmount,
|
||||
pressureDelta,
|
||||
maniaAmount,
|
||||
});
|
||||
|
||||
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({
|
||||
vibe,
|
||||
now,
|
||||
|
|
@ -610,8 +780,8 @@ export class GenerativePianoEngine {
|
|||
activity: number;
|
||||
selectedColorIndex: GardenAudioColorIndex;
|
||||
}): void {
|
||||
const earliestStart = now + appConfig.audioEngine.piano.scheduleAheadSeconds;
|
||||
this.nextBrushStreamAt ??= now + appConfig.audioEngine.startDelaySeconds;
|
||||
const earliestStart = now + this.engineConfig.piano.scheduleAheadSeconds;
|
||||
this.nextBrushStreamAt ??= now + this.engineConfig.startDelaySeconds;
|
||||
|
||||
this.brushPhraseLayers = this.brushPhraseLayers.filter(
|
||||
(layer) => layer.expiresAt > earliestStart
|
||||
|
|
@ -631,6 +801,7 @@ export class GenerativePianoEngine {
|
|||
startTime: this.nextBrushStreamAt,
|
||||
intensity: frame.intensity,
|
||||
selectedColorIndex: frame.selectedColorIndex ?? selectedColorIndex,
|
||||
layer: frame.layer,
|
||||
});
|
||||
}
|
||||
this.nextBrushStreamAt += this.getBrushStreamIntervalSeconds(frame.intensity);
|
||||
|
|
@ -643,14 +814,22 @@ export class GenerativePianoEngine {
|
|||
startTime,
|
||||
intensity,
|
||||
selectedColorIndex,
|
||||
layer,
|
||||
}: {
|
||||
vibe: VibePreset;
|
||||
startTime: number;
|
||||
intensity: number;
|
||||
selectedColorIndex: GardenAudioColorIndex;
|
||||
layer: BrushPhraseLayer | null;
|
||||
}): void {
|
||||
const profile = getVibeProfile(this.config, vibe);
|
||||
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 chordIntervals = getChordIntervals(chord, false);
|
||||
const rootMidi = profile.rootMidi + chord.rootOffset;
|
||||
|
|
@ -662,12 +841,29 @@ export class GenerativePianoEngine {
|
|||
}
|
||||
: {
|
||||
baseMidi: profile.rootMidi,
|
||||
offsets: this.rotate(
|
||||
pool.scaleDegrees,
|
||||
this.brushStreamNoteIndex + selectedColorIndex
|
||||
).map((degree) => degreeToSemitone(profile, degree)),
|
||||
offsets: this.getBrushMotifDegrees({
|
||||
layer,
|
||||
pool,
|
||||
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.lastMidiByColor[selectedColorIndex] = midi;
|
||||
|
|
@ -677,11 +873,38 @@ export class GenerativePianoEngine {
|
|||
(0.1 + intensity * 0.13) *
|
||||
this.config.colorVoices[selectedColorIndex].velocityMultiplier,
|
||||
startTime,
|
||||
durationSeconds: 0.42 + intensity * 0.22,
|
||||
pan: this.getColorPan(selectedColorIndex),
|
||||
delaySend: 0.012 + intensity * 0.01,
|
||||
lowpassHz: this.getLowpassHz(profile, midi, clamp01(0.35 + intensity * 0.65)),
|
||||
durationSeconds,
|
||||
pan,
|
||||
delaySend,
|
||||
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(
|
||||
|
|
@ -690,17 +913,19 @@ export class GenerativePianoEngine {
|
|||
): {
|
||||
intensity: number;
|
||||
selectedColorIndex: GardenAudioColorIndex | null;
|
||||
layer: BrushPhraseLayer | null;
|
||||
} {
|
||||
const layerStates = this.brushPhraseLayers.map((layer) => ({
|
||||
layer,
|
||||
intensity:
|
||||
layer.energy *
|
||||
this.getBrushPhraseFade(layer, startTime) *
|
||||
(0.8 + layer.mirrorAmount * 0.45),
|
||||
(0.8 + layer.mirrorAmount * 0.45 + layer.maniaAmount * 0.42),
|
||||
}));
|
||||
const dominant = layerStates.reduce<
|
||||
{ layer: BrushPhraseLayer; intensity: number } | null
|
||||
>((best, state) => {
|
||||
const dominant = layerStates.reduce<{
|
||||
layer: BrushPhraseLayer;
|
||||
intensity: number;
|
||||
} | null>((best, state) => {
|
||||
if (state.intensity <= 0) {
|
||||
return best;
|
||||
}
|
||||
|
|
@ -712,8 +937,11 @@ export class GenerativePianoEngine {
|
|||
);
|
||||
|
||||
return {
|
||||
intensity: clamp01(activity * 0.45 + layeredIntensity),
|
||||
intensity: clamp01(
|
||||
activity * 0.42 + layeredIntensity + (dominant?.layer.maniaAmount ?? 0) * 0.18
|
||||
),
|
||||
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));
|
||||
}
|
||||
|
||||
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(
|
||||
pitchSource: PitchSource,
|
||||
register: Register,
|
||||
|
|
@ -822,10 +1186,7 @@ export class GenerativePianoEngine {
|
|||
return [chordIntervals[2], 12, chordIntervals[3], chordIntervals[1] + 12];
|
||||
}
|
||||
|
||||
private getChord(
|
||||
profile: GardenAudioVibeProfile,
|
||||
barIndex: number
|
||||
): GardenAudioChord {
|
||||
private getChord(profile: GardenAudioVibeProfile, barIndex: number): GardenAudioChord {
|
||||
const progressionIndex =
|
||||
Math.floor(barIndex / CHORD_BARS) % profile.progression.length;
|
||||
return profile.progression[progressionIndex];
|
||||
|
|
@ -852,8 +1213,8 @@ export class GenerativePianoEngine {
|
|||
return clamp(
|
||||
this.config.piano.lowpassHz * profile.brightness * (0.58 + expression * 0.32) +
|
||||
midiLift,
|
||||
appConfig.audioEngine.piano.lowpassMinHz,
|
||||
appConfig.audioEngine.piano.lowpassMaxHz
|
||||
this.engineConfig.piano.lowpassMinHz,
|
||||
this.engineConfig.piano.lowpassMaxHz
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -862,7 +1223,7 @@ export class GenerativePianoEngine {
|
|||
return;
|
||||
}
|
||||
|
||||
const earliestStart = now + appConfig.audioEngine.piano.scheduleAheadSeconds;
|
||||
const earliestStart = now + this.engineConfig.piano.scheduleAheadSeconds;
|
||||
if (this.nextBeatAt >= earliestStart) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -898,3 +1259,6 @@ export class GenerativePianoEngine {
|
|||
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.frequency.setValueAtTime(filterHz, scheduledStart);
|
||||
filter.Q.value = this.engineConfig.noiseBurst.filterQ;
|
||||
envelope.gain.setValueAtTime(
|
||||
this.engineConfig.noiseBurst.silentGain,
|
||||
scheduledStart
|
||||
);
|
||||
envelope.gain.setValueAtTime(this.engineConfig.noiseBurst.silentGain, scheduledStart);
|
||||
envelope.gain.exponentialRampToValueAtTime(
|
||||
Math.max(this.engineConfig.noiseBurst.silentGain, gain),
|
||||
scheduledStart + this.engineConfig.noiseBurst.attackSeconds
|
||||
|
|
|
|||
|
|
@ -58,15 +58,6 @@ export class PianoSampler {
|
|||
|
||||
const sample = this.findNearestSample(midi);
|
||||
if (!sample) {
|
||||
this.playFallbackPluck({
|
||||
midi,
|
||||
velocity,
|
||||
startTime,
|
||||
durationSeconds,
|
||||
pan,
|
||||
delaySend,
|
||||
lowpassHz,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -84,7 +75,8 @@ export class PianoSampler {
|
|||
(this.engineConfig.piano.sustainBase +
|
||||
noteVelocity * this.engineConfig.piano.sustainVelocityRange);
|
||||
const sustainAt =
|
||||
scheduledStart + Math.max(this.engineConfig.piano.minDurationSeconds, durationSeconds);
|
||||
scheduledStart +
|
||||
Math.max(this.engineConfig.piano.minDurationSeconds, durationSeconds);
|
||||
const releaseAt = sustainAt + sustainSeconds;
|
||||
const releaseSeconds = this.config.piano.releaseSeconds;
|
||||
const stopAt = releaseAt + releaseSeconds;
|
||||
|
|
@ -108,10 +100,7 @@ export class PianoSampler {
|
|||
|
||||
source.buffer = sample.buffer;
|
||||
source.playbackRate.setValueAtTime(
|
||||
Math.pow(
|
||||
2,
|
||||
(midi - sample.midi) / this.engineConfig.piano.pitchSemitonesPerOctave
|
||||
),
|
||||
Math.pow(2, (midi - sample.midi) / this.engineConfig.piano.pitchSemitonesPerOctave),
|
||||
scheduledStart
|
||||
);
|
||||
filter.type = 'lowpass';
|
||||
|
|
@ -140,11 +129,7 @@ export class PianoSampler {
|
|||
sustainSeconds * this.engineConfig.piano.sustainBase
|
||||
)
|
||||
);
|
||||
gain.gain.setTargetAtTime(
|
||||
this.engineConfig.piano.minGain,
|
||||
releaseAt,
|
||||
releaseSeconds
|
||||
);
|
||||
gain.gain.setTargetAtTime(this.engineConfig.piano.minGain, releaseAt, releaseSeconds);
|
||||
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
|
||||
|
||||
source.connect(filter);
|
||||
|
|
@ -196,90 +181,4 @@ export class PianoSampler {
|
|||
private trimActiveVoices(now: number): void {
|
||||
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: {
|
||||
bottomRevealDistancePx: 96,
|
||||
intervalMs: 50,
|
||||
timeToLiveMs: 3500,
|
||||
desktopMediaQuery: '(min-width: 600px) and (hover: hover) and (pointer: fine)',
|
||||
hideDelayMs: 3000,
|
||||
},
|
||||
pipelines: {
|
||||
brush: {
|
||||
|
|
@ -194,9 +194,6 @@ export const appConfig = {
|
|||
fpsHeadroom: 0.95,
|
||||
fpsSmoothingNew: 0.06,
|
||||
fpsSmoothingRetain: 0.94,
|
||||
initialTargetAgentBudget: 20_000,
|
||||
rampAgentsPerSecond: 20_000,
|
||||
refreshTargetDecay: 0.995,
|
||||
},
|
||||
brushEffectFramesPerSecond: 60,
|
||||
globalAgentCap: 10_000_000,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,4 @@
|
|||
import type {
|
||||
AgentColorInteractionSettings,
|
||||
NumberControlConfig,
|
||||
} from './types';
|
||||
import type { AgentColorInteractionSettings, NumberControlConfig } from './types';
|
||||
|
||||
const agentInteractionOptions: Record<string, number> = {
|
||||
Follow: 1,
|
||||
|
|
@ -46,7 +43,8 @@ export const createColorInteractionSettings = (
|
|||
const random = createSeededRandom(hashString(seedSource));
|
||||
const values = Object.values(agentInteractionOptions);
|
||||
const randomInteraction = () =>
|
||||
values[Math.floor(random() * values.length)] ?? defaultColorInteractionSettings.color1ToColor2;
|
||||
values[Math.floor(random() * values.length)] ??
|
||||
defaultColorInteractionSettings.color1ToColor2;
|
||||
|
||||
return {
|
||||
color1ToColor1: 1,
|
||||
|
|
|
|||
|
|
@ -169,8 +169,8 @@ export interface GardenAppConfig {
|
|||
};
|
||||
menuHider: {
|
||||
bottomRevealDistancePx: number;
|
||||
intervalMs: number;
|
||||
timeToLiveMs: number;
|
||||
desktopMediaQuery: string;
|
||||
hideDelayMs: number;
|
||||
};
|
||||
pipelines: {
|
||||
brush: {
|
||||
|
|
@ -197,9 +197,6 @@ export interface GardenAppConfig {
|
|||
fpsHeadroom: number;
|
||||
fpsSmoothingNew: number;
|
||||
fpsSmoothingRetain: number;
|
||||
initialTargetAgentBudget: number;
|
||||
rampAgentsPerSecond: number;
|
||||
refreshTargetDecay: number;
|
||||
};
|
||||
brushEffectFramesPerSecond: number;
|
||||
globalAgentCap: number;
|
||||
|
|
|
|||
|
|
@ -32,14 +32,9 @@ const createPopulation = () => {
|
|||
return new AgentPopulation(pipeline);
|
||||
};
|
||||
|
||||
const setPopulationCounts = (
|
||||
population: AgentPopulation,
|
||||
activeCount: number,
|
||||
targetBudget: number
|
||||
) => {
|
||||
const setPopulationActiveCount = (population: AgentPopulation, activeCount: number) => {
|
||||
Object.assign(population as unknown as Record<string, number>, {
|
||||
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', () => {
|
||||
const population = createPopulation();
|
||||
setPopulationCounts(population, 1_000_000, 1_000_000);
|
||||
setPopulationActiveCount(population, 1_000_000);
|
||||
|
||||
population.growBudget(1 / 60, 60, 60);
|
||||
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', () => {
|
||||
const population = createPopulation();
|
||||
setPopulationCounts(population, 1_000_000, 1_000_000);
|
||||
setPopulationActiveCount(population, 1_000_000);
|
||||
|
||||
population.growBudget(10, 50, 60);
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ const ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND =
|
|||
|
||||
export class AgentPopulation {
|
||||
private activeCount = 0;
|
||||
private targetBudget = appConfig.simulation.budget.initialTargetAgentBudget;
|
||||
private replacementCursor = 0;
|
||||
private canExpandAdaptiveCap = true;
|
||||
private shouldCompactAfterErase = false;
|
||||
|
|
@ -33,24 +32,16 @@ export class AgentPopulation {
|
|||
return this.activeCount;
|
||||
}
|
||||
|
||||
public get targetAgentBudget(): number {
|
||||
return this.targetBudget;
|
||||
}
|
||||
|
||||
public get maxAgentCount(): number {
|
||||
return this.pipeline.maxAgentCount;
|
||||
}
|
||||
|
||||
public initializeIntroAgents(canvasSize: vec2): void {
|
||||
settings.agentBudgetMax = this.clampAdaptiveCap(settings.agentBudgetMax);
|
||||
this.targetBudget = Math.min(
|
||||
this.pipeline.maxAgentCount,
|
||||
settings.agentBudgetMax,
|
||||
INITIAL_AGENT_COUNT
|
||||
);
|
||||
const introAgentCount = Math.min(settings.agentBudgetMax, INITIAL_AGENT_COUNT);
|
||||
this.writeAgentBatch(
|
||||
createIntroTitleAgents({
|
||||
count: this.targetBudget,
|
||||
count: introAgentCount,
|
||||
width: canvasSize[0],
|
||||
height: canvasSize[1],
|
||||
})
|
||||
|
|
@ -59,11 +50,7 @@ export class AgentPopulation {
|
|||
|
||||
public onVibeChanged(): void {
|
||||
settings.agentBudgetMax = this.clampAdaptiveCap(settings.agentBudgetMax);
|
||||
this.targetBudget = Math.min(
|
||||
this.targetBudget,
|
||||
settings.agentBudgetMax,
|
||||
this.pipeline.maxAgentCount
|
||||
);
|
||||
this.trimActiveCountToBudget();
|
||||
}
|
||||
|
||||
public growBudget(
|
||||
|
|
@ -72,18 +59,6 @@ export class AgentPopulation {
|
|||
refreshTargetFps: number
|
||||
): void {
|
||||
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 {
|
||||
|
|
@ -110,7 +85,6 @@ export class AgentPopulation {
|
|||
this.activeCount = compactedAgentCount;
|
||||
this.replacementCursor =
|
||||
compactedAgentCount === 0 ? 0 : this.replacementCursor % compactedAgentCount;
|
||||
this.targetBudget = Math.max(this.targetBudget, compactedAgentCount);
|
||||
} finally {
|
||||
this.isCompacting = false;
|
||||
}
|
||||
|
|
@ -157,7 +131,7 @@ export class AgentPopulation {
|
|||
const count = data.length / AGENT_FLOAT_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);
|
||||
|
||||
if (appendCount > 0) {
|
||||
|
|
@ -196,10 +170,12 @@ export class AgentPopulation {
|
|||
): void {
|
||||
const previousCap = this.clampAdaptiveCap(settings.agentBudgetMax);
|
||||
this.canExpandAdaptiveCap =
|
||||
refreshTargetFps <= 0 ||
|
||||
smoothedFps >= refreshTargetFps * appConfig.simulation.budget.fpsHeadroom;
|
||||
|
||||
if (this.canExpandAdaptiveCap) {
|
||||
settings.agentBudgetMax = previousCap;
|
||||
this.trimActiveCountToBudget();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -209,33 +185,31 @@ export class AgentPopulation {
|
|||
);
|
||||
const nextCap = this.clampAdaptiveCap(previousCap - decrease);
|
||||
settings.agentBudgetMax = nextCap;
|
||||
this.targetBudget = Math.min(this.targetBudget, nextCap);
|
||||
|
||||
if (this.activeCount > this.targetBudget) {
|
||||
this.activeCount = Math.max(this.targetBudget, this.activeCount - decrease);
|
||||
this.replacementCursor =
|
||||
this.activeCount === 0 ? 0 : this.replacementCursor % this.activeCount;
|
||||
}
|
||||
this.trimActiveCountToBudget(decrease);
|
||||
}
|
||||
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const pendingAgentCount = requestedAgentCount - available;
|
||||
const nextCap = this.clampAdaptiveCap(currentCap + pendingAgentCount);
|
||||
settings.agentBudgetMax = nextCap;
|
||||
this.targetBudget = Math.max(
|
||||
this.targetBudget,
|
||||
Math.min(nextCap, this.activeCount + requestedAgentCount)
|
||||
this.activeCount = Math.max(
|
||||
settings.agentBudgetMax,
|
||||
this.activeCount - Math.max(1, Math.ceil(maxDecrease))
|
||||
);
|
||||
this.replacementCursor =
|
||||
this.activeCount === 0 ? 0 : this.replacementCursor % this.activeCount;
|
||||
}
|
||||
|
||||
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;
|
||||
encodeCpuMs: number;
|
||||
activeAgentCount: number;
|
||||
targetAgentBudget: number;
|
||||
agentBudgetMax: number;
|
||||
canvas: HTMLCanvasElement;
|
||||
devicePixelRatio: number;
|
||||
renderSpeed: number;
|
||||
}
|
||||
|
||||
const COMMON_DISPLAY_REFRESH_RATES = [
|
||||
50, 60, 72, 75, 90, 100, 120, 144, 165, 180, 240,
|
||||
] as const;
|
||||
const DISPLAY_REFRESH_CONFIRMATION_FRAMES = 8;
|
||||
const DISPLAY_REFRESH_SNAP_TOLERANCE = 0.15;
|
||||
|
||||
export class FramePerformance {
|
||||
public latestFps = 60;
|
||||
public smoothedFps = 60;
|
||||
public refreshTargetFps = 60;
|
||||
public displayRefreshFps = 60;
|
||||
public readonly refreshTargetFps = 60;
|
||||
|
||||
private lastTelemetryAt = 0;
|
||||
private hasConfirmedDisplayRefreshFps = false;
|
||||
private pendingDisplayRefreshFps = 0;
|
||||
private pendingDisplayRefreshFrameCount = 0;
|
||||
|
||||
public markCpuStart(): number {
|
||||
return appConfig.telemetry.enabled ? performance.now() : 0;
|
||||
|
|
@ -28,10 +38,7 @@ export class FramePerformance {
|
|||
public update(deltaTime: number): void {
|
||||
const fps = 1 / Math.max(deltaTime, appConfig.deltaTime.minDeltaTimeSeconds);
|
||||
this.latestFps = fps;
|
||||
this.refreshTargetFps = Math.max(
|
||||
this.refreshTargetFps * appConfig.simulation.budget.refreshTargetDecay,
|
||||
fps
|
||||
);
|
||||
this.updateDisplayRefreshEstimate(fps);
|
||||
this.smoothedFps =
|
||||
this.smoothedFps * appConfig.simulation.budget.fpsSmoothingRetain +
|
||||
fps * appConfig.simulation.budget.fpsSmoothingNew;
|
||||
|
|
@ -41,7 +48,7 @@ export class FramePerformance {
|
|||
frameCpuStartedAt,
|
||||
encodeCpuMs,
|
||||
activeAgentCount,
|
||||
targetAgentBudget,
|
||||
agentBudgetMax,
|
||||
canvas,
|
||||
devicePixelRatio,
|
||||
renderSpeed,
|
||||
|
|
@ -60,8 +67,9 @@ export class FramePerformance {
|
|||
fps: Math.round(this.latestFps),
|
||||
smoothedFps: Math.round(this.smoothedFps),
|
||||
refreshTargetFps: Math.round(this.refreshTargetFps),
|
||||
displayRefreshFps: Math.round(this.displayRefreshFps),
|
||||
activeAgentCount,
|
||||
targetAgentBudget,
|
||||
agentBudgetMax,
|
||||
canvasWidth: canvas.width,
|
||||
canvasHeight: canvas.height,
|
||||
dpr: devicePixelRatio,
|
||||
|
|
@ -70,4 +78,61 @@ export class FramePerformance {
|
|||
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 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 introPrompt: IntroPrompt;
|
||||
private readonly eraserPreview: EraserPreview;
|
||||
|
|
@ -30,12 +34,13 @@ export default class GameLoop {
|
|||
private readonly agentPopulation: AgentPopulation;
|
||||
private readonly export4KRenderer: Export4KRenderer;
|
||||
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 resizeListener = this.resize.bind(this);
|
||||
private readonly keydownListener: (event: KeyboardEvent) => void;
|
||||
|
||||
private lastDevStatsUpdateAt = 0;
|
||||
private isStatsOverlayPinned = false;
|
||||
private hasFinished = false;
|
||||
private readonly finished = Promise.withResolvers<void>();
|
||||
|
||||
|
|
@ -46,9 +51,8 @@ export default class GameLoop {
|
|||
ui: GardenUi
|
||||
) {
|
||||
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.introPrompt = new IntroPrompt(ui.prompt);
|
||||
this.eraserPreview = new EraserPreview(canvas, ui.eraserPreview);
|
||||
|
|
@ -108,6 +112,17 @@ export default class GameLoop {
|
|||
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 {
|
||||
this.audio.start(activeVibe, { userGesture });
|
||||
}
|
||||
|
|
@ -205,7 +220,7 @@ export default class GameLoop {
|
|||
frameCpuStartedAt,
|
||||
encodeCpuMs,
|
||||
activeAgentCount: this.agentPopulation.activeAgentCount,
|
||||
targetAgentBudget: this.agentPopulation.targetAgentBudget,
|
||||
agentBudgetMax: settings.agentBudgetMax,
|
||||
canvas: this.canvas,
|
||||
devicePixelRatio: this.devicePixelRatio,
|
||||
renderSpeed: settings.renderSpeed,
|
||||
|
|
@ -235,22 +250,31 @@ export default class GameLoop {
|
|||
private updateDevStats(time: DOMHighResTimeStamp): void {
|
||||
if (
|
||||
!this.devStatsElement ||
|
||||
!this.shouldShowDevStats ||
|
||||
time - this.lastDevStatsUpdateAt < GameLoop.DEV_STATS_INTERVAL_MS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastDevStatsUpdateAt = time;
|
||||
const displayRefreshFps = Math.round(this.framePerformance.displayRefreshFps);
|
||||
this.devStatsElement.textContent = [
|
||||
`FPS ${this.framePerformance.smoothedFps.toFixed(1)} / ${Math.round(
|
||||
this.framePerformance.refreshTargetFps
|
||||
)}`,
|
||||
`FPS ${this.framePerformance.smoothedFps.toFixed(1)} / ${displayRefreshFps}`,
|
||||
`Agents ${this.formatDevStatNumber(this.agentPopulation.activeAgentCount)}`,
|
||||
`Target ${this.formatDevStatNumber(this.agentPopulation.targetAgentBudget)}`,
|
||||
`Cap ${this.formatDevStatNumber(settings.agentBudgetMax)}`,
|
||||
].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 {
|
||||
return Math.max(0, Math.round(value)).toLocaleString('en-US');
|
||||
}
|
||||
|
|
@ -298,4 +322,8 @@ export default class GameLoop {
|
|||
: 1;
|
||||
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(audio.start).toHaveBeenCalledWith(expect.anything(), { userGesture: true });
|
||||
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(spawnStrokeAgents).toHaveBeenCalledTimes(1);
|
||||
expect(canvas.capturedPointerIds).toEqual([9]);
|
||||
|
|
|
|||
|
|
@ -110,11 +110,14 @@ export class GardenPointerInput {
|
|||
return;
|
||||
}
|
||||
|
||||
const position = this.getCanvasPointerPosition(event);
|
||||
this.options.audio.start(activeVibe, { userGesture: event.isTrusted });
|
||||
this.options.audio.beginGesture();
|
||||
this.options.audio.touchDown({
|
||||
vibe: activeVibe,
|
||||
colorIndex: settings.selectedColorIndex,
|
||||
position,
|
||||
canvasSize: this.options.getCanvasSize(),
|
||||
mirrorSegmentCount: this.options.getMirrorSegmentCount(),
|
||||
pressure: this.getPointerPressure(event),
|
||||
pointerType: event.pointerType,
|
||||
|
|
@ -174,12 +177,8 @@ export class GardenPointerInput {
|
|||
};
|
||||
|
||||
private addSwipeAt(event: PointerEvent, options: { emitAudio?: boolean } = {}): void {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const devicePixelRatio = this.options.getDevicePixelRatio();
|
||||
const position = vec2.fromValues(
|
||||
(event.clientX - rect.left) * devicePixelRatio,
|
||||
(event.clientY - rect.top) * devicePixelRatio
|
||||
);
|
||||
const position = this.getCanvasPointerPosition(event);
|
||||
const previousPosition = this.lastPointerPosition ?? position;
|
||||
const previousTimeMs = this.lastPointerEventTimeMs ?? event.timeStamp;
|
||||
const elapsedSeconds = Math.max(
|
||||
|
|
@ -219,6 +218,7 @@ export class GardenPointerInput {
|
|||
isErasing: this.isErasing,
|
||||
pressure: pressure > 0 ? pressure : this.lastPointerPressure,
|
||||
velocityPixelsPerSecond,
|
||||
elapsedSeconds,
|
||||
eraserSizePixels: settings.eraserSize * devicePixelRatio,
|
||||
mirrorSegmentCount: this.options.getMirrorSegmentCount(),
|
||||
pointerType: event.pointerType,
|
||||
|
|
@ -228,6 +228,15 @@ export class GardenPointerInput {
|
|||
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 {
|
||||
const previousSample =
|
||||
this.smoothedStrokePoints[this.smoothedStrokePoints.length - 1];
|
||||
|
|
|
|||
39
src/index.ts
|
|
@ -68,9 +68,9 @@ const renderRuntimeMessage = (
|
|||
};
|
||||
|
||||
const elements = {
|
||||
aside: queryRequiredElement('aside', HTMLDivElement),
|
||||
aside: queryRequiredElement('aside', HTMLElement),
|
||||
infoButton: queryRequiredElement('button.info', HTMLButtonElement),
|
||||
infoElement: queryRequiredElement('.info-page', HTMLDivElement),
|
||||
infoElement: queryRequiredElement('.info-page', HTMLElement),
|
||||
minimizeFullScreenButton: queryRequiredElement(
|
||||
'button.minimize-full-screen',
|
||||
HTMLButtonElement
|
||||
|
|
@ -84,20 +84,14 @@ const elements = {
|
|||
restartButton: queryRequiredElement('button.restart', HTMLButtonElement),
|
||||
canvas: queryRequiredElement('canvas', HTMLCanvasElement),
|
||||
eraserPreview: queryRequiredElement('.eraser-preview', HTMLDivElement),
|
||||
errorContainer: queryRequiredElement('.errors-container', HTMLDivElement),
|
||||
errorContainer: queryRequiredElement('.errors-container', HTMLElement),
|
||||
previousVibe: queryRequiredElement('.previous-vibe', HTMLButtonElement),
|
||||
nextVibe: queryRequiredElement('.next-vibe', HTMLButtonElement),
|
||||
swatches: queryRequiredElements('.color-swatch', HTMLButtonElement),
|
||||
eraserSizeControl: queryRequiredElement('.eraser-size-control', HTMLLabelElement),
|
||||
eraserSizeSlider: queryRequiredElement('.eraser-size-slider', HTMLInputElement),
|
||||
mirrorSegmentControl: queryRequiredElement(
|
||||
'.mirror-segment-control',
|
||||
HTMLLabelElement
|
||||
),
|
||||
mirrorSegmentSlider: queryRequiredElement(
|
||||
'.mirror-segment-slider',
|
||||
HTMLInputElement
|
||||
),
|
||||
mirrorSegmentControl: queryRequiredElement('.mirror-segment-control', HTMLLabelElement),
|
||||
mirrorSegmentSlider: queryRequiredElement('.mirror-segment-slider', HTMLInputElement),
|
||||
export4k: queryRequiredElement('.export-4k', HTMLButtonElement),
|
||||
exportStatus: queryRequiredElement('.export-status', HTMLSpanElement),
|
||||
prompt: queryRequiredElement('.garden-prompt', HTMLDivElement),
|
||||
|
|
@ -222,6 +216,7 @@ const main = async () => {
|
|||
const configPane = new ConfigPane({
|
||||
settingsButton: elements.settingsButton,
|
||||
onConfigChange: syncRuntimeUi,
|
||||
onOpenChange: (isOpen) => game?.setStatsOverlayPinned(isOpen),
|
||||
onRuntimeChange: syncRuntimeUi,
|
||||
onRuntimeReset: () => {
|
||||
resetSettings();
|
||||
|
|
@ -241,8 +236,7 @@ const main = async () => {
|
|||
() =>
|
||||
FullScreenHandler.isInFullScreenMode() &&
|
||||
!configPane.isOpen &&
|
||||
!infoPageHandler.isOpen,
|
||||
{ persistentElement: elements.settingsButton }
|
||||
!infoPageHandler.isOpen
|
||||
);
|
||||
new FullScreenHandler(
|
||||
elements.minimizeFullScreenButton,
|
||||
|
|
@ -250,13 +244,6 @@ const main = async () => {
|
|||
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.soundButton.addEventListener('click', (event) => {
|
||||
isAudioMuted = !isAudioMuted;
|
||||
|
|
@ -267,8 +254,6 @@ const main = async () => {
|
|||
}
|
||||
});
|
||||
|
||||
const deltaTimeCalculator = new DeltaTimeCalculator();
|
||||
|
||||
elements.previousVibe.addEventListener('click', (event) => {
|
||||
const current = VIBE_PRESETS.findIndex((vibe) => vibe.id === activeVibe.id);
|
||||
const vibe =
|
||||
|
|
@ -345,6 +330,15 @@ const main = async () => {
|
|||
renderMirrorSegmentUi();
|
||||
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;
|
||||
while (!shouldStop) {
|
||||
game = new GameLoop(elements.canvas, gpu, deltaTimeCalculator, {
|
||||
|
|
@ -352,6 +346,7 @@ const main = async () => {
|
|||
eraserPreview: elements.eraserPreview,
|
||||
exportStatus: elements.exportStatus,
|
||||
});
|
||||
game.setStatsOverlayPinned(configPane.isOpen);
|
||||
renderPaletteUi(game);
|
||||
renderEraserSizeUi(game);
|
||||
renderMirrorSegmentUi();
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ const isColorReactionKey = (key: string): key is ColorReactionKey =>
|
|||
|
||||
interface ConfigPaneOptions {
|
||||
onConfigChange: () => void;
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
onRestart: () => void;
|
||||
onRuntimeChange: () => void;
|
||||
onRuntimeReset: () => void;
|
||||
|
|
@ -90,10 +91,7 @@ const getNumberBindingParams = (
|
|||
export class ConfigPane {
|
||||
private readonly container: HTMLDivElement;
|
||||
private readonly pane: Pane;
|
||||
private readonly colorReactionSelects = new Map<
|
||||
ColorReactionKey,
|
||||
HTMLSelectElement
|
||||
>();
|
||||
private readonly colorReactionSelects = new Map<ColorReactionKey, HTMLSelectElement>();
|
||||
private readonly colorReactionSwatches: Array<{
|
||||
colorIndex: number;
|
||||
element: HTMLElement;
|
||||
|
|
@ -139,7 +137,7 @@ export class ConfigPane {
|
|||
|
||||
this.setUpRuntimeTab(tabs.pages[0]);
|
||||
this.setUpConfigTab(tabs.pages[1]);
|
||||
this.syncButton();
|
||||
this.syncOpenState();
|
||||
}
|
||||
|
||||
public get isOpen(): boolean {
|
||||
|
|
@ -150,17 +148,17 @@ export class ConfigPane {
|
|||
this.state.activeVibeId = activeVibe.id;
|
||||
this.pane.refresh();
|
||||
this.syncColorReactionMatrix();
|
||||
this.syncButton();
|
||||
this.syncOpenState();
|
||||
}
|
||||
|
||||
private readonly toggle = () => {
|
||||
this.pane.hidden = !this.pane.hidden;
|
||||
this.syncButton();
|
||||
this.syncOpenState();
|
||||
};
|
||||
|
||||
private setHidden(isHidden: boolean): void {
|
||||
this.pane.hidden = isHidden;
|
||||
this.syncButton();
|
||||
this.syncOpenState();
|
||||
}
|
||||
|
||||
private setUpRuntimeTab(container: PaneContainer): void {
|
||||
|
|
@ -428,6 +426,11 @@ export class ConfigPane {
|
|||
: 'Show config overlay';
|
||||
}
|
||||
|
||||
private syncOpenState(): void {
|
||||
this.syncButton();
|
||||
this.options.onOpenChange?.(this.isOpen);
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
this.setHidden(true);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,107 +1,144 @@
|
|||
import { appConfig } from '../config';
|
||||
|
||||
interface MenuHiderOptions {
|
||||
persistentElement?: HTMLElement;
|
||||
}
|
||||
|
||||
export class MenuHider {
|
||||
private static readonly DEFAULT_TIME_TO_LIVE = appConfig.menuHider.timeToLiveMs;
|
||||
private static readonly INTERVAL = appConfig.menuHider.intervalMs;
|
||||
private static readonly BOTTOM_REVEAL_DISTANCE =
|
||||
appConfig.menuHider.bottomRevealDistancePx;
|
||||
private readonly interactiveElements: Array<HTMLElement>;
|
||||
private timeToLive = MenuHider.DEFAULT_TIME_TO_LIVE;
|
||||
private readonly desktopMediaQuery = window.matchMedia(
|
||||
appConfig.menuHider.desktopMediaQuery
|
||||
);
|
||||
private hideTimeout: number | undefined;
|
||||
private isHidden = false;
|
||||
private pointerInside = false;
|
||||
|
||||
public constructor(
|
||||
private readonly element: HTMLElement,
|
||||
private readonly shouldBeHidden: () => boolean,
|
||||
private readonly options: MenuHiderOptions = {}
|
||||
private readonly shouldBeHidden: () => boolean
|
||||
) {
|
||||
this.interactiveElements = Array.from(
|
||||
element.querySelectorAll<HTMLElement>(
|
||||
'a[href], button, input, select, textarea, [tabindex]'
|
||||
)
|
||||
element.addEventListener('pointerenter', this.onPointerEnter);
|
||||
element.addEventListener('pointerleave', this.onPointerLeave);
|
||||
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 = () => {
|
||||
this.timeToLive = MenuHider.DEFAULT_TIME_TO_LIVE;
|
||||
this.updateVisibility();
|
||||
private readonly onPointerEnter = () => {
|
||||
this.pointerInside = true;
|
||||
this.reveal();
|
||||
};
|
||||
|
||||
private readonly wakeUpNearViewportBottom = (event: PointerEvent) => {
|
||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
||||
const revealStart = viewportHeight - MenuHider.BOTTOM_REVEAL_DISTANCE;
|
||||
|
||||
if (event.clientY >= revealStart) {
|
||||
this.wakeUp();
|
||||
}
|
||||
private readonly onPointerLeave = () => {
|
||||
this.pointerInside = false;
|
||||
this.scheduleHide();
|
||||
};
|
||||
|
||||
private updateVisibility() {
|
||||
const focusWithin = this.element.contains(document.activeElement);
|
||||
const shouldHide = this.timeToLive === 0 && this.shouldBeHidden() && !focusWithin;
|
||||
private readonly onFocusIn = () => {
|
||||
this.reveal();
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
this.isHidden = shouldHide;
|
||||
this.element.classList.toggle('menu-hidden', shouldHide);
|
||||
this.syncAccessibility(shouldHide);
|
||||
}
|
||||
|
||||
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;
|
||||
if (this.isPointerOverDock(event.clientX, event.clientY)) {
|
||||
this.pointerInside = true;
|
||||
this.reveal();
|
||||
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.inert = false;
|
||||
}
|
||||
|
||||
this.interactiveElements.forEach((interactiveElement) => {
|
||||
const isPersistentElement = interactiveElement === persistentElement;
|
||||
private hide(): void {
|
||||
this.isHidden = true;
|
||||
this.element.classList.add('menu-hidden');
|
||||
this.element.setAttribute('aria-hidden', 'true');
|
||||
this.element.inert = true;
|
||||
}
|
||||
|
||||
interactiveElement.inert = shouldHide && !isPersistentElement;
|
||||
interactiveElement.toggleAttribute(
|
||||
'aria-hidden',
|
||||
shouldHide && !isPersistentElement
|
||||
);
|
||||
});
|
||||
private clearHideTimeout(): void {
|
||||
if (this.hideTimeout === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
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,
|
||||
sourceMap: GPUTextureView
|
||||
) {
|
||||
if (this.agentCount <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bindGroup = this.getBindGroup(trailMapIn, trailMapOut, sourceMap);
|
||||
|
||||
const passEncoder = commandEncoder.beginComputePass();
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export class BrushPipeline {
|
|||
private static readonly MAX_LINE_COUNT = appConfig.pipelines.brush.maxLineCount;
|
||||
private static readonly VERTICES_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 bindGroup: GPUBindGroup;
|
||||
|
|
@ -92,6 +93,18 @@ export class BrushPipeline {
|
|||
targets: [
|
||||
{
|
||||
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,
|
||||
isErasing,
|
||||
}: BrushSettings & { selectedColorIndex: number; isErasing: boolean }) {
|
||||
this.uniformValues[0] = brushSize / 2;
|
||||
this.uniformValues[1] = Math.floor((brushSize / 2) * brushSizeVariation);
|
||||
const brushRadius = brushSize / 2;
|
||||
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[3] = 0;
|
||||
this.uniformValues[4] = !isErasing && selectedColorIndex === 0 ? 1 : 0;
|
||||
|
|
@ -178,7 +197,7 @@ export class BrushPipeline {
|
|||
floatOffset,
|
||||
segment.from,
|
||||
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.g * settings.colorB
|
||||
+ strengths.b * settings.colorC;
|
||||
let normalizedTraceColor = traceColor / max(1.0, strengths.r + strengths.g + strengths.b);
|
||||
let brushColor =
|
||||
sourceStrengths.r * settings.colorA
|
||||
+ sourceStrengths.g * settings.colorB
|
||||
+ sourceStrengths.b * settings.colorC;
|
||||
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);
|
||||
|
||||
|
|
|
|||
|
|
@ -73,6 +73,10 @@ html > body {
|
|||
pointer-events: none;
|
||||
user-select: none;
|
||||
white-space: pre;
|
||||
|
||||
&[hidden] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
> .errors-container {
|
||||
|
|
|
|||
|
|
@ -1,17 +1,19 @@
|
|||
html > body > aside.control-dock {
|
||||
--dock-hidden-translate-y: calc(100% + env(safe-area-inset-bottom, 0px) + 16px);
|
||||
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: env(safe-area-inset-bottom);
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: env(safe-area-inset-bottom, 0px);
|
||||
z-index: 4;
|
||||
width: min(calc(100vw - 1rem), 980px);
|
||||
transform: translate(-50%, 0);
|
||||
translate: 0 0;
|
||||
margin: 0 auto;
|
||||
transform: translateY(0);
|
||||
visibility: visible;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity var(--transition-time-long),
|
||||
transform var(--transition-time-long),
|
||||
translate var(--transition-time-long),
|
||||
visibility 0s;
|
||||
|
||||
> .toolbar-row,
|
||||
|
|
@ -22,7 +24,7 @@ html > body > aside.control-dock {
|
|||
&.menu-hidden {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translate(-50%, 10px);
|
||||
transform: translateY(var(--dock-hidden-translate-y));
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity var(--transition-time-long),
|
||||
|
|
@ -34,32 +36,4 @@ html > body > aside.control-dock {
|
|||
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;
|
||||
width: var(--loading-progress);
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgb(255 255 255 / 72%),
|
||||
rgb(255 255 255 / 96%)
|
||||
);
|
||||
background: linear-gradient(90deg, rgb(255 255 255 / 72%), rgb(255 255 255 / 96%));
|
||||
box-shadow: 0 0 12px rgb(255 255 255 / 38%);
|
||||
transition: width var(--transition-time-long) ease-out;
|
||||
}
|
||||
|
|
@ -94,7 +90,7 @@ html > body.is-loading {
|
|||
aside.control-dock {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
translate: 0 36px;
|
||||
transform: translateY(var(--dock-hidden-translate-y));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,10 +12,7 @@
|
|||
}
|
||||
|
||||
> aside.control-dock {
|
||||
&,
|
||||
&.menu-hidden {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
transform: translateY(0);
|
||||
|
||||
> .toolbar-row {
|
||||
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,
|
||||
workgroupSize: 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 workgroupCountX = Math.min(
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ export const initializeContext = ({
|
|||
device: GPUDevice;
|
||||
canvas: HTMLCanvasElement;
|
||||
}): GPUCanvasContext => {
|
||||
const context = canvas.getContext('webgpu' as any) as GPUCanvasContext | null;
|
||||
const context = canvas.getContext('webgpu');
|
||||
|
||||
if (!context) {
|
||||
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,
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
},
|
||||
"include": ["src/**/*", "definitions.d.ts", "vite.config.ts"]
|
||||
}
|
||||
|
|
|
|||