sure
This commit is contained in:
parent
ced0ac56f3
commit
d6a8f898d1
27 changed files with 751 additions and 354 deletions
312
e2e/app.spec.ts
312
e2e/app.spec.ts
|
|
@ -1,5 +1,34 @@
|
||||||
import { expect, test, type Page } from '@playwright/test';
|
import { expect, test, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
const canvasName = 'Interactive generative garden canvas';
|
||||||
|
|
||||||
|
const isLocalUrl = (url: string) => {
|
||||||
|
const { hostname } = new URL(url);
|
||||||
|
return hostname === '127.0.0.1' || hostname === 'localhost';
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectLocalBrowserFailures = (page: Page) => {
|
||||||
|
const failures: Array<string> = [];
|
||||||
|
|
||||||
|
page.on('requestfailed', (request) => {
|
||||||
|
if (!isLocalUrl(request.url())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const failure = request.failure();
|
||||||
|
failures.push(`${request.method()} ${request.url()} ${failure?.errorText}`);
|
||||||
|
});
|
||||||
|
page.on('response', (response) => {
|
||||||
|
if (response.status() < 400 || !isLocalUrl(response.url())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
failures.push(`${response.status()} ${response.url()}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return failures;
|
||||||
|
};
|
||||||
|
|
||||||
const disableWebGpu = async (page: Page) => {
|
const disableWebGpu = async (page: Page) => {
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
Object.defineProperty(navigator, 'gpu', {
|
Object.defineProperty(navigator, 'gpu', {
|
||||||
|
|
@ -9,223 +38,98 @@ const disableWebGpu = async (page: Page) => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const getFirstSwatchColor = (page: Page) =>
|
test('starts the WebGPU garden and accepts drawing input', async ({ page }) => {
|
||||||
page
|
const browserFailures = collectLocalBrowserFailures(page);
|
||||||
.locator('.color-swatch')
|
const consoleErrors: Array<string> = [];
|
||||||
.first()
|
page.on('console', (message) => {
|
||||||
.evaluate((element) => getComputedStyle(element).backgroundColor);
|
if (message.type() === 'error') {
|
||||||
|
consoleErrors.push(message.text());
|
||||||
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 }) => {
|
|
||||||
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.addInitScript((expectedCanvasName) => {
|
||||||
|
const captureState = { count: 0 };
|
||||||
|
Object.defineProperty(window, '__fleetingGardenPointerCaptures', {
|
||||||
|
configurable: true,
|
||||||
|
value: captureState,
|
||||||
|
});
|
||||||
|
|
||||||
|
const originalSetPointerCapture = Element.prototype.setPointerCapture;
|
||||||
|
Element.prototype.setPointerCapture = function setPointerCapture(pointerId) {
|
||||||
|
if (
|
||||||
|
this instanceof HTMLCanvasElement &&
|
||||||
|
this.getAttribute('aria-label') === expectedCanvasName
|
||||||
|
) {
|
||||||
|
captureState.count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return originalSetPointerCapture.call(this, pointerId);
|
||||||
|
};
|
||||||
|
}, canvasName);
|
||||||
|
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
await expect(page.locator('body')).not.toHaveClass(/is-loading/, {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
await expect(page).toHaveTitle('Fleeting Garden');
|
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||||
await expect(
|
|
||||||
page.getByRole('img', { name: 'Interactive generative garden canvas' })
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible();
|
await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible();
|
||||||
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
|
|
||||||
await expect(page.getByRole('alert')).toContainText('Fleeting Garden needs WebGPU');
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'About' }).click();
|
const canvas = page.getByRole('img', { name: canvasName });
|
||||||
await expect(page.getByRole('heading', { name: 'Fleeting Garden' })).toBeVisible();
|
await expect(canvas).toBeVisible();
|
||||||
|
const canvasSize = await canvas.evaluate((element) => {
|
||||||
|
const canvasElement = element as HTMLCanvasElement;
|
||||||
|
return {
|
||||||
|
height: canvasElement.height,
|
||||||
|
width: canvasElement.width,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(canvasSize.width).toBeGreaterThan(0);
|
||||||
|
expect(canvasSize.height).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const box = await canvas.boundingBox();
|
||||||
|
expect(box).not.toBeNull();
|
||||||
|
if (!box) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.mouse.move(box.x + box.width * 0.2, box.y + box.height * 0.5);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(box.x + box.width * 0.8, box.y + box.height * 0.5, {
|
||||||
|
steps: 16,
|
||||||
|
});
|
||||||
|
await page.mouse.up();
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page.evaluate(
|
||||||
|
() =>
|
||||||
|
(
|
||||||
|
window as unknown as {
|
||||||
|
__fleetingGardenPointerCaptures?: { count: number };
|
||||||
|
}
|
||||||
|
).__fleetingGardenPointerCaptures?.count ?? 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.toBeGreaterThan(0);
|
||||||
|
|
||||||
|
expect(consoleErrors).toEqual([]);
|
||||||
expect(browserFailures).toEqual([]);
|
expect(browserFailures).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('keeps fallback controls interactive and accessible', async ({ page }) => {
|
test('shows a clear fallback when WebGPU is unavailable', async ({ page }) => {
|
||||||
await disableWebGpu(page);
|
const browserFailures = collectLocalBrowserFailures(page);
|
||||||
|
|
||||||
|
await disableWebGpu(page);
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
|
||||||
|
await expect(page).toHaveTitle('Fleeting Garden');
|
||||||
|
await expect(page.getByRole('img', { name: canvasName })).toBeVisible();
|
||||||
|
await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible();
|
||||||
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
|
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
|
||||||
|
|
||||||
const aboutButton = page.getByRole('button', { name: 'About' });
|
const fallback = page.getByRole('alert');
|
||||||
const aboutPanel = page.locator('#info-panel');
|
await expect(fallback).toContainText('Fleeting Garden needs WebGPU');
|
||||||
await expect(aboutButton).toHaveAttribute('aria-expanded', 'false');
|
await expect(fallback).toContainText('webgpu-unsupported');
|
||||||
await aboutButton.click();
|
expect(browserFailures).toEqual([]);
|
||||||
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.locator('button.settings');
|
|
||||||
await expect(settingsButton).toHaveAttribute('aria-label', 'Show config overlay');
|
|
||||||
await expect(settingsButton).toHaveAttribute('aria-expanded', 'false');
|
|
||||||
await settingsButton.click();
|
|
||||||
await expect(settingsButton).toHaveAttribute('aria-expanded', 'true');
|
|
||||||
await expect(settingsButton).toHaveAttribute('aria-label', 'Hide config overlay');
|
|
||||||
await expect(page.locator('.config-pane')).toBeVisible();
|
|
||||||
await expect(page.locator('.config-pane')).toContainText('Runtime');
|
|
||||||
await expect(page.locator('.color-reaction-matrix')).toBeVisible();
|
|
||||||
|
|
||||||
const colorReaction = page.getByLabel('Color 1 agents reacting to color 2');
|
|
||||||
await colorReaction.selectOption('-1');
|
|
||||||
await expect(colorReaction).toHaveValue('-1');
|
|
||||||
await settingsButton.click();
|
|
||||||
await expect(settingsButton).toHaveAttribute('aria-expanded', 'false');
|
|
||||||
|
|
||||||
const soundButton = page.locator('button.sound');
|
|
||||||
const volumeSlider = page.getByLabel('Master volume');
|
|
||||||
await expect(volumeSlider).toHaveValue('0.42');
|
|
||||||
await volumeSlider.evaluate((input) => {
|
|
||||||
const slider = input as HTMLInputElement;
|
|
||||||
slider.value = '0.25';
|
|
||||||
slider.dispatchEvent(new Event('input', { bubbles: true }));
|
|
||||||
});
|
|
||||||
await expect(volumeSlider).toHaveValue('0.25');
|
|
||||||
await expect(volumeSlider).toHaveAttribute('aria-valuetext', '25%');
|
|
||||||
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');
|
|
||||||
await expect(page.getByLabel('Master volume')).toHaveValue('0.25');
|
|
||||||
await expect(page.getByLabel('Master volume')).toHaveAttribute(
|
|
||||||
'aria-valuetext',
|
|
||||||
'Muted, 25%'
|
|
||||||
);
|
|
||||||
|
|
||||||
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/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('keeps the fallback shell usable on mobile', async ({ page }) => {
|
|
||||||
await page.setViewportSize({ height: 844, width: 390 });
|
|
||||||
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: 'About' })).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,12 @@ export default defineConfig({
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
name: 'chromium',
|
name: 'chromium',
|
||||||
use: { ...devices['Desktop Chrome'] },
|
use: {
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
launchOptions: {
|
||||||
|
args: ['--enable-unsafe-webgpu'],
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import {
|
||||||
type PlausibleEventOptions,
|
type PlausibleEventOptions,
|
||||||
} from '@plausible-analytics/tracker';
|
} from '@plausible-analytics/tracker';
|
||||||
|
|
||||||
|
import type { VibeId } from './vibes';
|
||||||
|
|
||||||
let isInitialized = false;
|
let isInitialized = false;
|
||||||
|
|
||||||
const track = (eventName: string, options: PlausibleEventOptions = {}) => {
|
const track = (eventName: string, options: PlausibleEventOptions = {}) => {
|
||||||
|
|
@ -37,7 +39,7 @@ export const trackVibeChange = ({
|
||||||
vibeName,
|
vibeName,
|
||||||
source,
|
source,
|
||||||
}: {
|
}: {
|
||||||
vibeId: string;
|
vibeId: VibeId;
|
||||||
vibeName: string;
|
vibeName: string;
|
||||||
source: string;
|
source: string;
|
||||||
}) => {
|
}) => {
|
||||||
|
|
@ -50,7 +52,7 @@ export const trackVibeChange = ({
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const trackExport = ({ vibeId }: { vibeId: string }) => {
|
export const trackExport = ({ vibeId }: { vibeId: VibeId }) => {
|
||||||
track('Export', {
|
track('Export', {
|
||||||
props: {
|
props: {
|
||||||
format: 'png',
|
format: 'png',
|
||||||
|
|
|
||||||
|
|
@ -94,8 +94,6 @@ interface GardenAudioGenerativePianoConfig {
|
||||||
};
|
};
|
||||||
brushPhrase: {
|
brushPhrase: {
|
||||||
initialMotifOffset: number;
|
initialMotifOffset: number;
|
||||||
energyRetain: number;
|
|
||||||
maniaRetain: number;
|
|
||||||
energyDecaySeconds: number;
|
energyDecaySeconds: number;
|
||||||
maniaDecaySeconds: number;
|
maniaDecaySeconds: number;
|
||||||
fadeMinimumLifetimeSeconds: number;
|
fadeMinimumLifetimeSeconds: number;
|
||||||
|
|
@ -165,7 +163,6 @@ interface GardenAudioGenerativePianoConfig {
|
||||||
min: number;
|
min: number;
|
||||||
max: number;
|
max: number;
|
||||||
};
|
};
|
||||||
styleRotationMinSeconds: number;
|
|
||||||
stylePanOffsetScale: number;
|
stylePanOffsetScale: number;
|
||||||
lowpass: {
|
lowpass: {
|
||||||
midiBase: number;
|
midiBase: number;
|
||||||
|
|
@ -174,7 +171,6 @@ interface GardenAudioGenerativePianoConfig {
|
||||||
expressionBase: number;
|
expressionBase: number;
|
||||||
expressionWeight: number;
|
expressionWeight: number;
|
||||||
};
|
};
|
||||||
styleRotationSeconds: number;
|
|
||||||
styleRotationBars: number;
|
styleRotationBars: number;
|
||||||
chordBars: number;
|
chordBars: number;
|
||||||
supportBarSpacing: number;
|
supportBarSpacing: number;
|
||||||
|
|
@ -189,7 +185,6 @@ interface GardenAudioGenerativePianoConfig {
|
||||||
noteScoreChordToneWeight: number;
|
noteScoreChordToneWeight: number;
|
||||||
noteScoreRepeatPenalty: number;
|
noteScoreRepeatPenalty: number;
|
||||||
gestureAccentMinIntervalSeconds: number;
|
gestureAccentMinIntervalSeconds: number;
|
||||||
strokeAccentMinIntervalSeconds: number;
|
|
||||||
strokeAccentMinSteps: number;
|
strokeAccentMinSteps: number;
|
||||||
strokeAccentThreshold: number;
|
strokeAccentThreshold: number;
|
||||||
stingerDurationSeconds: number;
|
stingerDurationSeconds: number;
|
||||||
|
|
@ -315,8 +310,6 @@ export interface GardenAudioConfig {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
input: {
|
input: {
|
||||||
distanceWindowForFullActivityPixels: number;
|
|
||||||
distanceWindowSeconds: number;
|
|
||||||
fallbackFrameSeconds: number;
|
fallbackFrameSeconds: number;
|
||||||
fullActivitySpeed: number;
|
fullActivitySpeed: number;
|
||||||
activityNoiseFloorSpeed: number;
|
activityNoiseFloorSpeed: number;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import type { GardenAudioConfig } from './garden-audio-config';
|
import type { GardenAudioConfig } from './garden-audio-config';
|
||||||
import type { GardenAudioStroke } from './garden-audio-types';
|
import type { GardenAudioStroke } from './garden-audio-types';
|
||||||
|
|
||||||
|
const fallbackNormalizationPixels = 1000;
|
||||||
|
|
||||||
export interface GardenAudioStrokeMetrics {
|
export interface GardenAudioStrokeMetrics {
|
||||||
distancePixels: number;
|
distancePixels: number;
|
||||||
elapsedSeconds: number;
|
elapsedSeconds: number;
|
||||||
|
|
@ -16,8 +18,7 @@ export const getStrokeMetrics = (
|
||||||
const dy = stroke.to[1] - stroke.from[1];
|
const dy = stroke.to[1] - stroke.from[1];
|
||||||
const distancePixels = Math.hypot(dx, dy);
|
const distancePixels = Math.hypot(dx, dy);
|
||||||
const elapsedSeconds = getElapsedSeconds(stroke, inputConfig);
|
const elapsedSeconds = getElapsedSeconds(stroke, inputConfig);
|
||||||
const normalizedDistance =
|
const normalizedDistance = distancePixels / getStrokeNormalizationPixels(stroke);
|
||||||
distancePixels / getStrokeNormalizationPixels(stroke, inputConfig);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
distancePixels,
|
distancePixels,
|
||||||
|
|
@ -42,10 +43,7 @@ const getElapsedSeconds = (
|
||||||
return inputConfig.fallbackFrameSeconds;
|
return inputConfig.fallbackFrameSeconds;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStrokeNormalizationPixels = (
|
const getStrokeNormalizationPixels = (stroke: GardenAudioStroke): number => {
|
||||||
stroke: GardenAudioStroke,
|
|
||||||
inputConfig: GardenAudioConfig['input']
|
|
||||||
): number => {
|
|
||||||
const width = stroke.canvasSize?.[0];
|
const width = stroke.canvasSize?.[0];
|
||||||
const height = stroke.canvasSize?.[1];
|
const height = stroke.canvasSize?.[1];
|
||||||
if (
|
if (
|
||||||
|
|
@ -59,5 +57,5 @@ const getStrokeNormalizationPixels = (
|
||||||
return Math.max(1, Math.min(width, height));
|
return Math.max(1, Math.min(width, height));
|
||||||
}
|
}
|
||||||
|
|
||||||
return Math.max(1, inputConfig.distanceWindowForFullActivityPixels);
|
return fallbackNormalizationPixels;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { clamp01 } from '../utils/clamp';
|
import { clamp01 } from '../utils/clamp';
|
||||||
import { ErrorHandler, Severity } from '../utils/error-handler';
|
import { ErrorHandler, Severity } from '../utils/error-handler';
|
||||||
import { VibePreset } from '../vibes';
|
import type { VibeId, VibePreset } from '../vibes';
|
||||||
import { GardenAudioConfig } from './garden-audio-config';
|
import { GardenAudioConfig } from './garden-audio-config';
|
||||||
import { GardenAudioEnergy } from './garden-audio-energy';
|
import { GardenAudioEnergy } from './garden-audio-energy';
|
||||||
import { GardenAudioGestureState } from './garden-audio-gesture-state';
|
import { GardenAudioGestureState } from './garden-audio-gesture-state';
|
||||||
|
|
@ -22,6 +22,8 @@ export type {
|
||||||
GardenAudioStroke,
|
GardenAudioStroke,
|
||||||
} from './garden-audio-types';
|
} from './garden-audio-types';
|
||||||
|
|
||||||
|
type AudioLifecycle = 'idle' | 'started' | 'destroyed';
|
||||||
|
|
||||||
export class GardenAudio {
|
export class GardenAudio {
|
||||||
private readonly graph: GardenAudioGraph;
|
private readonly graph: GardenAudioGraph;
|
||||||
private readonly piano: PianoSampler;
|
private readonly piano: PianoSampler;
|
||||||
|
|
@ -30,13 +32,11 @@ export class GardenAudio {
|
||||||
private readonly gestureState: GardenAudioGestureState;
|
private readonly gestureState: GardenAudioGestureState;
|
||||||
private readonly pianoEngine: GenerativePianoEngine;
|
private readonly pianoEngine: GenerativePianoEngine;
|
||||||
|
|
||||||
private currentVibeId: string | null = null;
|
private currentVibeId: VibeId | null = null;
|
||||||
private hasStarted = false;
|
private lifecycle: AudioLifecycle = 'idle';
|
||||||
private isDestroyed = false;
|
|
||||||
private isMuted = false;
|
private isMuted = false;
|
||||||
private masterVolume: number;
|
|
||||||
private isGestureActive = false;
|
private isGestureActive = false;
|
||||||
private hasQueuedPianoLoad = false;
|
private masterVolume: number;
|
||||||
private lastEraserAt = Number.NEGATIVE_INFINITY;
|
private lastEraserAt = Number.NEGATIVE_INFINITY;
|
||||||
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
||||||
|
|
||||||
|
|
@ -51,7 +51,7 @@ export class GardenAudio {
|
||||||
}
|
}
|
||||||
|
|
||||||
public start(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
|
public start(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
|
||||||
if (this.isDestroyed || this.isMuted) {
|
if (this.lifecycle === 'destroyed' || this.isMuted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,7 +81,11 @@ export class GardenAudio {
|
||||||
if (resumePromise) {
|
if (resumePromise) {
|
||||||
void resumePromise
|
void resumePromise
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (this.graph.context === context && !this.isDestroyed && !this.isMuted) {
|
if (
|
||||||
|
this.graph.context === context &&
|
||||||
|
this.lifecycle !== 'destroyed' &&
|
||||||
|
!this.isMuted
|
||||||
|
) {
|
||||||
this.graph.unlock();
|
this.graph.unlock();
|
||||||
this.graph.setMasterGain(this.masterVolume, startupRampSeconds);
|
this.graph.setMasterGain(this.masterVolume, startupRampSeconds);
|
||||||
}
|
}
|
||||||
|
|
@ -94,17 +98,16 @@ export class GardenAudio {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.hasStarted = true;
|
this.lifecycle = 'started';
|
||||||
this.applyVibe(vibe);
|
this.applyVibe(vibe);
|
||||||
this.pianoEngine.prime(context.currentTime);
|
this.pianoEngine.prime(context.currentTime);
|
||||||
this.graph.setMasterGain(this.masterVolume, startupRampSeconds);
|
this.graph.setMasterGain(this.masterVolume, startupRampSeconds);
|
||||||
|
|
||||||
if (!this.hasQueuedPianoLoad) {
|
const pianoLoad = this.piano.loadIfIdle(context);
|
||||||
this.hasQueuedPianoLoad = true;
|
if (pianoLoad) {
|
||||||
void this.piano
|
void pianoLoad
|
||||||
.load(context)
|
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (this.graph.context === context && !this.isDestroyed) {
|
if (this.graph.context === context && this.lifecycle !== 'destroyed') {
|
||||||
this.pianoEngine.cue(context.currentTime);
|
this.pianoEngine.cue(context.currentTime);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -126,7 +129,7 @@ export class GardenAudio {
|
||||||
context &&
|
context &&
|
||||||
(context.state === 'running' || options.userGesture === true) &&
|
(context.state === 'running' || options.userGesture === true) &&
|
||||||
!this.isMuted &&
|
!this.isMuted &&
|
||||||
!this.isDestroyed &&
|
this.lifecycle !== 'destroyed' &&
|
||||||
didChangeVibe
|
didChangeVibe
|
||||||
) {
|
) {
|
||||||
this.playVibeChangeStinger(vibe);
|
this.playVibeChangeStinger(vibe);
|
||||||
|
|
@ -173,7 +176,7 @@ export class GardenAudio {
|
||||||
|
|
||||||
public update(snapshot: GardenAudioSnapshot): void {
|
public update(snapshot: GardenAudioSnapshot): void {
|
||||||
const context = this.graph.context;
|
const context = this.graph.context;
|
||||||
if (!this.hasStarted || !context || this.isMuted) {
|
if (this.lifecycle !== 'started' || !context || this.isMuted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -195,7 +198,7 @@ export class GardenAudio {
|
||||||
}
|
}
|
||||||
|
|
||||||
public stroke(stroke: GardenAudioStroke): void {
|
public stroke(stroke: GardenAudioStroke): void {
|
||||||
if (this.isDestroyed || this.isMuted) {
|
if (this.lifecycle === 'destroyed' || this.isMuted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -230,7 +233,7 @@ export class GardenAudio {
|
||||||
}
|
}
|
||||||
|
|
||||||
public async destroy(): Promise<void> {
|
public async destroy(): Promise<void> {
|
||||||
this.isDestroyed = true;
|
this.lifecycle = 'destroyed';
|
||||||
await this.graph.close();
|
await this.graph.close();
|
||||||
|
|
||||||
this.piano.reset();
|
this.piano.reset();
|
||||||
|
|
@ -238,9 +241,7 @@ export class GardenAudio {
|
||||||
this.gestureState.reset();
|
this.gestureState.reset();
|
||||||
this.pianoEngine.reset();
|
this.pianoEngine.reset();
|
||||||
this.currentVibeId = null;
|
this.currentVibeId = null;
|
||||||
this.hasStarted = false;
|
|
||||||
this.isGestureActive = false;
|
this.isGestureActive = false;
|
||||||
this.hasQueuedPianoLoad = false;
|
|
||||||
this.lastEraserAt = Number.NEGATIVE_INFINITY;
|
this.lastEraserAt = Number.NEGATIVE_INFINITY;
|
||||||
this.lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
this.lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,47 @@ describe('PianoSampler', () => {
|
||||||
expect(calls.bufferSourcesStarted).toBe(1);
|
expect(calls.bufferSourcesStarted).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('only queues a piano load when the sampler is idle', async () => {
|
||||||
|
const context = new FakeAudioContext() as unknown as AudioContext;
|
||||||
|
const sampler = await makeSampler(context);
|
||||||
|
const fetch = vi.fn(async () => {
|
||||||
|
return {
|
||||||
|
arrayBuffer: async () => new ArrayBuffer(8),
|
||||||
|
ok: true,
|
||||||
|
} as Response;
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
|
||||||
|
const firstLoad = sampler.loadIfIdle(context);
|
||||||
|
const secondLoad = sampler.loadIfIdle(context);
|
||||||
|
|
||||||
|
expect(firstLoad).toBeInstanceOf(Promise);
|
||||||
|
expect(secondLoad).toBeNull();
|
||||||
|
|
||||||
|
await firstLoad;
|
||||||
|
|
||||||
|
expect(sampler.loadIfIdle(context)).toBeNull();
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(sampleCount);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows loading to be retried after a load failure', async () => {
|
||||||
|
const context = new FakeAudioContext() as unknown as AudioContext;
|
||||||
|
const sampler = await makeSampler(context);
|
||||||
|
const fetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(new Error('load failed'))
|
||||||
|
.mockResolvedValue({
|
||||||
|
arrayBuffer: async () => new ArrayBuffer(8),
|
||||||
|
ok: true,
|
||||||
|
} as Response);
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
|
||||||
|
await expect(sampler.loadIfIdle(context)).rejects.toThrow('load failed');
|
||||||
|
await expect(sampler.loadIfIdle(context)).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(sampleCount * 2);
|
||||||
|
});
|
||||||
|
|
||||||
it('stays silent when no decoded sample is available', () => {
|
it('stays silent when no decoded sample is available', () => {
|
||||||
const context = new FakeAudioContext() as unknown as AudioContext;
|
const context = new FakeAudioContext() as unknown as AudioContext;
|
||||||
return makeSampler(context).then((sampler) => {
|
return makeSampler(context).then((sampler) => {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,10 @@ import { GardenAudioGraph } from './garden-audio-graph';
|
||||||
import { ActivePianoVoice, LoadedPianoSample, PianoNote } from './garden-audio-types';
|
import { ActivePianoVoice, LoadedPianoSample, PianoNote } from './garden-audio-types';
|
||||||
import { getLoadedPianoSamples, loadPianoSamples } from './piano-samples';
|
import { getLoadedPianoSamples, loadPianoSamples } from './piano-samples';
|
||||||
|
|
||||||
|
type PianoLoadState = 'idle' | 'loading' | 'loaded';
|
||||||
|
|
||||||
export class PianoSampler {
|
export class PianoSampler {
|
||||||
|
private loadState: PianoLoadState = 'idle';
|
||||||
private sampleLoadPromise: Promise<void> | null = null;
|
private sampleLoadPromise: Promise<void> | null = null;
|
||||||
private samples: Array<LoadedPianoSample> = [];
|
private samples: Array<LoadedPianoSample> = [];
|
||||||
private activeVoices: Array<ActivePianoVoice> = [];
|
private activeVoices: Array<ActivePianoVoice> = [];
|
||||||
|
|
@ -15,9 +18,14 @@ export class PianoSampler {
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public load(context: BaseAudioContext): Promise<void> {
|
public load(context: BaseAudioContext): Promise<void> {
|
||||||
|
if (this.loadState === 'loaded') {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
const loadedSamples = getLoadedPianoSamples();
|
const loadedSamples = getLoadedPianoSamples();
|
||||||
if (loadedSamples) {
|
if (loadedSamples) {
|
||||||
this.setSamples(loadedSamples);
|
this.setSamples(loadedSamples);
|
||||||
|
this.loadState = 'loaded';
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -25,13 +33,29 @@ export class PianoSampler {
|
||||||
return this.sampleLoadPromise;
|
return this.sampleLoadPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sampleLoadPromise = loadPianoSamples(context).then((samples) => {
|
this.loadState = 'loading';
|
||||||
this.setSamples(samples);
|
this.sampleLoadPromise = loadPianoSamples(context)
|
||||||
});
|
.then((samples) => {
|
||||||
|
this.setSamples(samples);
|
||||||
|
this.loadState = 'loaded';
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.loadState = 'idle';
|
||||||
|
this.sampleLoadPromise = null;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
return this.sampleLoadPromise;
|
return this.sampleLoadPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public loadIfIdle(context: BaseAudioContext): Promise<void> | null {
|
||||||
|
if (this.loadState !== 'idle') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.load(context);
|
||||||
|
}
|
||||||
|
|
||||||
public play({
|
public play({
|
||||||
midi,
|
midi,
|
||||||
velocity,
|
velocity,
|
||||||
|
|
@ -158,6 +182,7 @@ export class PianoSampler {
|
||||||
}
|
}
|
||||||
|
|
||||||
public reset(): void {
|
public reset(): void {
|
||||||
|
this.loadState = 'idle';
|
||||||
this.sampleLoadPromise = null;
|
this.sampleLoadPromise = null;
|
||||||
this.samples = [];
|
this.samples = [];
|
||||||
this.activeVoices = [];
|
this.activeVoices = [];
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import { defaultVibeId, vibePresets } from './config/vibe-presets';
|
||||||
|
|
||||||
const defaultAudioMasterVolume = 0.42;
|
const defaultAudioMasterVolume = 0.42;
|
||||||
|
|
||||||
|
export { VibeId } from './config/types';
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
GardenAppConfig,
|
GardenAppConfig,
|
||||||
GardenRuntimeSettings,
|
GardenRuntimeSettings,
|
||||||
|
|
@ -56,7 +58,7 @@ export const appConfig = {
|
||||||
tailStopExtraSeconds: 0.05,
|
tailStopExtraSeconds: 0.05,
|
||||||
voiceStealFadeSeconds: 0.025,
|
voiceStealFadeSeconds: 0.025,
|
||||||
voiceStealStopSeconds: 0.05,
|
voiceStealStopSeconds: 0.05,
|
||||||
sampleBaseUrl: `${import.meta.env.BASE_URL}audio/piano/`,
|
sampleBaseUrl: `${import.meta.env.BASE_URL}audio/`,
|
||||||
preloadDecode: {
|
preloadDecode: {
|
||||||
channels: 1,
|
channels: 1,
|
||||||
frames: 1,
|
frames: 1,
|
||||||
|
|
@ -126,8 +128,6 @@ export const appConfig = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
input: {
|
input: {
|
||||||
distanceWindowForFullActivityPixels: 140,
|
|
||||||
distanceWindowSeconds: 0.5,
|
|
||||||
fallbackFrameSeconds: 1 / 60,
|
fallbackFrameSeconds: 1 / 60,
|
||||||
fullActivitySpeed: 0.86,
|
fullActivitySpeed: 0.86,
|
||||||
activityNoiseFloorSpeed: 0.025,
|
activityNoiseFloorSpeed: 0.025,
|
||||||
|
|
@ -267,8 +267,6 @@ export const appConfig = {
|
||||||
},
|
},
|
||||||
brushPhrase: {
|
brushPhrase: {
|
||||||
initialMotifOffset: -1,
|
initialMotifOffset: -1,
|
||||||
energyRetain: 0.94,
|
|
||||||
maniaRetain: 0.92,
|
|
||||||
energyDecaySeconds: 0.72,
|
energyDecaySeconds: 0.72,
|
||||||
maniaDecaySeconds: 0.54,
|
maniaDecaySeconds: 0.54,
|
||||||
fadeMinimumLifetimeSeconds: 0.001,
|
fadeMinimumLifetimeSeconds: 0.001,
|
||||||
|
|
@ -338,7 +336,6 @@ export const appConfig = {
|
||||||
min: -3,
|
min: -3,
|
||||||
max: 3,
|
max: 3,
|
||||||
},
|
},
|
||||||
styleRotationMinSeconds: 0.001,
|
|
||||||
stylePanOffsetScale: 0.35,
|
stylePanOffsetScale: 0.35,
|
||||||
lowpass: {
|
lowpass: {
|
||||||
midiBase: 48,
|
midiBase: 48,
|
||||||
|
|
@ -347,7 +344,6 @@ export const appConfig = {
|
||||||
expressionBase: 0.58,
|
expressionBase: 0.58,
|
||||||
expressionWeight: 0.32,
|
expressionWeight: 0.32,
|
||||||
},
|
},
|
||||||
styleRotationSeconds: 8,
|
|
||||||
styleRotationBars: 2,
|
styleRotationBars: 2,
|
||||||
chordBars: 4,
|
chordBars: 4,
|
||||||
supportBarSpacing: 2,
|
supportBarSpacing: 2,
|
||||||
|
|
@ -362,7 +358,6 @@ export const appConfig = {
|
||||||
noteScoreChordToneWeight: 0.75,
|
noteScoreChordToneWeight: 0.75,
|
||||||
noteScoreRepeatPenalty: 3.2,
|
noteScoreRepeatPenalty: 3.2,
|
||||||
gestureAccentMinIntervalSeconds: 2.5,
|
gestureAccentMinIntervalSeconds: 2.5,
|
||||||
strokeAccentMinIntervalSeconds: 3.2,
|
|
||||||
strokeAccentMinSteps: 12,
|
strokeAccentMinSteps: 12,
|
||||||
strokeAccentThreshold: 0.58,
|
strokeAccentThreshold: 0.58,
|
||||||
stingerSpacingSeconds: 0.08,
|
stingerSpacingSeconds: 0.08,
|
||||||
|
|
|
||||||
|
|
@ -64,8 +64,17 @@ type GardenDefaultSettings = Omit<
|
||||||
keyof GardenVibeSettings | 'eraserSize' | 'mirrorSegmentCount'
|
keyof GardenVibeSettings | 'eraserSize' | 'mirrorSegmentCount'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
export enum VibeId {
|
||||||
|
CandyRain = 'candy-rain',
|
||||||
|
SunlitMoss = 'sunlit-moss',
|
||||||
|
CoralTide = 'coral-tide',
|
||||||
|
MoonOrchid = 'moon-orchid',
|
||||||
|
PeachNeon = 'peach-neon',
|
||||||
|
FrostBloom = 'frost-bloom',
|
||||||
|
}
|
||||||
|
|
||||||
export interface VibePreset {
|
export interface VibePreset {
|
||||||
id: string;
|
id: VibeId;
|
||||||
name: string;
|
name: string;
|
||||||
colors: [string, string, string];
|
colors: [string, string, string];
|
||||||
backgroundColor: string;
|
backgroundColor: string;
|
||||||
|
|
@ -248,7 +257,7 @@ export interface GardenAppConfig {
|
||||||
title: string;
|
title: string;
|
||||||
};
|
};
|
||||||
vibes: {
|
vibes: {
|
||||||
defaultVibeId: string;
|
defaultVibeId: VibeId;
|
||||||
presets: Array<VibePreset>;
|
presets: Array<VibePreset>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { GardenAudioChord } from '../audio/garden-audio-config';
|
import type { GardenAudioChord } from '../audio/garden-audio-config';
|
||||||
import type { VibePreset } from './types';
|
import { VibeId, type VibePreset } from './types';
|
||||||
|
|
||||||
const majorProgression: Array<GardenAudioChord> = [
|
const majorProgression: Array<GardenAudioChord> = [
|
||||||
{ rootOffset: 0, quality: 'major' },
|
{ rootOffset: 0, quality: 'major' },
|
||||||
|
|
@ -21,11 +21,11 @@ const mixolydianPentatonic = [0, 2, 4, 7, 10];
|
||||||
const dorianHexatonic = [0, 2, 3, 5, 7, 10];
|
const dorianHexatonic = [0, 2, 3, 5, 7, 10];
|
||||||
const darkMinorPentatonic = [0, 2, 3, 7, 10];
|
const darkMinorPentatonic = [0, 2, 3, 7, 10];
|
||||||
|
|
||||||
export const defaultVibeId = 'candy-rain';
|
export const defaultVibeId = VibeId.CandyRain;
|
||||||
|
|
||||||
export const vibePresets: Array<VibePreset> = [
|
export const vibePresets: Array<VibePreset> = [
|
||||||
{
|
{
|
||||||
id: 'candy-rain',
|
id: VibeId.CandyRain,
|
||||||
name: 'Candy Rain',
|
name: 'Candy Rain',
|
||||||
colors: ['#ff5da2', '#36d7d0', '#ffd84d'],
|
colors: ['#ff5da2', '#36d7d0', '#ffd84d'],
|
||||||
backgroundColor: '#10151f',
|
backgroundColor: '#10151f',
|
||||||
|
|
@ -50,7 +50,7 @@ export const vibePresets: Array<VibePreset> = [
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'sunlit-moss',
|
id: VibeId.SunlitMoss,
|
||||||
name: 'Sunlit Moss',
|
name: 'Sunlit Moss',
|
||||||
colors: ['#83d483', '#f6d76b', '#5ec1a1'],
|
colors: ['#83d483', '#f6d76b', '#5ec1a1'],
|
||||||
backgroundColor: '#172016',
|
backgroundColor: '#172016',
|
||||||
|
|
@ -80,7 +80,7 @@ export const vibePresets: Array<VibePreset> = [
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'coral-tide',
|
id: VibeId.CoralTide,
|
||||||
name: 'Coral Tide',
|
name: 'Coral Tide',
|
||||||
colors: ['#ff7f6e', '#40b8ff', '#f4f0a6'],
|
colors: ['#ff7f6e', '#40b8ff', '#f4f0a6'],
|
||||||
backgroundColor: '#0f1822',
|
backgroundColor: '#0f1822',
|
||||||
|
|
@ -105,7 +105,7 @@ export const vibePresets: Array<VibePreset> = [
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'moon-orchid',
|
id: VibeId.MoonOrchid,
|
||||||
name: 'Moon Orchid',
|
name: 'Moon Orchid',
|
||||||
colors: ['#c993ff', '#7dd8ff', '#f0f4ff'],
|
colors: ['#c993ff', '#7dd8ff', '#f0f4ff'],
|
||||||
backgroundColor: '#14121d',
|
backgroundColor: '#14121d',
|
||||||
|
|
@ -130,7 +130,7 @@ export const vibePresets: Array<VibePreset> = [
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'peach-neon',
|
id: VibeId.PeachNeon,
|
||||||
name: 'Peach Neon',
|
name: 'Peach Neon',
|
||||||
colors: ['#ff9b73', '#5bf0a9', '#6ea8ff'],
|
colors: ['#ff9b73', '#5bf0a9', '#6ea8ff'],
|
||||||
backgroundColor: '#191716',
|
backgroundColor: '#191716',
|
||||||
|
|
@ -155,7 +155,7 @@ export const vibePresets: Array<VibePreset> = [
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'frost-bloom',
|
id: VibeId.FrostBloom,
|
||||||
name: 'Frost Bloom',
|
name: 'Frost Bloom',
|
||||||
colors: ['#b4f7ff', '#9ec8ff', '#ffb8d2'],
|
colors: ['#b4f7ff', '#9ec8ff', '#ffb8d2'],
|
||||||
backgroundColor: '#101820',
|
backgroundColor: '#101820',
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ vi.hoisted(() => {
|
||||||
const originalBrushSize = settings.brushSize;
|
const originalBrushSize = settings.brushSize;
|
||||||
const originalSelectedColorIndex = settings.selectedColorIndex;
|
const originalSelectedColorIndex = settings.selectedColorIndex;
|
||||||
const originalSpawnPerPixel = settings.spawnPerPixel;
|
const originalSpawnPerPixel = settings.spawnPerPixel;
|
||||||
|
const originalStrokeSpawnSpreadBrushSizeMultiplier =
|
||||||
|
settings.strokeSpawnSpreadBrushSizeMultiplier;
|
||||||
|
|
||||||
const createPopulation = () => {
|
const createPopulation = () => {
|
||||||
const pipeline = {
|
const pipeline = {
|
||||||
|
|
@ -51,12 +53,16 @@ describe('AgentPopulation adaptive budget', () => {
|
||||||
settings.brushSize = 1;
|
settings.brushSize = 1;
|
||||||
settings.selectedColorIndex = 0;
|
settings.selectedColorIndex = 0;
|
||||||
settings.spawnPerPixel = 1;
|
settings.spawnPerPixel = 1;
|
||||||
|
settings.strokeSpawnSpreadBrushSizeMultiplier = 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
settings.brushSize = originalBrushSize;
|
settings.brushSize = originalBrushSize;
|
||||||
settings.selectedColorIndex = originalSelectedColorIndex;
|
settings.selectedColorIndex = originalSelectedColorIndex;
|
||||||
settings.spawnPerPixel = originalSpawnPerPixel;
|
settings.spawnPerPixel = originalSpawnPerPixel;
|
||||||
|
settings.strokeSpawnSpreadBrushSizeMultiplier =
|
||||||
|
originalStrokeSpawnSpreadBrushSizeMultiplier;
|
||||||
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('expands beyond the 1M start cap only when new agents arrive under healthy FPS', () => {
|
it('expands beyond the 1M start cap only when new agents arrive under healthy FPS', () => {
|
||||||
|
|
@ -102,6 +108,25 @@ describe('AgentPopulation adaptive budget', () => {
|
||||||
expect(population.activeAgentCount).toBe(maxAgentCount);
|
expect(population.activeAgentCount).toBe(maxAgentCount);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('scales stroke spawn spread by device pixel ratio', () => {
|
||||||
|
settings.brushSize = 10;
|
||||||
|
const writeAgents = vi.fn();
|
||||||
|
const pipeline = {
|
||||||
|
maxAgentCount: 10_000_000,
|
||||||
|
writeAgents,
|
||||||
|
resizeAgents: vi.fn(),
|
||||||
|
compactAgents: vi.fn(),
|
||||||
|
} as unknown as AgentGenerationPipeline;
|
||||||
|
const population = new AgentPopulation(pipeline, 0, () => 2);
|
||||||
|
vi.spyOn(Math, 'random').mockReturnValue(1);
|
||||||
|
|
||||||
|
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(0, 0));
|
||||||
|
|
||||||
|
const firstBatch = writeAgents.mock.calls[0][1] as Float32Array;
|
||||||
|
expect(firstBatch[0]).toBe(10);
|
||||||
|
expect(firstBatch[1]).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
it('decreases the cap and active count slowly when FPS falls below the threshold', () => {
|
it('decreases the cap and active count slowly when FPS falls below the threshold', () => {
|
||||||
const population = createPopulation();
|
const population = createPopulation();
|
||||||
setPopulationActiveCount(population, 1_000_000);
|
setPopulationActiveCount(population, 1_000_000);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { vec2 } from 'gl-matrix';
|
||||||
import { appConfig } from '../config';
|
import { appConfig } from '../config';
|
||||||
import { AGENT_FLOAT_COUNT } from '../pipelines/agents/agent-generation/agent';
|
import { AGENT_FLOAT_COUNT } from '../pipelines/agents/agent-generation/agent';
|
||||||
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
|
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
|
||||||
|
import { getSafeDevicePixelRatio } from '../pipelines/brush/brush-pipeline';
|
||||||
import { settings } from '../settings';
|
import { settings } from '../settings';
|
||||||
import { createIntroTitleAgents } from './intro-title-agents';
|
import { createIntroTitleAgents } from './intro-title-agents';
|
||||||
|
|
||||||
|
|
@ -19,7 +20,8 @@ export class AgentPopulation {
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly pipeline: AgentGenerationPipeline,
|
private readonly pipeline: AgentGenerationPipeline,
|
||||||
private readonly introSeed = Math.floor(Math.random() * 0xffffffff)
|
private readonly introSeed = Math.floor(Math.random() * 0xffffffff),
|
||||||
|
private readonly getDevicePixelRatio = () => 1
|
||||||
) {
|
) {
|
||||||
this.adaptiveCap = this.clampAdaptiveCap(
|
this.adaptiveCap = this.clampAdaptiveCap(
|
||||||
appConfig.simulation.budget.adaptiveCapInitial
|
appConfig.simulation.budget.adaptiveCapInitial
|
||||||
|
|
@ -121,7 +123,10 @@ export class AgentPopulation {
|
||||||
baseAngle +
|
baseAngle +
|
||||||
(Math.random() - 0.5) * appConfig.simulation.stroke.angleJitterRadians;
|
(Math.random() - 0.5) * appConfig.simulation.stroke.angleJitterRadians;
|
||||||
const base = i * AGENT_FLOAT_COUNT;
|
const base = i * AGENT_FLOAT_COUNT;
|
||||||
const spread = settings.brushSize * settings.strokeSpawnSpreadBrushSizeMultiplier;
|
const spread =
|
||||||
|
settings.brushSize *
|
||||||
|
getSafeDevicePixelRatio(this.getDevicePixelRatio()) *
|
||||||
|
settings.strokeSpawnSpreadBrushSizeMultiplier;
|
||||||
this.strokeAgentData[base] = x + (Math.random() - 0.5) * spread;
|
this.strokeAgentData[base] = x + (Math.random() - 0.5) * spread;
|
||||||
this.strokeAgentData[base + 1] = y + (Math.random() - 0.5) * spread;
|
this.strokeAgentData[base + 1] = y + (Math.random() - 0.5) * spread;
|
||||||
this.strokeAgentData[base + 2] = angle;
|
this.strokeAgentData[base + 2] = angle;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { appConfig } from '../config';
|
import { appConfig } from '../config';
|
||||||
import { RenderPipeline } from '../pipelines/render/render-pipeline';
|
import { RenderPipeline } from '../pipelines/render/render-pipeline';
|
||||||
|
import type { VibeId } from '../vibes';
|
||||||
import {
|
import {
|
||||||
estimateExport4KMemory,
|
estimateExport4KMemory,
|
||||||
getAspectFitExport4KDimensions,
|
getAspectFitExport4KDimensions,
|
||||||
|
|
@ -15,7 +16,7 @@ interface Export4KRendererOptions {
|
||||||
getSourceSize: () => { width: number; height: number };
|
getSourceSize: () => { width: number; height: number };
|
||||||
getColorTextureView: () => GPUTextureView;
|
getColorTextureView: () => GPUTextureView;
|
||||||
getSourceTextureView: () => GPUTextureView;
|
getSourceTextureView: () => GPUTextureView;
|
||||||
getVibeId: () => string;
|
getVibeId: () => VibeId;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Export4KRenderer {
|
export class Export4KRenderer {
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ interface FrameParameters extends RenderInputs {
|
||||||
deltaTime: number;
|
deltaTime: number;
|
||||||
canvasSize: vec2;
|
canvasSize: vec2;
|
||||||
activeAgentCount: number;
|
activeAgentCount: number;
|
||||||
|
devicePixelRatio: number;
|
||||||
introProgress: number;
|
introProgress: number;
|
||||||
selectedColorIndex: number;
|
selectedColorIndex: number;
|
||||||
isErasing: boolean;
|
isErasing: boolean;
|
||||||
|
|
@ -99,6 +100,7 @@ export class GameLoopResources {
|
||||||
deltaTime,
|
deltaTime,
|
||||||
canvasSize,
|
canvasSize,
|
||||||
activeAgentCount,
|
activeAgentCount,
|
||||||
|
devicePixelRatio,
|
||||||
introProgress,
|
introProgress,
|
||||||
selectedColorIndex,
|
selectedColorIndex,
|
||||||
channelColors,
|
channelColors,
|
||||||
|
|
@ -123,6 +125,7 @@ export class GameLoopResources {
|
||||||
});
|
});
|
||||||
this.brushPipeline.setParameters({
|
this.brushPipeline.setParameters({
|
||||||
...settings,
|
...settings,
|
||||||
|
devicePixelRatio,
|
||||||
selectedColorIndex,
|
selectedColorIndex,
|
||||||
});
|
});
|
||||||
this.diffusionPipeline.setParameters(settings);
|
this.diffusionPipeline.setParameters(settings);
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,8 @@ export default class GameLoop {
|
||||||
this.toolbarContrastMonitor = new ToolbarContrastMonitor(canvas, ui.toolbar, device);
|
this.toolbarContrastMonitor = new ToolbarContrastMonitor(canvas, ui.toolbar, device);
|
||||||
this.agentPopulation = new AgentPopulation(
|
this.agentPopulation = new AgentPopulation(
|
||||||
this.resources.agentGenerationPipeline,
|
this.resources.agentGenerationPipeline,
|
||||||
this.seedValue
|
this.seedValue,
|
||||||
|
() => this.devicePixelRatio
|
||||||
);
|
);
|
||||||
this.agentPopulation.initializeIntroAgents(this.canvasSize);
|
this.agentPopulation.initializeIntroAgents(this.canvasSize);
|
||||||
this.pointerInput = new GardenPointerInput({
|
this.pointerInput = new GardenPointerInput({
|
||||||
|
|
@ -155,7 +156,8 @@ export default class GameLoop {
|
||||||
|
|
||||||
const { channelColors, backgroundColor } = this.renderInputs.get();
|
const { channelColors, backgroundColor } = this.renderInputs.get();
|
||||||
const introProgress = this.introPrompt.progress;
|
const introProgress = this.introPrompt.progress;
|
||||||
const eraserPixelSize = settings.eraserSize * this.devicePixelRatio;
|
const devicePixelRatio = this.devicePixelRatio;
|
||||||
|
const eraserPixelSize = settings.eraserSize * devicePixelRatio;
|
||||||
const isErasing = this.pointerInput.isEraseMode;
|
const isErasing = this.pointerInput.isEraseMode;
|
||||||
const accentColor = channelColors[settings.selectedColorIndex] ?? channelColors[0];
|
const accentColor = channelColors[settings.selectedColorIndex] ?? channelColors[0];
|
||||||
this.renderInputs.updateAccentColor(accentColor);
|
this.renderInputs.updateAccentColor(accentColor);
|
||||||
|
|
@ -169,6 +171,7 @@ export default class GameLoop {
|
||||||
deltaTime,
|
deltaTime,
|
||||||
canvasSize: this.canvasSize,
|
canvasSize: this.canvasSize,
|
||||||
activeAgentCount: this.agentPopulation.activeAgentCount,
|
activeAgentCount: this.agentPopulation.activeAgentCount,
|
||||||
|
devicePixelRatio,
|
||||||
introProgress,
|
introProgress,
|
||||||
selectedColorIndex: settings.selectedColorIndex,
|
selectedColorIndex: settings.selectedColorIndex,
|
||||||
isErasing,
|
isErasing,
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,9 @@ const makeSwipePipeline = () => ({
|
||||||
clearSwipes: vi.fn(),
|
clearSwipes: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const createPointerInput = async () => {
|
const createPointerInput = async ({
|
||||||
|
devicePixelRatio = 1,
|
||||||
|
}: { devicePixelRatio?: number } = {}) => {
|
||||||
const { GardenPointerInput } = await import('./pointer-input');
|
const { GardenPointerInput } = await import('./pointer-input');
|
||||||
const { settings: runtimeSettings } = await import('../settings');
|
const { settings: runtimeSettings } = await import('../settings');
|
||||||
const canvas = new FakeCanvas();
|
const canvas = new FakeCanvas();
|
||||||
|
|
@ -117,7 +119,7 @@ const createPointerInput = async () => {
|
||||||
eraserAgentPipeline,
|
eraserAgentPipeline,
|
||||||
eraserPreview,
|
eraserPreview,
|
||||||
eraserTexturePipeline,
|
eraserTexturePipeline,
|
||||||
getDevicePixelRatio: () => 1,
|
getDevicePixelRatio: () => devicePixelRatio,
|
||||||
getMirrorSegmentCount: () => 1,
|
getMirrorSegmentCount: () => 1,
|
||||||
onEraseGestureEnded,
|
onEraseGestureEnded,
|
||||||
onStartDrawing,
|
onStartDrawing,
|
||||||
|
|
@ -277,6 +279,30 @@ describe('GardenPointerInput drawing startup', () => {
|
||||||
expect(toPoint(stroke.to)).toEqual([40, 50]);
|
expect(toPoint(stroke.to)).toEqual([40, 50]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps pointer geometry in backing pixels on high-DPR canvases', async () => {
|
||||||
|
const { audio, brushPipeline, canvas } = await createPointerInput({
|
||||||
|
devicePixelRatio: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.dispatchPointerEvent('pointerdown', {
|
||||||
|
clientX: 10,
|
||||||
|
clientY: 20,
|
||||||
|
pointerId: 9,
|
||||||
|
timeStamp: 100,
|
||||||
|
});
|
||||||
|
canvas.dispatchPointerEvent('pointermove', {
|
||||||
|
clientX: 40,
|
||||||
|
clientY: 50,
|
||||||
|
pointerId: 9,
|
||||||
|
timeStamp: 150,
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstStroke = audio.stroke.mock.calls[0][0];
|
||||||
|
expect(toPoint(firstStroke.from)).toEqual([20, 40]);
|
||||||
|
expect(toPoint(firstStroke.to)).toEqual([80, 100]);
|
||||||
|
expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[0][0])).toEqual([20, 40]);
|
||||||
|
});
|
||||||
|
|
||||||
it('caps curve tessellation with the brush curve resolution setting', async () => {
|
it('caps curve tessellation with the brush curve resolution setting', async () => {
|
||||||
const { brushPipeline, canvas, runtimeSettings } = await createPointerInput();
|
const { brushPipeline, canvas, runtimeSettings } = await createPointerInput();
|
||||||
runtimeSettings.brushCurveResolution = 2;
|
runtimeSettings.brushCurveResolution = 2;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,10 @@ import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
import { GardenAudio } from '../audio/garden-audio';
|
import { GardenAudio } from '../audio/garden-audio';
|
||||||
import { appConfig } from '../config';
|
import { appConfig } from '../config';
|
||||||
import { BrushPipeline } from '../pipelines/brush/brush-pipeline';
|
import {
|
||||||
|
BrushPipeline,
|
||||||
|
getSafeDevicePixelRatio,
|
||||||
|
} from '../pipelines/brush/brush-pipeline';
|
||||||
import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline';
|
import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline';
|
||||||
import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline';
|
import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline';
|
||||||
import { activeVibe, settings } from '../settings';
|
import { activeVibe, settings } from '../settings';
|
||||||
|
|
@ -201,7 +204,9 @@ export class GardenPointerInput {
|
||||||
|
|
||||||
private getCanvasPointerPosition(event: PointerEvent): vec2 {
|
private getCanvasPointerPosition(event: PointerEvent): vec2 {
|
||||||
const rect = this.canvas.getBoundingClientRect();
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
const devicePixelRatio = this.options.getDevicePixelRatio();
|
const devicePixelRatio = getSafeDevicePixelRatio(
|
||||||
|
this.options.getDevicePixelRatio()
|
||||||
|
);
|
||||||
return vec2.fromValues(
|
return vec2.fromValues(
|
||||||
(event.clientX - rect.left) * devicePixelRatio,
|
(event.clientX - rect.left) * devicePixelRatio,
|
||||||
(event.clientY - rect.top) * devicePixelRatio
|
(event.clientY - rect.top) * devicePixelRatio
|
||||||
|
|
@ -213,7 +218,8 @@ export class GardenPointerInput {
|
||||||
this.smoothedStrokePoints[this.smoothedStrokePoints.length - 1];
|
this.smoothedStrokePoints[this.smoothedStrokePoints.length - 1];
|
||||||
if (
|
if (
|
||||||
previousSample !== undefined &&
|
previousSample !== undefined &&
|
||||||
vec2.squaredDistance(previousSample, position) <= getBrushSmoothingDistanceSquared()
|
vec2.squaredDistance(previousSample, position) <=
|
||||||
|
getBrushSmoothingDistanceSquared(this.options.getDevicePixelRatio())
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -247,12 +253,13 @@ export class GardenPointerInput {
|
||||||
|
|
||||||
private addQuadraticBrushSegments(start: vec2, control: vec2, end: vec2): void {
|
private addQuadraticBrushSegments(start: vec2, control: vec2, end: vec2): void {
|
||||||
const curveLength = vec2.distance(start, control) + vec2.distance(control, end);
|
const curveLength = vec2.distance(start, control) + vec2.distance(control, end);
|
||||||
|
const devicePixelRatio = getSafeDevicePixelRatio(this.options.getDevicePixelRatio());
|
||||||
const brushRadius = Math.max(
|
const brushRadius = Math.max(
|
||||||
settings.brushCurveMinBrushRadius,
|
settings.brushCurveMinBrushRadius * devicePixelRatio,
|
||||||
settings.brushSize / 2
|
(settings.brushSize * devicePixelRatio) / 2
|
||||||
);
|
);
|
||||||
const segmentSpacing = Math.max(
|
const segmentSpacing = Math.max(
|
||||||
settings.brushCurveMinSegmentSpacing,
|
settings.brushCurveMinSegmentSpacing * devicePixelRatio,
|
||||||
brushRadius * settings.brushCurveSegmentBrushRadiusRatio
|
brushRadius * settings.brushCurveSegmentBrushRadiusRatio
|
||||||
);
|
);
|
||||||
const mirrorSegmentCount = Math.max(1, this.options.getMirrorSegmentCount());
|
const mirrorSegmentCount = Math.max(1, this.options.getMirrorSegmentCount());
|
||||||
|
|
@ -292,7 +299,7 @@ export class GardenPointerInput {
|
||||||
if (
|
if (
|
||||||
this.lastSmoothedBrushPosition !== null &&
|
this.lastSmoothedBrushPosition !== null &&
|
||||||
vec2.squaredDistance(this.lastSmoothedBrushPosition, finalSample) >
|
vec2.squaredDistance(this.lastSmoothedBrushPosition, finalSample) >
|
||||||
getBrushSmoothingDistanceSquared()
|
getBrushSmoothingDistanceSquared(this.options.getDevicePixelRatio())
|
||||||
) {
|
) {
|
||||||
this.addMirroredBrushSegment(this.lastSmoothedBrushPosition, finalSample);
|
this.addMirroredBrushSegment(this.lastSmoothedBrushPosition, finalSample);
|
||||||
}
|
}
|
||||||
|
|
@ -374,11 +381,11 @@ const getBrushCurveResolution = (): number => {
|
||||||
return Math.max(1, Math.floor(resolution));
|
return Math.max(1, Math.floor(resolution));
|
||||||
};
|
};
|
||||||
|
|
||||||
const getBrushSmoothingDistanceSquared = (): number => {
|
const getBrushSmoothingDistanceSquared = (devicePixelRatio?: number): number => {
|
||||||
const distance = Number.isFinite(settings.brushSmoothingMinSampleDistance)
|
const distance = Number.isFinite(settings.brushSmoothingMinSampleDistance)
|
||||||
? settings.brushSmoothingMinSampleDistance
|
? settings.brushSmoothingMinSampleDistance
|
||||||
: appConfig.defaultSettings.brushSmoothingMinSampleDistance;
|
: appConfig.defaultSettings.brushSmoothingMinSampleDistance;
|
||||||
return Math.max(0, distance) ** 2;
|
return Math.max(0, distance * getSafeDevicePixelRatio(devicePixelRatio)) ** 2;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isSamePointerSample = (left: PointerEvent, right: PointerEvent): boolean =>
|
const isSamePointerSample = (left: PointerEvent, right: PointerEvent): boolean =>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { activeVibe } from '../settings';
|
import { activeVibe } from '../settings';
|
||||||
import { hexToRgb } from '../vibes';
|
import { hexToRgb, type VibeId } from '../vibes';
|
||||||
import { RenderInputs } from './game-loop-types';
|
import { RenderInputs } from './game-loop-types';
|
||||||
|
|
||||||
export class RenderInputCache {
|
export class RenderInputCache {
|
||||||
private cachedVibeId: string | null = null;
|
private cachedVibeId: VibeId | null = null;
|
||||||
private cachedRenderInputs?: RenderInputs;
|
private cachedRenderInputs?: RenderInputs;
|
||||||
private previousAccentColor = '';
|
private previousAccentColor = '';
|
||||||
|
|
||||||
|
|
|
||||||
64
src/index.ts
64
src/index.ts
|
|
@ -308,38 +308,6 @@ const main = async () => {
|
||||||
elements.infoElement,
|
elements.infoElement,
|
||||||
elements.aside
|
elements.aside
|
||||||
);
|
);
|
||||||
configPane = new ConfigPane({
|
|
||||||
settingsButton: elements.settingsButton,
|
|
||||||
onConfigChange: () => {
|
|
||||||
game?.onVibeChanged();
|
|
||||||
syncRuntimeUi();
|
|
||||||
},
|
|
||||||
onOpenChange: () => undefined,
|
|
||||||
onRuntimeChange: syncRuntimeUi,
|
|
||||||
onRuntimeReset: () => {
|
|
||||||
resetSettings();
|
|
||||||
game?.onVibeChanged();
|
|
||||||
syncRuntimeUi();
|
|
||||||
},
|
|
||||||
onRestart: () => game?.destroy(),
|
|
||||||
onVibeChange: (vibeId) => {
|
|
||||||
const vibe = VIBE_PRESETS.find((candidate) => candidate.id === vibeId);
|
|
||||||
if (!vibe) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const activePreset = applyVibeSettings(vibe);
|
|
||||||
trackVibeChange({
|
|
||||||
vibeId: activePreset.id,
|
|
||||||
vibeName: activePreset.name,
|
|
||||||
source: 'settings',
|
|
||||||
});
|
|
||||||
game?.onVibeChanged();
|
|
||||||
syncRuntimeUi();
|
|
||||||
game?.playVibeChangeAudio(false);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
infoPageHandler.onOpen = configPane.close.bind(configPane);
|
|
||||||
|
|
||||||
new MenuHider(
|
new MenuHider(
|
||||||
elements.aside,
|
elements.aside,
|
||||||
|
|
@ -488,6 +456,38 @@ const main = async () => {
|
||||||
});
|
});
|
||||||
setLoadingStage('Connecting to GPU…', 0.1);
|
setLoadingStage('Connecting to GPU…', 0.1);
|
||||||
const gpu = await initializeGpu();
|
const gpu = await initializeGpu();
|
||||||
|
configPane = new ConfigPane({
|
||||||
|
settingsButton: elements.settingsButton,
|
||||||
|
onConfigChange: () => {
|
||||||
|
game?.onVibeChanged();
|
||||||
|
syncRuntimeUi();
|
||||||
|
},
|
||||||
|
onOpenChange: () => undefined,
|
||||||
|
onRuntimeChange: syncRuntimeUi,
|
||||||
|
onRuntimeReset: () => {
|
||||||
|
resetSettings();
|
||||||
|
game?.onVibeChanged();
|
||||||
|
syncRuntimeUi();
|
||||||
|
},
|
||||||
|
onRestart: () => game?.destroy(),
|
||||||
|
onVibeChange: (vibeId) => {
|
||||||
|
const vibe = VIBE_PRESETS.find((candidate) => candidate.id === vibeId);
|
||||||
|
if (!vibe) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activePreset = applyVibeSettings(vibe);
|
||||||
|
trackVibeChange({
|
||||||
|
vibeId: activePreset.id,
|
||||||
|
vibeName: activePreset.name,
|
||||||
|
source: 'settings',
|
||||||
|
});
|
||||||
|
game?.onVibeChanged();
|
||||||
|
syncRuntimeUi();
|
||||||
|
game?.playVibeChangeAudio(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
infoPageHandler.onOpen = configPane.close.bind(configPane);
|
||||||
setLoadingStage('Loading fonts…', 0.3);
|
setLoadingStage('Loading fonts…', 0.3);
|
||||||
await fontsReady;
|
await fontsReady;
|
||||||
setLoadingStage('Loading piano samples…', 0.45);
|
setLoadingStage('Loading piano samples…', 0.45);
|
||||||
|
|
|
||||||
137
src/page/collapsible-panel-animator.test.ts
Normal file
137
src/page/collapsible-panel-animator.test.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { CollapsiblePanelAnimator } from './collapsible-panel-animator';
|
||||||
|
|
||||||
|
type Listener = (event: Record<string, unknown>) => void;
|
||||||
|
|
||||||
|
class FakeClassList {
|
||||||
|
private readonly classes = new Set<string>();
|
||||||
|
|
||||||
|
public add(className: string): void {
|
||||||
|
this.classes.add(className);
|
||||||
|
}
|
||||||
|
|
||||||
|
public contains(className: string): boolean {
|
||||||
|
return this.classes.has(className);
|
||||||
|
}
|
||||||
|
|
||||||
|
public remove(className: string): void {
|
||||||
|
this.classes.delete(className);
|
||||||
|
}
|
||||||
|
|
||||||
|
public toggle(className: string, force?: boolean): boolean {
|
||||||
|
const shouldAdd = force ?? !this.classes.has(className);
|
||||||
|
if (shouldAdd) {
|
||||||
|
this.add(className);
|
||||||
|
} else {
|
||||||
|
this.remove(className);
|
||||||
|
}
|
||||||
|
return shouldAdd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeElement {
|
||||||
|
public readonly classList = new FakeClassList();
|
||||||
|
public inert = false;
|
||||||
|
|
||||||
|
private readonly attributes = new Map<string, string>();
|
||||||
|
private readonly children = new Set<FakeElement>();
|
||||||
|
private readonly listeners = new Map<string, Array<Listener>>();
|
||||||
|
|
||||||
|
public addChild(child: FakeElement): void {
|
||||||
|
this.children.add(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
public addEventListener(type: string, listener: Listener): void {
|
||||||
|
this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public contains(target: unknown): boolean {
|
||||||
|
return target === this || this.children.has(target as FakeElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
public dispatch(type: string, event: Record<string, unknown> = {}): void {
|
||||||
|
this.listeners.get(type)?.forEach((listener) => listener({ target: this, ...event }));
|
||||||
|
}
|
||||||
|
|
||||||
|
public focus(): void {
|
||||||
|
fakeDocument.activeElement = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getAttribute(name: string): string | null {
|
||||||
|
return this.attributes.get(name) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setAttribute(name: string, value: string): void {
|
||||||
|
this.attributes.set(name, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const windowListeners = new Map<string, Array<Listener>>();
|
||||||
|
const fakeDocument: { activeElement: FakeElement | null } = {
|
||||||
|
activeElement: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const dispatchWindowEvent = (type: string, event: Record<string, unknown> = {}) => {
|
||||||
|
windowListeners.get(type)?.forEach((listener) => listener(event));
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('CollapsiblePanelAnimator', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
windowListeners.clear();
|
||||||
|
fakeDocument.activeElement = null;
|
||||||
|
vi.stubGlobal('HTMLElement', FakeElement);
|
||||||
|
vi.stubGlobal('document', fakeDocument);
|
||||||
|
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||||
|
callback(0);
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
vi.stubGlobal('window', {
|
||||||
|
addEventListener: (type: string, listener: Listener) => {
|
||||||
|
windowListeners.set(type, [...(windowListeners.get(type) ?? []), listener]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('syncs About panel accessibility when toggled and closed with Escape', () => {
|
||||||
|
const button = new FakeElement();
|
||||||
|
const panel = new FakeElement();
|
||||||
|
const dock = new FakeElement();
|
||||||
|
dock.addChild(button);
|
||||||
|
dock.addChild(panel);
|
||||||
|
|
||||||
|
new CollapsiblePanelAnimator(
|
||||||
|
button as unknown as HTMLButtonElement,
|
||||||
|
panel as unknown as HTMLElement,
|
||||||
|
dock as unknown as HTMLElement
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(button.getAttribute('aria-expanded')).toBe('false');
|
||||||
|
expect(panel.getAttribute('aria-hidden')).toBe('true');
|
||||||
|
expect(panel.inert).toBe(true);
|
||||||
|
expect(panel.classList.contains('hidden')).toBe(true);
|
||||||
|
|
||||||
|
fakeDocument.activeElement = button;
|
||||||
|
button.dispatch('click');
|
||||||
|
|
||||||
|
expect(button.getAttribute('aria-expanded')).toBe('true');
|
||||||
|
expect(button.classList.contains('active')).toBe(true);
|
||||||
|
expect(panel.getAttribute('aria-hidden')).toBe('false');
|
||||||
|
expect(panel.inert).toBe(false);
|
||||||
|
expect(panel.classList.contains('hidden')).toBe(false);
|
||||||
|
expect(fakeDocument.activeElement).toBe(panel);
|
||||||
|
|
||||||
|
const preventDefault = vi.fn();
|
||||||
|
dispatchWindowEvent('keydown', { key: 'Escape', preventDefault });
|
||||||
|
|
||||||
|
expect(preventDefault).toHaveBeenCalledOnce();
|
||||||
|
expect(button.getAttribute('aria-expanded')).toBe('false');
|
||||||
|
expect(panel.getAttribute('aria-hidden')).toBe('true');
|
||||||
|
expect(panel.inert).toBe(true);
|
||||||
|
expect(fakeDocument.activeElement).toBe(button);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -7,7 +7,7 @@ import {
|
||||||
type NumberControlConfig,
|
type NumberControlConfig,
|
||||||
} from '../config';
|
} from '../config';
|
||||||
import { activeVibe, settings } from '../settings';
|
import { activeVibe, settings } from '../settings';
|
||||||
import { VIBE_PRESETS } from '../vibes';
|
import { isVibeId, VIBE_PRESETS, type VibeId } from '../vibes';
|
||||||
|
|
||||||
type PaneContainer = Pick<FolderApi, 'addBinding' | 'addButton' | 'addFolder'>;
|
type PaneContainer = Pick<FolderApi, 'addBinding' | 'addButton' | 'addFolder'>;
|
||||||
type ColorReactionKey = (typeof colorReactionRows)[number]['keys'][number];
|
type ColorReactionKey = (typeof colorReactionRows)[number]['keys'][number];
|
||||||
|
|
@ -43,7 +43,7 @@ interface ConfigPaneOptions {
|
||||||
onRestart: () => void;
|
onRestart: () => void;
|
||||||
onRuntimeChange: () => void;
|
onRuntimeChange: () => void;
|
||||||
onRuntimeReset: () => void;
|
onRuntimeReset: () => void;
|
||||||
onVibeChange: (vibeId: string) => void;
|
onVibeChange: (vibeId: VibeId) => void;
|
||||||
settingsButton: HTMLButtonElement;
|
settingsButton: HTMLButtonElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -97,7 +97,7 @@ export class ConfigPane {
|
||||||
colorIndex: number;
|
colorIndex: number;
|
||||||
element: HTMLElement;
|
element: HTMLElement;
|
||||||
}> = [];
|
}> = [];
|
||||||
private readonly state = {
|
private readonly state: { activeVibeId: VibeId } = {
|
||||||
activeVibeId: activeVibe.id,
|
activeVibeId: activeVibe.id,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -172,9 +172,13 @@ export class ConfigPane {
|
||||||
label: 'active vibe',
|
label: 'active vibe',
|
||||||
options: Object.fromEntries(
|
options: Object.fromEntries(
|
||||||
VIBE_PRESETS.map((vibe) => [vibe.name, vibe.id])
|
VIBE_PRESETS.map((vibe) => [vibe.name, vibe.id])
|
||||||
) as Record<string, string>,
|
) as Record<string, VibeId>,
|
||||||
})
|
})
|
||||||
.on('change', ({ value }) => {
|
.on('change', ({ value }) => {
|
||||||
|
if (!isVibeId(value)) {
|
||||||
|
this.refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.options.onVibeChange(value);
|
this.options.onVibeChange(value);
|
||||||
this.refresh();
|
this.refresh();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
138
src/page/menu-hider.test.ts
Normal file
138
src/page/menu-hider.test.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { appConfig } from '../config';
|
||||||
|
import { MenuHider } from './menu-hider';
|
||||||
|
|
||||||
|
type Listener<T = Record<string, unknown>> = (event: T) => void;
|
||||||
|
|
||||||
|
class FakeClassList {
|
||||||
|
private readonly classes = new Set<string>();
|
||||||
|
|
||||||
|
public add(className: string): void {
|
||||||
|
this.classes.add(className);
|
||||||
|
}
|
||||||
|
|
||||||
|
public contains(className: string): boolean {
|
||||||
|
return this.classes.has(className);
|
||||||
|
}
|
||||||
|
|
||||||
|
public remove(className: string): void {
|
||||||
|
this.classes.delete(className);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeDockElement {
|
||||||
|
public readonly classList = new FakeClassList();
|
||||||
|
public inert = false;
|
||||||
|
|
||||||
|
private readonly attributes = new Map<string, string>();
|
||||||
|
private readonly listeners = new Map<string, Array<Listener>>();
|
||||||
|
|
||||||
|
public addEventListener(type: string, listener: Listener): void {
|
||||||
|
this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public contains(target: unknown): boolean {
|
||||||
|
return target === this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public dispatch(type: string, event: Record<string, unknown> = {}): void {
|
||||||
|
this.listeners.get(type)?.forEach((listener) => listener(event));
|
||||||
|
}
|
||||||
|
|
||||||
|
public getAttribute(name: string): string | null {
|
||||||
|
return this.attributes.get(name) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getBoundingClientRect(): DOMRect {
|
||||||
|
return {
|
||||||
|
bottom: 720,
|
||||||
|
height: 120,
|
||||||
|
left: 0,
|
||||||
|
right: 1280,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
top: 600,
|
||||||
|
width: 1280,
|
||||||
|
x: 0,
|
||||||
|
y: 600,
|
||||||
|
} as DOMRect;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setAttribute(name: string, value: string): void {
|
||||||
|
this.attributes.set(name, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const windowListeners = new Map<string, Array<Listener>>();
|
||||||
|
let isDesktop = true;
|
||||||
|
|
||||||
|
const dispatchWindowEvent = <T extends Record<string, unknown>>(
|
||||||
|
type: string,
|
||||||
|
event: T
|
||||||
|
) => {
|
||||||
|
windowListeners.get(type)?.forEach((listener) => listener(event));
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('MenuHider', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
windowListeners.clear();
|
||||||
|
isDesktop = true;
|
||||||
|
|
||||||
|
vi.stubGlobal('document', {
|
||||||
|
activeElement: null,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
documentElement: {
|
||||||
|
clientHeight: 720,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vi.stubGlobal('window', {
|
||||||
|
addEventListener: (type: string, listener: Listener) => {
|
||||||
|
windowListeners.set(type, [...(windowListeners.get(type) ?? []), listener]);
|
||||||
|
},
|
||||||
|
clearTimeout,
|
||||||
|
innerHeight: 720,
|
||||||
|
matchMedia: () => ({
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
matches: isDesktop,
|
||||||
|
}),
|
||||||
|
setTimeout,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the dock after the desktop fullscreen pointer leaves it', () => {
|
||||||
|
const dock = new FakeDockElement();
|
||||||
|
|
||||||
|
new MenuHider(dock as unknown as HTMLElement, () => true);
|
||||||
|
dock.dispatch('pointerleave');
|
||||||
|
vi.advanceTimersByTime(appConfig.menuHider.hideDelayMs);
|
||||||
|
|
||||||
|
expect(dock.classList.contains('menu-hidden')).toBe(true);
|
||||||
|
expect(dock.getAttribute('aria-hidden')).toBe('true');
|
||||||
|
expect(dock.inert).toBe(true);
|
||||||
|
|
||||||
|
dispatchWindowEvent('pointermove', { clientX: 640, clientY: 710 });
|
||||||
|
|
||||||
|
expect(dock.classList.contains('menu-hidden')).toBe(false);
|
||||||
|
expect(dock.getAttribute('aria-hidden')).toBe('false');
|
||||||
|
expect(dock.inert).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the dock visible outside the desktop auto-hide breakpoint', () => {
|
||||||
|
isDesktop = false;
|
||||||
|
const dock = new FakeDockElement();
|
||||||
|
|
||||||
|
new MenuHider(dock as unknown as HTMLElement, () => true);
|
||||||
|
dock.dispatch('pointerleave');
|
||||||
|
vi.advanceTimersByTime(appConfig.menuHider.hideDelayMs);
|
||||||
|
|
||||||
|
expect(dock.classList.contains('menu-hidden')).toBe(false);
|
||||||
|
expect(dock.getAttribute('aria-hidden')).toBe('false');
|
||||||
|
expect(dock.inert).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
45
src/pipelines/brush/brush-pipeline.test.ts
Normal file
45
src/pipelines/brush/brush-pipeline.test.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { getSafeDevicePixelRatio, setBrushUniformValues } from './brush-pipeline';
|
||||||
|
|
||||||
|
const brushSettings = {
|
||||||
|
brushAlpha: 0.75,
|
||||||
|
brushCoarseNoiseScale: 100,
|
||||||
|
brushDiscardThreshold: 0.02,
|
||||||
|
brushFeatherRatio: 0.25,
|
||||||
|
brushGrainMaxStrength: 1,
|
||||||
|
brushGrainMinStrength: 0.4,
|
||||||
|
brushGrainNoiseOffsetX: 0.1,
|
||||||
|
brushGrainNoiseOffsetY: 0.2,
|
||||||
|
brushGrainNoiseScale: 25,
|
||||||
|
brushMinimumFeather: 2,
|
||||||
|
brushSize: 10,
|
||||||
|
brushSizeVariation: 0.5,
|
||||||
|
selectedColorIndex: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('brush pipeline parameters', () => {
|
||||||
|
it('scales pixel-space brush uniforms by device pixel ratio', () => {
|
||||||
|
const uniformValues = new Float32Array(16);
|
||||||
|
|
||||||
|
setBrushUniformValues(uniformValues, {
|
||||||
|
...brushSettings,
|
||||||
|
devicePixelRatio: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(uniformValues[0]).toBe(10);
|
||||||
|
expect(uniformValues[1]).toBe(5);
|
||||||
|
expect(uniformValues[3]).toBe(4);
|
||||||
|
expect(uniformValues[5]).toBe(1);
|
||||||
|
expect(uniformValues[8]).toBe(200);
|
||||||
|
expect(uniformValues[9]).toBe(50);
|
||||||
|
expect(uniformValues[15]).toBe(19);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a 1x pixel ratio for invalid values', () => {
|
||||||
|
expect(getSafeDevicePixelRatio(0)).toBe(1);
|
||||||
|
expect(getSafeDevicePixelRatio(Number.NaN)).toBe(1);
|
||||||
|
expect(getSafeDevicePixelRatio(undefined)).toBe(1);
|
||||||
|
expect(getSafeDevicePixelRatio(1.5)).toBe(1.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -15,6 +15,66 @@ interface LineSegment {
|
||||||
to: vec2;
|
to: vec2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BrushParameterSettings extends BrushSettings {
|
||||||
|
devicePixelRatio?: number;
|
||||||
|
selectedColorIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getSafeDevicePixelRatio = (devicePixelRatio: number | undefined): number =>
|
||||||
|
typeof devicePixelRatio === 'number' &&
|
||||||
|
Number.isFinite(devicePixelRatio) &&
|
||||||
|
devicePixelRatio > 0
|
||||||
|
? devicePixelRatio
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
export const setBrushUniformValues = (
|
||||||
|
target: Float32Array,
|
||||||
|
{
|
||||||
|
brushSize,
|
||||||
|
brushSizeVariation,
|
||||||
|
brushAlpha,
|
||||||
|
brushFeatherRatio,
|
||||||
|
brushMinimumFeather,
|
||||||
|
brushDiscardThreshold,
|
||||||
|
brushCoarseNoiseScale,
|
||||||
|
brushGrainNoiseScale,
|
||||||
|
brushGrainNoiseOffsetX,
|
||||||
|
brushGrainNoiseOffsetY,
|
||||||
|
brushGrainMinStrength,
|
||||||
|
brushGrainMaxStrength,
|
||||||
|
selectedColorIndex,
|
||||||
|
devicePixelRatio,
|
||||||
|
}: BrushParameterSettings
|
||||||
|
): void => {
|
||||||
|
const pixelRatio = getSafeDevicePixelRatio(devicePixelRatio);
|
||||||
|
const brushRadius = (brushSize * pixelRatio) / 2;
|
||||||
|
const brushRadiusVariation = Math.floor(brushRadius * brushSizeVariation);
|
||||||
|
const brushMinimumFeatherPixels = brushMinimumFeather * pixelRatio;
|
||||||
|
const brushFeather = Math.max(
|
||||||
|
brushMinimumFeatherPixels,
|
||||||
|
brushRadius * brushFeatherRatio
|
||||||
|
);
|
||||||
|
const brushGeometryRadius =
|
||||||
|
brushRadius + Math.max(0, brushRadiusVariation) + brushFeather;
|
||||||
|
|
||||||
|
target[0] = brushRadius;
|
||||||
|
target[1] = brushRadiusVariation;
|
||||||
|
target[2] = brushFeatherRatio;
|
||||||
|
target[3] = brushMinimumFeatherPixels;
|
||||||
|
target[4] = selectedColorIndex === 0 ? 1 : 0;
|
||||||
|
target[5] = selectedColorIndex === 1 ? 1 : 0;
|
||||||
|
target[6] = selectedColorIndex === 2 ? 1 : 0;
|
||||||
|
target[7] = brushAlpha;
|
||||||
|
target[8] = brushCoarseNoiseScale * pixelRatio;
|
||||||
|
target[9] = brushGrainNoiseScale * pixelRatio;
|
||||||
|
target[10] = brushGrainNoiseOffsetX;
|
||||||
|
target[11] = brushGrainNoiseOffsetY;
|
||||||
|
target[12] = brushDiscardThreshold;
|
||||||
|
target[13] = brushGrainMinStrength;
|
||||||
|
target[14] = brushGrainMaxStrength;
|
||||||
|
target[15] = brushGeometryRadius;
|
||||||
|
};
|
||||||
|
|
||||||
export class BrushPipeline {
|
export class BrushPipeline {
|
||||||
private static readonly UNIFORM_COUNT = 16;
|
private static readonly UNIFORM_COUNT = 16;
|
||||||
private static readonly MAX_LINE_COUNT = appConfig.pipelines.brush.maxLineCount;
|
private static readonly MAX_LINE_COUNT = appConfig.pipelines.brush.maxLineCount;
|
||||||
|
|
@ -87,43 +147,8 @@ export class BrushPipeline {
|
||||||
this.actualSegments.length = 0;
|
this.actualSegments.length = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public setParameters({
|
public setParameters(parameters: BrushParameterSettings) {
|
||||||
brushSize,
|
setBrushUniformValues(this.uniformValues, parameters);
|
||||||
brushSizeVariation,
|
|
||||||
brushAlpha,
|
|
||||||
brushFeatherRatio,
|
|
||||||
brushMinimumFeather,
|
|
||||||
brushDiscardThreshold,
|
|
||||||
brushCoarseNoiseScale,
|
|
||||||
brushGrainNoiseScale,
|
|
||||||
brushGrainNoiseOffsetX,
|
|
||||||
brushGrainNoiseOffsetY,
|
|
||||||
brushGrainMinStrength,
|
|
||||||
brushGrainMaxStrength,
|
|
||||||
selectedColorIndex,
|
|
||||||
}: BrushSettings & { selectedColorIndex: number }) {
|
|
||||||
const brushRadius = brushSize / 2;
|
|
||||||
const brushRadiusVariation = Math.floor(brushRadius * brushSizeVariation);
|
|
||||||
const brushFeather = Math.max(brushMinimumFeather, brushRadius * brushFeatherRatio);
|
|
||||||
const brushGeometryRadius =
|
|
||||||
brushRadius + Math.max(0, brushRadiusVariation) + brushFeather;
|
|
||||||
|
|
||||||
this.uniformValues[0] = brushRadius;
|
|
||||||
this.uniformValues[1] = brushRadiusVariation;
|
|
||||||
this.uniformValues[2] = brushFeatherRatio;
|
|
||||||
this.uniformValues[3] = brushMinimumFeather;
|
|
||||||
this.uniformValues[4] = selectedColorIndex === 0 ? 1 : 0;
|
|
||||||
this.uniformValues[5] = selectedColorIndex === 1 ? 1 : 0;
|
|
||||||
this.uniformValues[6] = selectedColorIndex === 2 ? 1 : 0;
|
|
||||||
this.uniformValues[7] = brushAlpha;
|
|
||||||
this.uniformValues[8] = brushCoarseNoiseScale;
|
|
||||||
this.uniformValues[9] = brushGrainNoiseScale;
|
|
||||||
this.uniformValues[10] = brushGrainNoiseOffsetX;
|
|
||||||
this.uniformValues[11] = brushGrainNoiseOffsetY;
|
|
||||||
this.uniformValues[12] = brushDiscardThreshold;
|
|
||||||
this.uniformValues[13] = brushGrainMinStrength;
|
|
||||||
this.uniformValues[14] = brushGrainMaxStrength;
|
|
||||||
this.uniformValues[15] = brushGeometryRadius;
|
|
||||||
writeFloat32BufferIfChanged(
|
writeFloat32BufferIfChanged(
|
||||||
this.device,
|
this.device,
|
||||||
this.uniforms,
|
this.uniforms,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { gardenAudioConfig } from './audio/garden-audio-config';
|
import { gardenAudioConfig } from './audio/garden-audio-config';
|
||||||
import { getInitialVibe, hexToRgb, VIBE_PRESETS } from './vibes';
|
import { getInitialVibe, hexToRgb, VIBE_PRESETS, VibeId } from './vibes';
|
||||||
|
|
||||||
const originalLocalStorage = globalThis.localStorage;
|
const originalLocalStorage = globalThis.localStorage;
|
||||||
|
|
||||||
|
|
@ -29,9 +29,9 @@ describe('vibe selection', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses a valid stored vibe id', () => {
|
it('uses a valid stored vibe id', () => {
|
||||||
setBrowserVibeState({ storedVibeId: 'sunlit-moss' });
|
setBrowserVibeState({ storedVibeId: VibeId.SunlitMoss });
|
||||||
|
|
||||||
expect(getInitialVibe().id).toBe('sunlit-moss');
|
expect(getInitialVibe().id).toBe(VibeId.SunlitMoss);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to the default preset for an unknown stored vibe id', () => {
|
it('falls back to the default preset for an unknown stored vibe id', () => {
|
||||||
|
|
|
||||||
19
src/vibes.ts
19
src/vibes.ts
|
|
@ -1,9 +1,11 @@
|
||||||
import { appConfig, type VibePreset } from './config';
|
import { appConfig, type VibeId, type VibePreset } from './config';
|
||||||
import { readBrowserStorage } from './utils/browser-storage';
|
import { readBrowserStorage } from './utils/browser-storage';
|
||||||
|
|
||||||
|
export { VibeId } from './config';
|
||||||
export type { VibePreset } from './config';
|
export type { VibePreset } from './config';
|
||||||
|
|
||||||
export const VIBE_PRESETS: Array<VibePreset> = appConfig.vibes.presets;
|
export const VIBE_PRESETS: Array<VibePreset> = appConfig.vibes.presets;
|
||||||
|
const VIBE_IDS = new Set<VibeId>(VIBE_PRESETS.map((vibe) => vibe.id));
|
||||||
|
|
||||||
const HEX_COLOR_PATTERN =
|
const HEX_COLOR_PATTERN =
|
||||||
/^#?(?<red>[0-9a-f]{2})(?<green>[0-9a-f]{2})(?<blue>[0-9a-f]{2})$/i;
|
/^#?(?<red>[0-9a-f]{2})(?<green>[0-9a-f]{2})(?<blue>[0-9a-f]{2})$/i;
|
||||||
|
|
@ -18,11 +20,14 @@ export const hexToRgb = (hex: string): [number, number, number] => {
|
||||||
return [parseInt(red, 16) / 255, parseInt(green, 16) / 255, parseInt(blue, 16) / 255];
|
return [parseInt(red, 16) / 255, parseInt(green, 16) / 255, parseInt(blue, 16) / 255];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isVibeId = (value: unknown): value is VibeId =>
|
||||||
|
typeof value === 'string' && VIBE_IDS.has(value as VibeId);
|
||||||
|
|
||||||
export const getInitialVibe = (): VibePreset => {
|
export const getInitialVibe = (): VibePreset => {
|
||||||
const id = readBrowserStorage(appConfig.storage.vibeKey);
|
const storedVibeId = readBrowserStorage(appConfig.storage.vibeKey);
|
||||||
return (
|
const initialVibeId = isVibeId(storedVibeId)
|
||||||
VIBE_PRESETS.find((vibe) => vibe.id === id) ??
|
? storedVibeId
|
||||||
VIBE_PRESETS.find((vibe) => vibe.id === appConfig.vibes.defaultVibeId) ??
|
: appConfig.vibes.defaultVibeId;
|
||||||
VIBE_PRESETS[0]
|
|
||||||
);
|
return VIBE_PRESETS.find((vibe) => vibe.id === initialVibeId) ?? VIBE_PRESETS[0];
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue