Fleeting Garden
- Pick a vibe palette, draw with one of the three colours, and agents grow
- organic paths from your strokes.
+ A living sketchpad where each stroke becomes a trail that agents follow,
+ branch from, and weave into the scene.
- Your drawn paths persist until you erase them. Switching vibes recolours the
- whole garden without clearing the scene.
+ Paint with the three colour swatches, carve space with the eraser, and raise
+ the mirror control when you want radial patterns instead of a single line.
- Runs entirely on your GPU via WebGPU compute shaders — no servers, no
- tracking, no analytics. Source on
+ Switch vibes to recolour the whole garden without clearing your drawing. Add
+ or mute the generated piano, restart for a blank canvas, or export the current
+ frame as a 4K image.
+
+
+ Built with WebGPU and running locally in your browser. Source on
GitHub.
diff --git a/package-lock.json b/package-lock.json
index 13ab641..7c9269f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14,6 +14,7 @@
"devDependencies": {
"@eslint/js": "^10.0.1",
"@ianvs/prettier-plugin-sort-imports": "^4.7.1",
+ "@playwright/test": "^1.60.0",
"@tweakpane/core": "^2.0.5",
"@types/node": "^25.6.0",
"@vite-pwa/assets-generator": "^1.0.2",
@@ -1261,6 +1262,22 @@
"url": "https://opencollective.com/pkgr"
}
},
+ "node_modules/@playwright/test": {
+ "version": "1.60.0",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
+ "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.60.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@quansync/fs": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-1.0.0.tgz",
@@ -3461,6 +3478,53 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/playwright": {
+ "version": "1.60.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
+ "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.60.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.60.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
+ "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/playwright/node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/postcss": {
"version": "8.5.13",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
diff --git a/package.json b/package.json
index 3ecdc6e..f0b074d 100644
--- a/package.json
+++ b/package.json
@@ -9,13 +9,17 @@
"build": "vite build",
"preview": "vite preview",
"lint": "npm run lint:check",
- "lint:check": "eslint --rule \"prettier/prettier: off\" \"src/**/*.ts\"",
+ "lint:check": "eslint --rule \"prettier/prettier: off\" \"src/**/*.ts\" && npm run unused:check",
"lint:fix": "eslint --fix \"src/**/*.ts\"",
- "format": "prettier --write \"index.html\" \"src/**/*.{ts,scss,json,html}\" \".forgejo/workflows/*.yml\" \"*.{json,js,ts,md}\" \".prettierrc\"",
- "format:check": "prettier --check \"index.html\" \"src/**/*.{ts,scss,json,html}\" \".forgejo/workflows/*.yml\" \"*.{json,js,ts,md}\" \".prettierrc\"",
+ "format": "prettier --write \"index.html\" \"src/**/*.{ts,scss,json,html}\" \"scripts/**/*.mjs\" \"e2e/**/*.ts\" \".forgejo/workflows/*.yml\" \"*.{json,js,ts,md}\" \".prettierrc\"",
+ "format:check": "prettier --check \"index.html\" \"src/**/*.{ts,scss,json,html}\" \"scripts/**/*.mjs\" \"e2e/**/*.ts\" \".forgejo/workflows/*.yml\" \"*.{json,js,ts,md}\" \".prettierrc\"",
"typecheck": "tsc --noEmit",
+ "typecheck:e2e": "tsc --noEmit --project tsconfig.playwright.json",
"test": "vitest run",
+ "test:e2e": "npm run build && playwright test",
+ "test:e2e:ui": "npm run build && playwright test --ui",
"test:watch": "vitest",
+ "unused:check": "node scripts/check-unused-exports.mjs",
"generate-icons": "pwa-assets-generator",
"update": "ncu"
},
@@ -40,6 +44,7 @@
"devDependencies": {
"@eslint/js": "^10.0.1",
"@ianvs/prettier-plugin-sort-imports": "^4.7.1",
+ "@playwright/test": "^1.60.0",
"@tweakpane/core": "^2.0.5",
"@types/node": "^25.6.0",
"@vite-pwa/assets-generator": "^1.0.2",
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..e252a37
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,32 @@
+import { defineConfig, devices } from '@playwright/test';
+
+const port = 4173;
+const baseURL = `https://127.0.0.1:${port}`;
+const isCi = Boolean(process.env.CI);
+
+export default defineConfig({
+ testDir: './e2e',
+ fullyParallel: true,
+ forbidOnly: isCi,
+ retries: isCi ? 2 : 0,
+ workers: isCi ? 1 : undefined,
+ reporter: isCi ? [['list'], ['html', { open: 'never' }]] : 'list',
+ use: {
+ baseURL,
+ ignoreHTTPSErrors: true,
+ trace: 'on-first-retry',
+ },
+ webServer: {
+ command: `npm run preview -- --host 127.0.0.1 --port ${port}`,
+ ignoreHTTPSErrors: true,
+ reuseExistingServer: !isCi,
+ timeout: 120_000,
+ url: baseURL,
+ },
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+ ],
+});
diff --git a/public/apple-touch-icon-180x180.png b/public/apple-touch-icon-180x180.png
index b8bfae3..78ea11e 100644
Binary files a/public/apple-touch-icon-180x180.png and b/public/apple-touch-icon-180x180.png differ
diff --git a/public/favicon.ico b/public/favicon.ico
index e545180..5307896 100644
Binary files a/public/favicon.ico and b/public/favicon.ico differ
diff --git a/public/favicon.svg b/public/favicon.svg
index 50bb5e6..1c0ddbe 100644
--- a/public/favicon.svg
+++ b/public/favicon.svg
@@ -1,6 +1,31 @@
diff --git a/public/maskable-icon-512x512.png b/public/maskable-icon-512x512.png
index 7e94d56..a7224f9 100644
Binary files a/public/maskable-icon-512x512.png and b/public/maskable-icon-512x512.png differ
diff --git a/public/pwa-192x192.png b/public/pwa-192x192.png
index 667a104..22bb33e 100644
Binary files a/public/pwa-192x192.png and b/public/pwa-192x192.png differ
diff --git a/public/pwa-512x512.png b/public/pwa-512x512.png
index 9a5361f..0809d42 100644
Binary files a/public/pwa-512x512.png and b/public/pwa-512x512.png differ
diff --git a/public/pwa-64x64.png b/public/pwa-64x64.png
index 7c93311..7a20069 100644
Binary files a/public/pwa-64x64.png and b/public/pwa-64x64.png differ
diff --git a/scripts/check-unused-exports.mjs b/scripts/check-unused-exports.mjs
new file mode 100644
index 0000000..6114bba
--- /dev/null
+++ b/scripts/check-unused-exports.mjs
@@ -0,0 +1,185 @@
+import { existsSync, readdirSync, readFileSync } from 'node:fs';
+import path from 'node:path';
+import ts from 'typescript';
+
+const projectRoot = process.cwd();
+const sourceRoot = path.join(projectRoot, 'src');
+
+const toPosix = (value) => value.split(path.sep).join('/');
+
+const listTypeScriptFiles = (directory) =>
+ readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
+ const entryPath = path.join(directory, entry.name);
+ if (entry.isDirectory()) {
+ return listTypeScriptFiles(entryPath);
+ }
+ return entry.isFile() && entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')
+ ? [entryPath]
+ : [];
+ });
+
+const files = listTypeScriptFiles(sourceRoot);
+const fileSet = new Set(files.map((file) => path.resolve(file)));
+
+const resolveModule = (fromFile, specifier) => {
+ if (!specifier.startsWith('.')) {
+ return null;
+ }
+
+ const base = path.resolve(path.dirname(fromFile), specifier);
+ const candidates = [
+ `${base}.ts`,
+ path.join(base, 'index.ts'),
+ base.endsWith('.ts') ? base : null,
+ ].filter(Boolean);
+
+ return candidates.find((candidate) => existsSync(candidate) && fileSet.has(candidate)) ?? null;
+};
+
+const exportKey = (file, name) => `${path.resolve(file)}:${name}`;
+const isExported = (node) =>
+ ts.canHaveModifiers(node) &&
+ (ts.getModifiers(node) ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword);
+const isDefaultExported = (node) =>
+ ts.canHaveModifiers(node) &&
+ (ts.getModifiers(node) ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword);
+
+const exportedDeclarations = new Map();
+const usedExports = new Set();
+const wildcardUsedFiles = new Set();
+
+const markUsed = (fromFile, name) => {
+ usedExports.add(exportKey(fromFile, name));
+};
+
+const collectImportUsage = (file, sourceFile) => {
+ sourceFile.forEachChild((node) => {
+ if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
+ const resolved = resolveModule(file, node.moduleSpecifier.text);
+ if (!resolved || !node.importClause) {
+ return;
+ }
+
+ if (node.importClause.name) {
+ markUsed(resolved, 'default');
+ }
+
+ const namedBindings = node.importClause.namedBindings;
+ if (namedBindings && ts.isNamedImports(namedBindings)) {
+ namedBindings.elements.forEach((element) => {
+ markUsed(resolved, (element.propertyName ?? element.name).text);
+ });
+ } else if (namedBindings && ts.isNamespaceImport(namedBindings)) {
+ wildcardUsedFiles.add(path.resolve(resolved));
+ }
+ return;
+ }
+
+ if (
+ ts.isExportDeclaration(node) &&
+ node.moduleSpecifier &&
+ ts.isStringLiteral(node.moduleSpecifier)
+ ) {
+ const resolved = resolveModule(file, node.moduleSpecifier.text);
+ if (!resolved) {
+ return;
+ }
+
+ if (!node.exportClause) {
+ wildcardUsedFiles.add(path.resolve(resolved));
+ return;
+ }
+
+ if (ts.isNamedExports(node.exportClause)) {
+ node.exportClause.elements.forEach((element) => {
+ markUsed(resolved, (element.propertyName ?? element.name).text);
+ });
+ }
+ }
+ });
+};
+
+const collectExportedDeclarations = (file, sourceFile) => {
+ if (file.endsWith('.test.ts')) {
+ return;
+ }
+
+ sourceFile.forEachChild((node) => {
+ if (ts.isVariableStatement(node) && isExported(node)) {
+ node.declarationList.declarations.forEach((declaration) => {
+ if (ts.isIdentifier(declaration.name)) {
+ exportedDeclarations.set(exportKey(file, declaration.name.text), {
+ file,
+ name: declaration.name.text,
+ });
+ }
+ });
+ return;
+ }
+
+ if (
+ (ts.isFunctionDeclaration(node) ||
+ ts.isClassDeclaration(node) ||
+ ts.isInterfaceDeclaration(node) ||
+ ts.isTypeAliasDeclaration(node) ||
+ ts.isEnumDeclaration(node)) &&
+ isExported(node)
+ ) {
+ if (isDefaultExported(node)) {
+ exportedDeclarations.set(exportKey(file, 'default'), { file, name: 'default' });
+ return;
+ }
+
+ if (node.name) {
+ exportedDeclarations.set(exportKey(file, node.name.text), {
+ file,
+ name: node.name.text,
+ });
+ }
+ return;
+ }
+
+ if (
+ ts.isExportDeclaration(node) &&
+ !node.moduleSpecifier &&
+ node.exportClause &&
+ ts.isNamedExports(node.exportClause)
+ ) {
+ node.exportClause.elements.forEach((element) => {
+ exportedDeclarations.set(exportKey(file, element.name.text), {
+ file,
+ name: element.name.text,
+ });
+ });
+ }
+ });
+};
+
+const parsedFiles = files.map((file) => ({
+ file,
+ sourceFile: ts.createSourceFile(
+ file,
+ readFileSync(file, 'utf8'),
+ ts.ScriptTarget.Latest,
+ true,
+ ts.ScriptKind.TS
+ ),
+}));
+
+parsedFiles.forEach(({ file, sourceFile }) => collectImportUsage(file, sourceFile));
+parsedFiles.forEach(({ file, sourceFile }) => collectExportedDeclarations(file, sourceFile));
+
+const unusedExports = Array.from(exportedDeclarations.entries())
+ .filter(([key, declaration]) => !usedExports.has(key) && !wildcardUsedFiles.has(declaration.file))
+ .map(([, declaration]) => declaration)
+ .sort((left, right) =>
+ `${left.file}:${left.name}`.localeCompare(`${right.file}:${right.name}`)
+ );
+
+if (unusedExports.length > 0) {
+ console.error('Unused exported declarations found:');
+ unusedExports.forEach(({ file, name }) => {
+ console.error(`- ${toPosix(path.relative(projectRoot, file))}: ${name}`);
+ });
+ process.exitCode = 1;
+}
diff --git a/src/audio/garden-audio-config.ts b/src/audio/garden-audio-config.ts
index 5d90e44..c7a2c99 100644
--- a/src/audio/garden-audio-config.ts
+++ b/src/audio/garden-audio-config.ts
@@ -1,13 +1,13 @@
import { appConfig } from '../config';
-export type GardenAudioChordQuality = 'major' | 'minor';
+type GardenAudioChordQuality = 'major' | 'minor';
export interface GardenAudioChord {
rootOffset: number;
quality: GardenAudioChordQuality;
}
-export interface GardenAudioColorVoice {
+interface GardenAudioColorVoice {
scaleDegreeOffset: number;
velocityMultiplier: number;
panOffset: number;
diff --git a/src/audio/garden-audio-energy.ts b/src/audio/garden-audio-energy.ts
index bc16435..99bbc0d 100644
--- a/src/audio/garden-audio-energy.ts
+++ b/src/audio/garden-audio-energy.ts
@@ -1,4 +1,4 @@
-import { appConfig } from '../config';
+import type { GardenAudioEngineConfig } from '../config';
import { clamp01 } from '../utils/clamp';
const STROKE_IMMEDIATE_ACTIVITY_SCALE = 0.85;
@@ -9,6 +9,8 @@ export class GardenAudioEnergy {
private targetEnergy = 0;
private lastEnergyUpdateAt = 0;
+ public constructor(private readonly engineConfig: GardenAudioEngineConfig) {}
+
public beginGesture(now: number): void {
this.isGestureActive = true;
this.lastEnergyUpdateAt = now;
@@ -46,15 +48,15 @@ export class GardenAudioEnergy {
const elapsedSeconds = Math.max(0, now - this.lastEnergyUpdateAt);
this.lastEnergyUpdateAt = now;
this.targetEnergy *= Math.exp(
- -elapsedSeconds / appConfig.audioEngine.energy.strokeDecaySeconds
+ -elapsedSeconds / this.engineConfig.energy.strokeDecaySeconds
);
const target = this.isGestureActive ? this.targetEnergy : 0;
- let timeConstant = appConfig.audioEngine.energy.decaySeconds;
+ let timeConstant = this.engineConfig.energy.decaySeconds;
if (!this.isGestureActive) {
- timeConstant = appConfig.audioEngine.energy.releaseSeconds;
+ timeConstant = this.engineConfig.energy.releaseSeconds;
} else if (target > this.energy) {
- timeConstant = appConfig.audioEngine.energy.attackSeconds;
+ timeConstant = this.engineConfig.energy.attackSeconds;
}
const amount = 1 - Math.exp(-elapsedSeconds / timeConstant);
this.energy += (target - this.energy) * amount;
diff --git a/src/audio/garden-audio-graph.ts b/src/audio/garden-audio-graph.ts
index a4e6213..d4bb9ae 100644
--- a/src/audio/garden-audio-graph.ts
+++ b/src/audio/garden-audio-graph.ts
@@ -1,4 +1,4 @@
-import { appConfig } from '../config';
+import type { GardenAudioEngineConfig } from '../config';
import { clamp } from '../utils/clamp';
import { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
@@ -14,7 +14,10 @@ export class GardenAudioGraph {
private delayOutput: GainNode | null = null;
private hasUnlocked = false;
- public constructor(private readonly config: GardenAudioConfig) {}
+ public constructor(
+ private readonly config: GardenAudioConfig,
+ private readonly engineConfig: GardenAudioEngineConfig
+ ) {}
public ensureContext(canCreate: boolean): AudioContext | null {
if (this.context) {
@@ -62,8 +65,8 @@ export class GardenAudioGraph {
const buffer = this.context.createBuffer(
1,
- appConfig.audioEngine.graph.unlockBufferLength,
- appConfig.audioEngine.graph.unlockSampleRate
+ this.engineConfig.graph.unlockBufferLength,
+ this.engineConfig.graph.unlockSampleRate
);
const source = this.context.createBufferSource();
source.buffer = buffer;
@@ -92,7 +95,7 @@ export class GardenAudioGraph {
this.delayNode.delayTime.setTargetAtTime(
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
this.context.currentTime,
- appConfig.audioEngine.graph.delayTimeRampSeconds
+ this.engineConfig.graph.delayTimeRampSeconds
);
}
@@ -105,22 +108,22 @@ export class GardenAudioGraph {
this.delayNode.delayTime.setTargetAtTime(
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
now,
- appConfig.audioEngine.graph.delayTimeRampSeconds
+ this.engineConfig.graph.delayTimeRampSeconds
);
this.delayFeedback.gain.setTargetAtTime(
clamp(
this.config.delay.feedback +
- activity * appConfig.audioEngine.graph.delayActivityFeedbackWeight,
- appConfig.audioEngine.graph.delayFeedbackMin,
- appConfig.audioEngine.graph.delayFeedbackMax
+ activity * this.engineConfig.graph.delayActivityFeedbackWeight,
+ this.engineConfig.graph.delayFeedbackMin,
+ this.engineConfig.graph.delayFeedbackMax
),
now,
this.config.updateRampSeconds
);
this.delayOutput.gain.setTargetAtTime(
this.config.delay.wetGain *
- (appConfig.audioEngine.graph.delayOutputBase +
- activity * appConfig.audioEngine.graph.delayOutputActivityWeight),
+ (this.engineConfig.graph.delayOutputBase +
+ activity * this.engineConfig.graph.delayOutputActivityWeight),
now,
this.config.updateRampSeconds
);
@@ -134,9 +137,9 @@ export class GardenAudioGraph {
if (this.masterGain && context.state !== 'closed') {
this.masterGain.gain.setTargetAtTime(
- appConfig.audioEngine.graph.closeGain,
+ this.engineConfig.graph.closeGain,
context.currentTime,
- appConfig.audioEngine.graph.closeRampSeconds
+ this.engineConfig.graph.closeRampSeconds
);
}
@@ -171,7 +174,7 @@ export class GardenAudioGraph {
private createBuses(context: AudioContext, masterGain: GainNode): void {
this.eventBus = context.createGain();
- this.eventBus.gain.value = appConfig.audioEngine.graph.eventBusGain;
+ this.eventBus.gain.value = this.engineConfig.graph.eventBusGain;
this.eventBus.connect(masterGain);
}
@@ -181,9 +184,9 @@ export class GardenAudioGraph {
for (let index = 0; index < data.length; index++) {
data[index] =
- appConfig.audioEngine.graph.noiseMin +
+ this.engineConfig.graph.noiseMin +
Math.random() *
- (appConfig.audioEngine.graph.noiseMax - appConfig.audioEngine.graph.noiseMin);
+ (this.engineConfig.graph.noiseMax - this.engineConfig.graph.noiseMin);
}
return buffer;
diff --git a/src/audio/garden-audio-input.ts b/src/audio/garden-audio-input.ts
index 8cd7790..015e881 100644
--- a/src/audio/garden-audio-input.ts
+++ b/src/audio/garden-audio-input.ts
@@ -1,4 +1,4 @@
-import { appConfig } from '../config';
+import type { GardenAudioEngineConfig } from '../config';
import { clamp01 } from '../utils/clamp';
import { GardenAudioStroke } from './garden-audio-types';
@@ -12,24 +12,25 @@ export interface GardenAudioStrokeMetrics {
export const getStrokeMetrics = (
stroke: GardenAudioStroke,
speedForFullEnergyPixelsPerSecond: number,
- fallbackPressure: number
+ fallbackPressure: number,
+ inputConfig: GardenAudioEngineConfig['input']
): GardenAudioStrokeMetrics => {
const dx = stroke.to[0] - stroke.from[0];
const dy = stroke.to[1] - stroke.from[1];
const distancePixels = Math.hypot(dx, dy);
- const speedPixelsPerSecond = getStrokeVelocity(stroke, distancePixels);
- const pressure = getPressureAmount(stroke, fallbackPressure);
+ const speedPixelsPerSecond = getStrokeVelocity(stroke, distancePixels, inputConfig);
+ const pressure = getPressureAmount(stroke, fallbackPressure, inputConfig);
const speedAmount = clamp01(speedPixelsPerSecond / speedForFullEnergyPixelsPerSecond);
const strokeEnergy = clamp01(
- appConfig.audioEngine.input.strokeEnergyBase +
- speedAmount * appConfig.audioEngine.input.strokeEnergySpeedWeight +
- pressure * appConfig.audioEngine.input.strokeEnergyPressureWeight
+ inputConfig.strokeEnergyBase +
+ speedAmount * inputConfig.strokeEnergySpeedWeight +
+ pressure * inputConfig.strokeEnergyPressureWeight
);
const effectiveEnergy =
strokeEnergy *
- (appConfig.audioEngine.input.distanceEnergyBase +
- clamp01(distancePixels / appConfig.audioEngine.input.distanceForFullEnergyPixels) *
- appConfig.audioEngine.input.distanceEnergyScale);
+ (inputConfig.distanceEnergyBase +
+ clamp01(distancePixels / inputConfig.distanceForFullEnergyPixels) *
+ inputConfig.distanceEnergyScale);
return {
distancePixels,
@@ -39,7 +40,11 @@ export const getStrokeMetrics = (
};
};
-const getStrokeVelocity = (stroke: GardenAudioStroke, distancePixels: number): number => {
+const getStrokeVelocity = (
+ stroke: GardenAudioStroke,
+ distancePixels: number,
+ inputConfig: GardenAudioEngineConfig['input']
+): number => {
if (
stroke.velocityPixelsPerSecond !== undefined &&
Number.isFinite(stroke.velocityPixelsPerSecond) &&
@@ -48,12 +53,13 @@ const getStrokeVelocity = (stroke: GardenAudioStroke, distancePixels: number): n
return stroke.velocityPixelsPerSecond;
}
- return distancePixels / appConfig.audioEngine.input.fallbackFrameSeconds;
+ return distancePixels / inputConfig.fallbackFrameSeconds;
};
const getPressureAmount = (
stroke: GardenAudioStroke,
- fallbackPressure: number
+ fallbackPressure: number,
+ inputConfig: GardenAudioEngineConfig['input']
): number => {
if (
stroke.pressure !== undefined &&
@@ -64,6 +70,6 @@ const getPressureAmount = (
}
return stroke.pointerType === 'pen'
- ? Math.max(appConfig.audioEngine.input.penMinPressure, clamp01(fallbackPressure))
+ ? Math.max(inputConfig.penMinPressure, clamp01(fallbackPressure))
: clamp01(fallbackPressure);
};
diff --git a/src/audio/garden-audio-music.ts b/src/audio/garden-audio-music.ts
index f3d12c8..11ee580 100644
--- a/src/audio/garden-audio-music.ts
+++ b/src/audio/garden-audio-music.ts
@@ -1,4 +1,3 @@
-import { clamp } from '../utils/clamp';
import { VibePreset } from '../vibes';
import {
GardenAudioChord,
@@ -10,9 +9,6 @@ import { GardenAudioColorIndex } from './garden-audio-types';
export const normalizeColorIndex = (index: number): GardenAudioColorIndex =>
Math.max(0, Math.min(2, Math.round(index))) as GardenAudioColorIndex;
-export const clampMidi = (midi: number, min: number, max: number): number =>
- Math.round(clamp(midi, min, max));
-
export const getVibeProfile = (
config: GardenAudioConfig,
vibe: VibePreset
@@ -21,15 +17,6 @@ export const getVibeProfile = (
config.vibes[config.fallbackVibeId] ??
Object.values(config.vibes)[0];
-export const getChordAtStep = (
- config: GardenAudioConfig,
- profile: GardenAudioVibeProfile,
- stepIndex: number
-): GardenAudioChord => {
- const barIndex = Math.floor(stepIndex / config.rhythm.stepsPerBar);
- return profile.progression[barIndex % profile.progression.length];
-};
-
export const getChordIntervals = (
chord: GardenAudioChord,
openVoicing: boolean
diff --git a/src/audio/garden-audio-types.ts b/src/audio/garden-audio-types.ts
index 370293d..b6edc29 100644
--- a/src/audio/garden-audio-types.ts
+++ b/src/audio/garden-audio-types.ts
@@ -6,6 +6,7 @@ export interface GardenAudioSnapshot {
vibe: VibePreset;
selectedColorIndex: number;
isErasing: boolean;
+ mirrorSegmentCount?: number;
}
export interface GardenAudioStroke {
@@ -18,6 +19,15 @@ export interface GardenAudioStroke {
pressure?: number;
velocityPixelsPerSecond?: number;
eraserSizePixels?: number;
+ mirrorSegmentCount?: number;
+ pointerType?: string;
+}
+
+export interface GardenAudioTouchDown {
+ vibe: VibePreset;
+ colorIndex: number;
+ mirrorSegmentCount?: number;
+ pressure?: number;
pointerType?: string;
}
diff --git a/src/audio/garden-audio.ts b/src/audio/garden-audio.ts
index 4fe6d1e..01d5a5d 100644
--- a/src/audio/garden-audio.ts
+++ b/src/audio/garden-audio.ts
@@ -4,13 +4,14 @@ import { VibePreset } from '../vibes';
import { GardenAudioConfig } from './garden-audio-config';
import { GardenAudioEnergy } from './garden-audio-energy';
import { GardenAudioGraph } from './garden-audio-graph';
-import { getStrokeMetrics } from './garden-audio-input';
+import { GardenAudioStrokeMetrics, getStrokeMetrics } from './garden-audio-input';
import { getVibeProfile, normalizeColorIndex } from './garden-audio-music';
import type {
GardenAudioColorIndex,
GardenAudioSnapshot,
GardenAudioStartOptions,
GardenAudioStroke,
+ GardenAudioTouchDown,
} from './garden-audio-types';
import { GenerativePianoEngine } from './generative-piano';
import { NoiseBurstPlayer } from './noise-burst-player';
@@ -20,6 +21,7 @@ export type {
GardenAudioSnapshot,
GardenAudioStartOptions,
GardenAudioStroke,
+ GardenAudioTouchDown,
} from './garden-audio-types';
export class GardenAudio {
@@ -35,6 +37,7 @@ export class GardenAudio {
private isMuted = false;
private isGestureActive = false;
private selectedColorIndex: GardenAudioColorIndex = 0;
+ private hasQueuedPianoLoad = false;
private lastEraserAt = Number.NEGATIVE_INFINITY;
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
@@ -70,9 +73,21 @@ export class GardenAudio {
this.hasStarted = true;
this.applyVibe(vibe);
this.pianoEngine.prime(context.currentTime);
- this.graph.setMasterGain(this.config.masterVolume, this.config.fadeInSeconds);
+ this.graph.setMasterGain(
+ this.config.masterVolume,
+ options.userGesture === true
+ ? appConfig.audioEngine.muteRampSeconds
+ : this.config.fadeInSeconds
+ );
- void this.piano.load(context);
+ if (!this.hasQueuedPianoLoad) {
+ this.hasQueuedPianoLoad = true;
+ void this.piano.load(context).then(() => {
+ if (this.graph.context === context && !this.isDestroyed) {
+ this.pianoEngine.cue(context.currentTime);
+ }
+ });
+ }
}
public changeVibe(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
@@ -108,19 +123,38 @@ export class GardenAudio {
this.isGestureActive = true;
this.energy.beginGesture(context.currentTime);
- this.pianoEngine.beginGesture(context.currentTime);
+ this.pianoEngine.beginGesture();
}
public endGesture(): void {
- const context = this.graph.context;
this.isGestureActive = false;
this.energy.endGesture();
this.pianoEngine.endGesture();
- if (!context) {
+ }
+
+ public touchDown(touch: GardenAudioTouchDown): void {
+ if (this.isDestroyed || this.isMuted) {
return;
}
- this.piano.fadeActive(context.currentTime, appConfig.audioEngine.gestureFadeSeconds);
+ const context = this.graph.context;
+ if (!context || !this.isGestureActive) {
+ return;
+ }
+
+ this.selectedColorIndex = normalizeColorIndex(touch.colorIndex);
+ const mirrorAmount = this.getMirrorAmount(touch.mirrorSegmentCount ?? 1);
+ const pressure = this.getTouchPressure(touch.pressure, touch.pointerType);
+ const strength = clamp01(0.36 + pressure * 0.34 + mirrorAmount * 0.22);
+
+ this.energy.recordStroke(strength, context.currentTime);
+ this.pianoEngine.recordTouchDown({
+ vibe: touch.vibe,
+ now: context.currentTime,
+ strength,
+ selectedColorIndex: this.selectedColorIndex,
+ mirrorAmount,
+ });
}
public update(snapshot: GardenAudioSnapshot): void {
@@ -135,22 +169,14 @@ export class GardenAudio {
if (snapshot.isErasing) {
this.energy.silence();
- this.piano.fadeActive(
- context.currentTime,
- appConfig.audioEngine.gestureFadeSeconds
- );
- this.updateDelay(snapshot);
- return;
}
- if (this.isGestureActive) {
- this.pianoEngine.renderLookahead({
- vibe: snapshot.vibe,
- now: context.currentTime,
- activity: this.energy.getActivity(),
- selectedColorIndex: this.selectedColorIndex,
- });
- }
+ this.pianoEngine.renderLookahead({
+ vibe: snapshot.vibe,
+ now: context.currentTime,
+ activity: snapshot.isErasing ? 0 : this.energy.getLevel(),
+ selectedColorIndex: this.selectedColorIndex,
+ });
this.updateDelay(snapshot);
}
@@ -183,9 +209,16 @@ export class GardenAudio {
return;
}
- const strokeEnergy = metrics.effectiveEnergy;
+ const mirrorAmount = this.getMirrorAmount(stroke.mirrorSegmentCount ?? 1);
+ const strokeEnergy = this.getStrokeMusicActivity(stroke, metrics, mirrorAmount);
this.energy.recordStroke(strokeEnergy, now);
- this.pianoEngine.wake(now);
+ this.pianoEngine.recordStroke({
+ vibe: stroke.vibe,
+ now,
+ activity: strokeEnergy,
+ selectedColorIndex: this.selectedColorIndex,
+ mirrorAmount,
+ });
}
public async destroy(): Promise {
@@ -199,6 +232,7 @@ export class GardenAudio {
this.hasStarted = false;
this.isGestureActive = false;
this.selectedColorIndex = 0;
+ this.hasQueuedPianoLoad = false;
this.lastEraserAt = Number.NEGATIVE_INFINITY;
this.lastVibeStingerAt = Number.NEGATIVE_INFINITY;
}
@@ -285,5 +319,62 @@ export class GardenAudio {
this.currentVibeId = vibe.id;
this.graph.applyDelayProfile(getVibeProfile(this.config, vibe));
+ this.pianoEngine.cue(this.graph.context.currentTime);
+ }
+
+ private getMirrorAmount(mirrorSegmentCount: number): number {
+ const maxMirrorSegmentCount = Math.max(1, appConfig.simulation.maxMirrorSegmentCount);
+ const segmentCount = clamp(
+ Number.isFinite(mirrorSegmentCount) ? mirrorSegmentCount : 1,
+ 1,
+ maxMirrorSegmentCount
+ );
+
+ if (maxMirrorSegmentCount <= 1) {
+ return 0;
+ }
+
+ 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)
+ : 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);
+};
diff --git a/src/audio/generative-piano.test.ts b/src/audio/generative-piano.test.ts
index a2d06e9..f75bf8c 100644
--- a/src/audio/generative-piano.test.ts
+++ b/src/audio/generative-piano.test.ts
@@ -14,104 +14,220 @@ const makeEngine = () => {
return { engine, notes };
};
+const getBeatSeconds = (): number => 60 / gardenAudioConfig.rhythm.bpm;
+
+const getBeatsPerBar = (): number =>
+ Math.round(gardenAudioConfig.rhythm.stepsPerBar / gardenAudioConfig.rhythm.stepsPerBeat);
+
const renderBars = (
engine: GenerativePianoEngine,
activity: number,
selectedColorIndex = 0,
- bars = 4
+ bars = 8
) => {
engine.renderLookahead({
vibe: VIBE_PRESETS[0],
now: 0,
activity,
selectedColorIndex: selectedColorIndex as 0 | 1 | 2,
- lookaheadSeconds:
- (60 / gardenAudioConfig.rhythm.bpm) *
- Math.round(
- gardenAudioConfig.rhythm.stepsPerBar / gardenAudioConfig.rhythm.stepsPerBeat
- ) *
- bars,
+ lookaheadSeconds: getBeatSeconds() * getBeatsPerBar() * bars,
});
};
const average = (values: Array): number =>
values.reduce((sum, value) => sum + value, 0) / values.length;
+const uniqueStartTimes = (notes: Array): Array =>
+ Array.from(new Set(notes.map((note) => note.startTime.toFixed(3))));
+
+const countNotesBetween = (
+ notes: Array,
+ startSeconds: number,
+ endSeconds: number
+): number =>
+ notes.filter(
+ (note) => note.startTime >= startSeconds && note.startTime < endSeconds
+ ).length;
+
describe('GenerativePianoEngine', () => {
- it('does not emit notes below the sparse activity threshold', () => {
+ it('plays quiet background music even when the garden is idle', () => {
const { engine, notes } = makeEngine();
- renderBars(engine, gardenAudioConfig.rhythm.sparseActivity - 0.01);
-
- expect(notes).toHaveLength(0);
- });
-
- it('keeps drawing notes on beat starts', () => {
- const { engine, notes } = makeEngine();
- const beatSeconds = 60 / gardenAudioConfig.rhythm.bpm;
- const startDelaySeconds = 0.02;
-
- renderBars(engine, 1, 1);
+ renderBars(engine, 0);
expect(notes.length).toBeGreaterThan(0);
- notes.forEach((note) => {
- const beatsFromStart = (note.startTime - startDelaySeconds) / beatSeconds;
+ expect(notes.some((note) => note.durationSeconds > getBeatSeconds() * 12)).toBe(
+ true
+ );
+ expect(Math.max(...notes.map((note) => note.velocity))).toBeLessThan(0.16);
+ });
+
+ it('keeps the background sparse instead of filling every beat', () => {
+ const { engine, notes } = makeEngine();
+
+ renderBars(engine, 0, 1, 4);
+
+ expect(uniqueStartTimes(notes).length).toBeLessThan(8);
+ });
+
+ it('lets activity add density without changing the beat grid', () => {
+ const idle = makeEngine();
+ const active = makeEngine();
+ const startDelaySeconds = 0.02;
+
+ renderBars(idle.engine, 0, 1, 8);
+ renderBars(active.engine, 1, 1, 8);
+
+ expect(active.notes.length).toBeGreaterThan(idle.notes.length);
+ active.notes.forEach((note) => {
+ const beatsFromStart = (note.startTime - startDelaySeconds) / getBeatSeconds();
expect(Math.abs(beatsFromStart - Math.round(beatsFromStart))).toBeLessThan(0.001);
});
});
- it('varies density with activity without exceeding one note per beat', () => {
- const low = makeEngine();
- const high = makeEngine();
-
- renderBars(low.engine, gardenAudioConfig.rhythm.sparseActivity + 0.03, 1);
- renderBars(high.engine, 1, 1);
-
- expect(high.notes.length).toBeGreaterThan(low.notes.length);
- expect(high.notes.length).toBeLessThanOrEqual(16);
- });
-
- it('wakes every color with a prompt first note at low activity', () => {
+ it('uses color pools with multiple notes instead of one key per color', () => {
([0, 1, 2] as const).forEach((selectedColorIndex) => {
const { engine, notes } = makeEngine();
- const now = 4;
- engine.beginGesture(1);
- engine.wake(now);
- engine.renderLookahead({
- vibe: VIBE_PRESETS[0],
- now,
- activity: gardenAudioConfig.rhythm.sparseActivity + 0.01,
- selectedColorIndex,
- lookaheadSeconds: 0.08,
- });
+ renderBars(engine, 1, selectedColorIndex, 16);
- expect(notes).toHaveLength(1);
- expect(notes[0].startTime).toBeCloseTo(now + 0.02);
+ expect(new Set(notes.map((note) => note.midi)).size).toBeGreaterThan(3);
});
});
- it('uses different color roles for register and pan', () => {
- const anchor = makeEngine();
- const spark = makeEngine();
+ it('keeps the upper color higher and wider than the lower color', () => {
+ const lower = makeEngine();
+ const upper = makeEngine();
- renderBars(anchor.engine, 1, 0);
- renderBars(spark.engine, 1, 2);
+ renderBars(lower.engine, 1, 0, 16);
+ renderBars(upper.engine, 1, 2, 16);
- expect(average(spark.notes.map((note) => note.midi))).toBeGreaterThan(
- average(anchor.notes.map((note) => note.midi))
+ expect(average(upper.notes.map((note) => note.midi))).toBeGreaterThan(
+ average(lower.notes.map((note) => note.midi))
);
- expect(average(spark.notes.map((note) => note.pan))).toBeGreaterThan(
- average(anchor.notes.map((note) => note.pan))
+ expect(average(upper.notes.map((note) => note.pan))).toBeGreaterThan(
+ average(lower.notes.map((note) => note.pan))
);
});
- it('is deterministic for the same phrase inputs', () => {
+ it('starts a fading brush phrase layer with each new brush gesture', () => {
+ const baseline = makeEngine();
+ const layered = makeEngine();
+ const now = 4;
+
+ baseline.engine.renderLookahead({
+ vibe: VIBE_PRESETS[0],
+ now,
+ activity: 0.35,
+ selectedColorIndex: 1,
+ lookaheadSeconds: 12,
+ });
+
+ layered.engine.beginGesture();
+ layered.engine.recordStroke({
+ vibe: VIBE_PRESETS[0],
+ now,
+ activity: 0.85,
+ selectedColorIndex: 1,
+ mirrorAmount: 0.45,
+ });
+ layered.engine.renderLookahead({
+ vibe: VIBE_PRESETS[0],
+ now,
+ activity: 0.35,
+ selectedColorIndex: 1,
+ lookaheadSeconds: 12,
+ });
+
+ const earlyExtra =
+ countNotesBetween(layered.notes, now + 1, now + 5) -
+ countNotesBetween(baseline.notes, now + 1, now + 5);
+ const lateExtra =
+ countNotesBetween(layered.notes, now + 10.5, now + 12) -
+ countNotesBetween(baseline.notes, now + 10.5, now + 12);
+
+ expect(earlyExtra).toBeGreaterThan(2);
+ expect(lateExtra).toBe(0);
+ });
+
+ it('makes brush phrase layers denser at higher mirror amounts', () => {
+ const lowMirror = makeEngine();
+ const highMirror = makeEngine();
+ const now = 4;
+
+ lowMirror.engine.beginGesture();
+ lowMirror.engine.recordStroke({
+ vibe: VIBE_PRESETS[0],
+ now,
+ activity: 0.85,
+ selectedColorIndex: 2,
+ mirrorAmount: 0,
+ });
+ lowMirror.engine.renderLookahead({
+ vibe: VIBE_PRESETS[0],
+ now,
+ activity: 0.35,
+ selectedColorIndex: 2,
+ lookaheadSeconds: 9,
+ });
+
+ highMirror.engine.beginGesture();
+ highMirror.engine.recordStroke({
+ vibe: VIBE_PRESETS[0],
+ now,
+ activity: 0.85,
+ selectedColorIndex: 2,
+ mirrorAmount: 1,
+ });
+ highMirror.engine.renderLookahead({
+ vibe: VIBE_PRESETS[0],
+ now,
+ activity: 0.35,
+ selectedColorIndex: 2,
+ lookaheadSeconds: 9,
+ });
+
+ expect(highMirror.notes.length).toBeGreaterThan(lowMirror.notes.length);
+ });
+
+ it('plays one immediate touch note and throttles later stroke accents', () => {
+ const { engine, notes } = makeEngine();
+ const now = 4;
+
+ engine.beginGesture();
+ engine.recordStroke({
+ vibe: VIBE_PRESETS[0],
+ now,
+ activity: 0.9,
+ selectedColorIndex: 1,
+ });
+ engine.recordStroke({
+ vibe: VIBE_PRESETS[0],
+ now: now + 1,
+ activity: 0.95,
+ selectedColorIndex: 1,
+ });
+
+ expect(notes).toHaveLength(1);
+ expect(notes[0].startTime).toBe(now);
+
+ engine.recordStroke({
+ vibe: VIBE_PRESETS[0],
+ now: now + 6,
+ activity: 0.95,
+ selectedColorIndex: 1,
+ });
+
+ expect(notes).toHaveLength(2);
+ expect(new Set(notes.map((note) => note.midi)).size).toBeGreaterThan(1);
+ });
+
+ it('is deterministic for the same musical inputs', () => {
const first = makeEngine();
const second = makeEngine();
- renderBars(first.engine, 0.78, 2);
- renderBars(second.engine, 0.78, 2);
+ renderBars(first.engine, 0.78, 2, 16);
+ renderBars(second.engine, 0.78, 2, 16);
expect(second.notes).toEqual(first.notes);
});
diff --git a/src/audio/generative-piano.ts b/src/audio/generative-piano.ts
index e1cedaa..503f030 100644
--- a/src/audio/generative-piano.ts
+++ b/src/audio/generative-piano.ts
@@ -6,13 +6,7 @@ import {
GardenAudioConfig,
GardenAudioVibeProfile,
} from './garden-audio-config';
-import {
- clampMidi,
- degreeToSemitone,
- getChordAtStep,
- getChordIntervals,
- getVibeProfile,
-} from './garden-audio-music';
+import { degreeToSemitone, getChordIntervals, getVibeProfile } from './garden-audio-music';
import { GardenAudioColorIndex, PianoNote } from './garden-audio-types';
interface RenderLookaheadRequest {
@@ -23,26 +17,31 @@ interface RenderLookaheadRequest {
lookaheadSeconds?: number;
}
-interface PianoRole {
- name: string;
+interface StrokeAccentRequest {
+ vibe: VibePreset;
+ now: number;
+ activity: number;
+ selectedColorIndex: GardenAudioColorIndex;
+ mirrorAmount?: number;
+}
+
+interface TouchDownRequest {
+ vibe: VibePreset;
+ now: number;
+ strength: number;
+ selectedColorIndex: GardenAudioColorIndex;
+ mirrorAmount?: number;
+}
+
+interface Register {
midiMin: number;
midiMax: number;
preferredMidi: number;
pan: number;
- delaySend: number;
- velocityBase: number;
- velocityActivityScale: number;
- durationBase: number;
- durationActivityScale: number;
- downbeatDurationBoost: number;
- lowBeats: ReadonlyArray;
- mediumBeats: ReadonlyArray;
- highBeats: ReadonlyArray;
- lowPhraseOffset: number;
- lowPhraseSpacing: number;
- lowKeepChance: number;
- mediumKeepChance: number;
- highKeepChance: number;
+}
+
+interface ColorPool extends Register {
+ scaleDegrees: ReadonlyArray;
}
interface PitchCandidate {
@@ -50,99 +49,108 @@ interface PitchCandidate {
preference: number;
}
-const COLOR_ROLES: [PianoRole, PianoRole, PianoRole] = [
+interface PitchSource {
+ baseMidi: number;
+ offsets: ReadonlyArray;
+}
+
+interface BrushPhraseLayer {
+ vibe: VibePreset;
+ startedAt: number;
+ expiresAt: number;
+ selectedColorIndex: GardenAudioColorIndex;
+ energy: number;
+ mirrorAmount: number;
+}
+
+const COLOR_POOLS: [ColorPool, ColorPool, ColorPool] = [
{
- name: 'anchor',
- midiMin: 45,
- midiMax: 64,
- preferredMidi: 53,
- pan: -0.28,
- delaySend: 0.012,
- velocityBase: 0.12,
- velocityActivityScale: 0.15,
- durationBase: 1.2,
- durationActivityScale: 0.75,
- downbeatDurationBoost: 0.32,
- lowBeats: [0, 2],
- mediumBeats: [0, 2],
- highBeats: [0, 2],
- lowPhraseOffset: 0,
- lowPhraseSpacing: 4,
- lowKeepChance: 1,
- mediumKeepChance: 0.92,
- highKeepChance: 1,
+ midiMin: 48,
+ midiMax: 67,
+ preferredMidi: 55,
+ pan: -0.18,
+ scaleDegrees: [0, 1, 2, 4],
},
{
- name: 'body',
midiMin: 55,
midiMax: 74,
- preferredMidi: 64,
+ preferredMidi: 63,
pan: 0,
- delaySend: 0.009,
- velocityBase: 0.115,
- velocityActivityScale: 0.18,
- durationBase: 0.86,
- durationActivityScale: 0.54,
- downbeatDurationBoost: 0.18,
- lowBeats: [0, 2],
- mediumBeats: [0, 2],
- highBeats: [0, 1, 2, 3],
- lowPhraseOffset: 0,
- lowPhraseSpacing: 4,
- lowKeepChance: 0.86,
- mediumKeepChance: 0.86,
- highKeepChance: 0.9,
+ scaleDegrees: [1, 2, 3, 5],
},
{
- name: 'spark',
- midiMin: 67,
- midiMax: 84,
- preferredMidi: 76,
- pan: 0.28,
- delaySend: 0.018,
- velocityBase: 0.09,
- velocityActivityScale: 0.14,
- durationBase: 0.48,
- durationActivityScale: 0.32,
- downbeatDurationBoost: 0,
- lowBeats: [0, 2],
- mediumBeats: [1, 3],
- highBeats: [0, 1, 2, 3],
- lowPhraseOffset: 0,
- lowPhraseSpacing: 4,
- lowKeepChance: 0.76,
- mediumKeepChance: 0.8,
- highKeepChance: 0.78,
+ midiMin: 62,
+ midiMax: 81,
+ preferredMidi: 72,
+ pan: 0.18,
+ scaleDegrees: [2, 3, 4, 6],
},
];
-const PHRASE_BAR_COUNT = 4;
-const PACE_MIN = 0.96;
-const PACE_MAX = 1.08;
-const PACE_RAMP_SECONDS = 2.8;
-const FIRST_NOTE_MAX_WAIT_SECONDS = 0.09;
-const NOTE_SCORE_PREFERENCE_WEIGHT = 1.8;
-const NOTE_SCORE_REGISTER_WEIGHT = 0.28;
-const STINGER_SPACING_SECONDS = 0.07;
-const STINGER_DURATION_SECONDS = 1.45;
-const PHRASE_CONTOURS: ReadonlyArray> = [
- [0, 0, 1, 0, -1, 0, 1, 0],
- [0, 1, 2, 1, 0, -1, 0, 1],
- [1, 0, -1, 0, 1, 2, 1, 0],
- [0, -1, 0, 1, 0, 1, 2, 1],
+const PAD_REGISTERS: [Register, Register, Register] = [
+ {
+ midiMin: 40,
+ midiMax: 55,
+ preferredMidi: 48,
+ pan: -0.12,
+ },
+ {
+ midiMin: 48,
+ midiMax: 64,
+ preferredMidi: 55,
+ pan: 0.08,
+ },
+ {
+ midiMin: 58,
+ midiMax: 76,
+ preferredMidi: 67,
+ pan: 0.2,
+ },
];
+const CHORD_BARS = 4;
+const SUPPORT_BAR_SPACING = 2;
+const SUPPORT_BAR_OFFSET = 1;
+const IDLE_TEXTURE_BAR_SPACING = 2;
+const MEDIUM_TEXTURE_BAR_SPACING = 1;
+const TEXTURE_BEAT = 2;
+const HIGH_ACTIVITY_EXTRA_BEAT = 3;
+const HIGH_ACTIVITY_EXTRA_THRESHOLD = 0.45;
+const NOTE_SCORE_PREFERENCE_WEIGHT = 1.8;
+const NOTE_SCORE_REGISTER_WEIGHT = 0.28;
+const NOTE_SCORE_REPEAT_PENALTY = 3.2;
+const GESTURE_ACCENT_SPACING_SECONDS = 0.26;
+const GESTURE_ACCENT_MIN_INTERVAL_SECONDS = 2.5;
+const STROKE_ACCENT_MIN_INTERVAL_SECONDS = 3.2;
+const STROKE_ACCENT_THRESHOLD = 0.58;
+const STINGER_SPACING_SECONDS = 0.08;
+const STINGER_DURATION_SECONDS = 1.1;
+const MAX_BRUSH_PHRASE_LAYERS = 5;
+const BRUSH_LAYER_BASE_SECONDS = 5.5;
+const BRUSH_LAYER_ENERGY_SECONDS = 2.5;
+const BRUSH_LAYER_MIRROR_SECONDS = 3;
+const BRUSH_LAYER_MIN_INTENSITY = 0.08;
+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;
+
export class GenerativePianoEngine {
- private nextStepAt: number | null = null;
- private stepIndex = 0;
- private pace = 1;
- private lastPaceUpdateAt: number | null = null;
- private isWaitingForFirstStrokeNote = false;
+ private nextBeatAt: number | null = null;
+ private timelineStartedAt: number | null = null;
+ private beatIndex = 0;
+ private isWaitingForGestureAccent = false;
+ private lastGestureAccentAt = Number.NEGATIVE_INFINITY;
+ private lastStrokeAccentAt = Number.NEGATIVE_INFINITY;
private readonly lastMidiByColor: [number | null, number | null, number | null] = [
null,
null,
null,
];
+ private brushPhraseLayers: Array = [];
+ private nextBrushStreamAt: number | null = null;
+ private brushStreamNoteIndex = 0;
+ private lastBrushStreamMidi: number | null = null;
public constructor(
private readonly config: GardenAudioConfig,
@@ -150,33 +158,85 @@ export class GenerativePianoEngine {
) {}
public prime(now: number): void {
- if (this.nextStepAt === null) {
- this.nextStepAt = now + appConfig.audioEngine.startDelaySeconds;
+ if (this.nextBeatAt === null) {
+ this.nextBeatAt = now + appConfig.audioEngine.startDelaySeconds;
}
- this.lastPaceUpdateAt ??= now;
+ this.timelineStartedAt ??= now;
+ this.nextBrushStreamAt ??= now + appConfig.audioEngine.startDelaySeconds;
}
- public beginGesture(now: number): void {
- this.isWaitingForFirstStrokeNote = true;
- this.wake(now);
+ public cue(now: number): void {
+ this.nextBeatAt = now + appConfig.audioEngine.startDelaySeconds;
+ this.timelineStartedAt = now;
+ this.beatIndex = 0;
+ this.nextBrushStreamAt = now + appConfig.audioEngine.startDelaySeconds;
+ this.brushStreamNoteIndex = 0;
+ this.lastBrushStreamMidi = null;
+ }
+
+ public beginGesture(): void {
+ this.isWaitingForGestureAccent = true;
}
public endGesture(): void {
- this.isWaitingForFirstStrokeNote = false;
+ this.isWaitingForGestureAccent = false;
}
- public wake(now: number): void {
- this.lastPaceUpdateAt ??= now;
+ public recordTouchDown({
+ vibe,
+ now,
+ strength,
+ selectedColorIndex,
+ mirrorAmount = 0,
+ }: TouchDownRequest): void {
+ const normalizedStrength = clamp01(strength);
+ const normalizedMirrorAmount = clamp01(mirrorAmount);
+
+ this.isWaitingForGestureAccent = false;
+ this.lastGestureAccentAt = now;
+ this.lastStrokeAccentAt = now;
+ this.startBrushPhraseLayer({
+ vibe,
+ now,
+ strength: normalizedStrength,
+ selectedColorIndex,
+ mirrorAmount: normalizedMirrorAmount,
+ });
+ this.playTouchNote(vibe, now, selectedColorIndex, normalizedStrength);
+ }
+
+ public recordStroke({
+ vibe,
+ now,
+ activity,
+ selectedColorIndex,
+ mirrorAmount = 0,
+ }: StrokeAccentRequest): void {
+ const strength = clamp01(activity);
+ const normalizedMirrorAmount = clamp01(mirrorAmount);
+
if (
- !this.isWaitingForFirstStrokeNote &&
- this.nextStepAt !== null &&
- this.nextStepAt - now <= FIRST_NOTE_MAX_WAIT_SECONDS
+ this.isWaitingForGestureAccent &&
+ now - this.lastGestureAccentAt >= GESTURE_ACCENT_MIN_INTERVAL_SECONDS
) {
+ this.recordTouchDown({
+ vibe,
+ now,
+ strength,
+ selectedColorIndex,
+ mirrorAmount: normalizedMirrorAmount,
+ });
return;
}
- this.nextStepAt = now + appConfig.audioEngine.startDelaySeconds;
- this.stepIndex = this.getNextDownbeatStepIndex();
+ this.isWaitingForGestureAccent = false;
+ if (
+ strength >= STROKE_ACCENT_THRESHOLD &&
+ now - this.lastStrokeAccentAt >= STROKE_ACCENT_MIN_INTERVAL_SECONDS
+ ) {
+ this.lastStrokeAccentAt = now;
+ this.playGestureAccent(vibe, now, selectedColorIndex, strength, 1);
+ }
}
public renderLookahead({
@@ -187,317 +247,525 @@ export class GenerativePianoEngine {
lookaheadSeconds = this.config.rhythm.lookaheadSeconds,
}: RenderLookaheadRequest): void {
this.prime(now);
- const normalizedActivity = clamp01(activity);
+ this.skipLateBeats(now, this.getBeatDurationSeconds());
- this.updatePace(now, normalizedActivity);
- const stepSeconds = this.getStepDurationSeconds();
- this.skipLateSteps(now, stepSeconds);
-
- if (this.nextStepAt === null) {
+ if (this.nextBeatAt === null) {
return;
}
const profile = getVibeProfile(this.config, vibe);
const lookaheadEnd = now + lookaheadSeconds;
- while (this.nextStepAt <= lookaheadEnd) {
- this.renderStep({
- vibe,
+ while (this.nextBeatAt <= lookaheadEnd) {
+ this.renderBeat({
profile,
- stepIndex: this.stepIndex,
- startTime: this.nextStepAt,
- activity: normalizedActivity,
+ beatIndex: this.beatIndex,
+ startTime: this.nextBeatAt,
+ expression: this.getExpression(activity),
selectedColorIndex,
});
- this.nextStepAt += stepSeconds;
- this.stepIndex += 1;
+ this.nextBeatAt += this.getBeatDurationSeconds();
+ this.beatIndex += 1;
}
+ this.renderBrushPhraseLayers({
+ vibe,
+ now,
+ lookaheadEnd,
+ activity,
+ selectedColorIndex,
+ });
}
public playVibeChangeStinger(vibe: VibePreset, now: number): void {
const profile = getVibeProfile(this.config, vibe);
- const chord = profile.progression[0];
+ const chord = this.getChord(profile, 0);
const intervals = getChordIntervals(chord, true);
const rootMidi = profile.rootMidi + chord.rootOffset;
const notes = [
{
- midi: clampMidi(rootMidi + intervals[0], 48, 60),
- velocity: 0.16,
- pan: -0.24,
+ midi: this.chooseMidi({ baseMidi: rootMidi, offsets: [0] }, PAD_REGISTERS[0]),
+ velocity: 0.1,
+ pan: -0.16,
+ delaySend: 0.012,
+ },
+ {
+ midi: this.chooseMidi(
+ { baseMidi: rootMidi, offsets: [intervals[1], intervals[2]] },
+ PAD_REGISTERS[1]
+ ),
+ velocity: 0.085,
+ pan: 0,
delaySend: 0.014,
},
{
- midi: clampMidi(rootMidi + intervals[2], 60, 72),
- velocity: 0.13,
- pan: 0.02,
+ midi: this.chooseMidi(
+ { baseMidi: rootMidi, offsets: [intervals[3], intervals[2]] },
+ PAD_REGISTERS[2]
+ ),
+ velocity: 0.07,
+ pan: 0.16,
delaySend: 0.016,
},
- {
- midi: clampMidi(rootMidi + intervals[3], 67, 84),
- velocity: 0.105,
- pan: 0.26,
- delaySend: 0.02,
- },
];
notes.forEach((note, index) => {
this.playNote({
...note,
durationSeconds: STINGER_DURATION_SECONDS,
- lowpassHz: this.getLowpassHz(profile, note.midi, 0.28),
+ lowpassHz: this.getLowpassHz(profile, note.midi, 0.35),
startTime: now + index * STINGER_SPACING_SECONDS,
});
});
}
public reset(): void {
- this.nextStepAt = null;
- this.stepIndex = 0;
- this.pace = 1;
- this.lastPaceUpdateAt = null;
- this.isWaitingForFirstStrokeNote = false;
+ this.nextBeatAt = null;
+ this.timelineStartedAt = null;
+ this.beatIndex = 0;
+ this.isWaitingForGestureAccent = false;
+ this.lastGestureAccentAt = Number.NEGATIVE_INFINITY;
+ this.lastStrokeAccentAt = Number.NEGATIVE_INFINITY;
this.lastMidiByColor[0] = null;
this.lastMidiByColor[1] = null;
this.lastMidiByColor[2] = null;
+ this.brushPhraseLayers = [];
+ this.nextBrushStreamAt = null;
+ this.brushStreamNoteIndex = 0;
+ this.lastBrushStreamMidi = null;
}
- private renderStep({
- vibe,
+ private renderBeat({
profile,
- stepIndex,
+ beatIndex,
startTime,
+ expression,
+ selectedColorIndex,
+ }: {
+ profile: GardenAudioVibeProfile;
+ beatIndex: number;
+ startTime: number;
+ expression: number;
+ selectedColorIndex: GardenAudioColorIndex;
+ }): void {
+ const beatsPerBar = this.getBeatsPerBar();
+ const beatInBar = beatIndex % beatsPerBar;
+ const barIndex = Math.floor(beatIndex / beatsPerBar);
+
+ if (beatInBar === 0 && barIndex % CHORD_BARS === 0) {
+ this.playPadChord(profile, barIndex, startTime, expression);
+ }
+
+ if (beatInBar === 0 && this.shouldPlaySupport(expression, barIndex)) {
+ this.playSupportNote(profile, barIndex, startTime, expression, selectedColorIndex);
+ }
+
+ if (beatInBar === TEXTURE_BEAT && this.shouldPlayTexture(expression, barIndex)) {
+ this.playTextureNote(profile, barIndex, startTime, expression, selectedColorIndex);
+ }
+
+ if (
+ beatInBar === HIGH_ACTIVITY_EXTRA_BEAT &&
+ expression >= HIGH_ACTIVITY_EXTRA_THRESHOLD
+ ) {
+ this.playTextureNote(
+ profile,
+ barIndex + 1,
+ startTime,
+ expression * 0.9,
+ selectedColorIndex
+ );
+ }
+ }
+
+ private playPadChord(
+ profile: GardenAudioVibeProfile,
+ barIndex: number,
+ startTime: number,
+ expression: number
+ ): void {
+ 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 notes = [
+ {
+ source: { baseMidi: rootMidi, offsets: [0] },
+ register: PAD_REGISTERS[0],
+ velocity: 0.082,
+ },
+ {
+ source: { baseMidi: rootMidi, offsets: [intervals[1]] },
+ register: PAD_REGISTERS[1],
+ velocity: 0.064,
+ },
+ {
+ source: { baseMidi: rootMidi, offsets: [intervals[3], intervals[2]] },
+ register: PAD_REGISTERS[2],
+ velocity: 0.052,
+ },
+ ];
+
+ notes.forEach(({ source, register, velocity }) => {
+ const midi = this.chooseMidi(source, register);
+ this.playNote({
+ midi,
+ velocity: velocity + expression * 0.02,
+ startTime,
+ durationSeconds,
+ pan: register.pan,
+ delaySend: 0.018,
+ lowpassHz: this.getLowpassHz(profile, midi, expression * 0.45),
+ });
+ });
+ }
+
+ private playSupportNote(
+ profile: GardenAudioVibeProfile,
+ barIndex: number,
+ startTime: number,
+ expression: number,
+ selectedColorIndex: GardenAudioColorIndex
+ ): void {
+ const pool = COLOR_POOLS[selectedColorIndex];
+ const chord = this.getChord(profile, barIndex);
+ const chordIntervals = getChordIntervals(chord, false);
+ const rootMidi = profile.rootMidi + chord.rootOffset;
+ const midi = this.chooseMidi(
+ {
+ baseMidi: rootMidi,
+ offsets: this.getSupportOffsets(chordIntervals, selectedColorIndex),
+ },
+ pool,
+ this.lastMidiByColor[selectedColorIndex],
+ true
+ );
+
+ this.lastMidiByColor[selectedColorIndex] = midi;
+ this.playNote({
+ midi,
+ velocity:
+ (0.105 + expression * 0.07) *
+ this.config.colorVoices[selectedColorIndex].velocityMultiplier,
+ startTime,
+ durationSeconds: 1.35 + expression * 0.4,
+ pan: this.getColorPan(selectedColorIndex),
+ delaySend: 0.016 + expression * 0.006,
+ lowpassHz: this.getLowpassHz(profile, midi, expression * 0.7),
+ });
+ }
+
+ private playTextureNote(
+ profile: GardenAudioVibeProfile,
+ barIndex: number,
+ startTime: number,
+ expression: number,
+ selectedColorIndex: GardenAudioColorIndex
+ ): void {
+ const pool = COLOR_POOLS[selectedColorIndex];
+ const degrees = this.rotate(pool.scaleDegrees, barIndex + selectedColorIndex);
+ const midi = this.chooseMidi(
+ {
+ baseMidi: profile.rootMidi,
+ offsets: degrees.map((degree) => degreeToSemitone(profile, degree)),
+ },
+ pool,
+ this.lastMidiByColor[selectedColorIndex],
+ true
+ );
+
+ this.lastMidiByColor[selectedColorIndex] = midi;
+ this.playNote({
+ midi,
+ velocity:
+ (0.09 + expression * 0.08) *
+ this.config.colorVoices[selectedColorIndex].velocityMultiplier,
+ startTime,
+ durationSeconds: 0.62 + expression * 0.24,
+ pan: this.getColorPan(selectedColorIndex),
+ delaySend: 0.016 + expression * 0.006,
+ lowpassHz: this.getLowpassHz(profile, midi, expression),
+ });
+ }
+
+ private playGestureAccent(
+ vibe: VibePreset,
+ now: number,
+ selectedColorIndex: GardenAudioColorIndex,
+ strength: number,
+ noteCount: number
+ ): void {
+ const profile = getVibeProfile(this.config, vibe);
+ const pool = COLOR_POOLS[selectedColorIndex];
+ const degrees = this.rotate(pool.scaleDegrees, Math.round(strength * 3));
+
+ for (let index = 0; index < noteCount; index += 1) {
+ const midi = this.chooseMidi(
+ {
+ baseMidi: profile.rootMidi,
+ offsets: degrees
+ .slice(index)
+ .concat(degrees.slice(0, index))
+ .map((degree) => degreeToSemitone(profile, degree)),
+ },
+ pool,
+ this.lastMidiByColor[selectedColorIndex],
+ true
+ );
+
+ this.lastMidiByColor[selectedColorIndex] = midi;
+ this.playNote({
+ midi,
+ velocity:
+ (0.12 + strength * 0.09) *
+ this.config.colorVoices[selectedColorIndex].velocityMultiplier,
+ startTime:
+ now +
+ appConfig.audioEngine.startDelaySeconds +
+ index * GESTURE_ACCENT_SPACING_SECONDS,
+ durationSeconds: 0.48 + strength * 0.22,
+ pan: this.getColorPan(selectedColorIndex),
+ delaySend: 0.012,
+ lowpassHz: this.getLowpassHz(profile, midi, strength),
+ });
+ }
+ }
+
+ private playTouchNote(
+ vibe: VibePreset,
+ now: number,
+ selectedColorIndex: GardenAudioColorIndex,
+ strength: number
+ ): void {
+ const profile = getVibeProfile(this.config, vibe);
+ const pool = COLOR_POOLS[selectedColorIndex];
+ const chord = this.getChord(profile, this.getGlobalBarIndex(now));
+ const chordIntervals = getChordIntervals(chord, false);
+ const rootMidi = profile.rootMidi + chord.rootOffset;
+ const midi = this.chooseMidi(
+ {
+ baseMidi: rootMidi,
+ offsets: this.getSupportOffsets(chordIntervals, selectedColorIndex),
+ },
+ pool,
+ this.lastMidiByColor[selectedColorIndex],
+ true
+ );
+
+ this.lastMidiByColor[selectedColorIndex] = midi;
+ this.lastBrushStreamMidi = midi;
+ this.playNote({
+ midi,
+ velocity:
+ (0.14 + strength * 0.11) *
+ this.config.colorVoices[selectedColorIndex].velocityMultiplier,
+ startTime: now,
+ durationSeconds: 0.55 + strength * 0.18,
+ pan: this.getColorPan(selectedColorIndex),
+ delaySend: 0.006,
+ lowpassHz: this.getLowpassHz(profile, midi, clamp01(0.45 + strength * 0.45)),
+ });
+ }
+
+ private startBrushPhraseLayer({
+ vibe,
+ now,
+ strength,
+ selectedColorIndex,
+ mirrorAmount,
+ }: {
+ vibe: VibePreset;
+ now: number;
+ strength: number;
+ selectedColorIndex: GardenAudioColorIndex;
+ mirrorAmount: number;
+ }): void {
+ const lifetimeSeconds =
+ BRUSH_LAYER_BASE_SECONDS +
+ strength * BRUSH_LAYER_ENERGY_SECONDS +
+ mirrorAmount * BRUSH_LAYER_MIRROR_SECONDS;
+
+ this.brushPhraseLayers.push({
+ vibe,
+ startedAt: now,
+ expiresAt: now + lifetimeSeconds,
+ selectedColorIndex,
+ energy: strength,
+ mirrorAmount,
+ });
+
+ if (this.brushPhraseLayers.length > MAX_BRUSH_PHRASE_LAYERS) {
+ this.brushPhraseLayers = this.brushPhraseLayers.slice(-MAX_BRUSH_PHRASE_LAYERS);
+ }
+ }
+
+ private renderBrushPhraseLayers({
+ vibe,
+ now,
+ lookaheadEnd,
activity,
selectedColorIndex,
}: {
vibe: VibePreset;
- profile: GardenAudioVibeProfile;
- stepIndex: number;
- startTime: number;
+ now: number;
+ lookaheadEnd: number;
activity: number;
selectedColorIndex: GardenAudioColorIndex;
}): void {
- if (
- activity < this.config.rhythm.sparseActivity ||
- stepIndex % this.config.rhythm.stepsPerBeat !== 0
- ) {
- return;
+ const earliestStart = now + appConfig.audioEngine.piano.scheduleAheadSeconds;
+ this.nextBrushStreamAt ??= now + appConfig.audioEngine.startDelaySeconds;
+
+ this.brushPhraseLayers = this.brushPhraseLayers.filter(
+ (layer) => layer.expiresAt > earliestStart
+ );
+
+ while (this.nextBrushStreamAt < earliestStart) {
+ const frame = this.getBrushStreamFrame(this.nextBrushStreamAt, activity);
+ this.nextBrushStreamAt += this.getBrushStreamIntervalSeconds(frame.intensity);
+ this.brushStreamNoteIndex += 1;
}
- const role = COLOR_ROLES[selectedColorIndex];
- const stepInBar = stepIndex % this.config.rhythm.stepsPerBar;
- const beatsPerBar = this.getBeatsPerBar();
- const beatInBar =
- Math.floor(stepInBar / this.config.rhythm.stepsPerBeat) % beatsPerBar;
- const barIndex = Math.floor(stepIndex / this.config.rhythm.stepsPerBar);
- const phraseIndex = Math.floor(barIndex / PHRASE_BAR_COUNT);
- const barInPhrase = barIndex % PHRASE_BAR_COUNT;
-
- if (
- !this.shouldPlayBeat({
- vibeId: vibe.id,
- role,
- activity,
- beatInBar,
- beatsPerBar,
- barInPhrase,
- phraseIndex,
- })
- ) {
- return;
+ while (this.nextBrushStreamAt <= lookaheadEnd) {
+ const frame = this.getBrushStreamFrame(this.nextBrushStreamAt, activity);
+ if (frame.intensity >= BRUSH_LAYER_MIN_INTENSITY) {
+ this.playBrushStreamNote({
+ vibe,
+ startTime: this.nextBrushStreamAt,
+ intensity: frame.intensity,
+ selectedColorIndex: frame.selectedColorIndex ?? selectedColorIndex,
+ });
+ }
+ this.nextBrushStreamAt += this.getBrushStreamIntervalSeconds(frame.intensity);
+ this.brushStreamNoteIndex += 1;
}
-
- const note = this.createNote({
- vibeId: vibe.id,
- profile,
- role,
- stepIndex,
- startTime,
- activity,
- selectedColorIndex,
- beatInBar,
- beatsPerBar,
- barInPhrase,
- phraseIndex,
- });
-
- this.lastMidiByColor[selectedColorIndex] = note.midi;
- this.isWaitingForFirstStrokeNote = false;
- this.playNote(note);
}
- private createNote({
- vibeId,
- profile,
- role,
- stepIndex,
+ private playBrushStreamNote({
+ vibe,
startTime,
- activity,
+ intensity,
selectedColorIndex,
- beatInBar,
- beatsPerBar,
- barInPhrase,
- phraseIndex,
}: {
- vibeId: string;
- profile: GardenAudioVibeProfile;
- role: PianoRole;
- stepIndex: number;
+ vibe: VibePreset;
startTime: number;
- activity: number;
+ intensity: number;
selectedColorIndex: GardenAudioColorIndex;
- beatInBar: number;
- beatsPerBar: number;
- barInPhrase: number;
- phraseIndex: number;
- }): PianoNote {
- const colorVoice = this.config.colorVoices[selectedColorIndex];
- const expression = this.getExpression(activity);
- const midi = this.chooseMidi({
- vibeId,
- profile,
- role,
- stepIndex,
- selectedColorIndex,
- beatInBar,
- beatsPerBar,
- barInPhrase,
- phraseIndex,
+ }): void {
+ const profile = getVibeProfile(this.config, vibe);
+ const pool = COLOR_POOLS[selectedColorIndex];
+ const chord = this.getChord(profile, this.getGlobalBarIndex(startTime));
+ const chordIntervals = getChordIntervals(chord, false);
+ const rootMidi = profile.rootMidi + chord.rootOffset;
+ const useChordTone = this.brushStreamNoteIndex % 4 === 0;
+ const source = useChordTone
+ ? {
+ baseMidi: rootMidi,
+ offsets: this.getSupportOffsets(chordIntervals, selectedColorIndex),
+ }
+ : {
+ baseMidi: profile.rootMidi,
+ offsets: this.rotate(
+ pool.scaleDegrees,
+ this.brushStreamNoteIndex + selectedColorIndex
+ ).map((degree) => degreeToSemitone(profile, degree)),
+ };
+ const midi = this.chooseMidi(source, pool, this.lastBrushStreamMidi, true);
+
+ this.lastBrushStreamMidi = midi;
+ this.lastMidiByColor[selectedColorIndex] = midi;
+ this.playNote({
+ midi,
+ velocity:
+ (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)),
});
- const beatAccent = beatInBar === 0 ? 1.08 : beatInBar === 2 ? 0.97 : 0.9;
- const phrasePosition =
- (barInPhrase * beatsPerBar + beatInBar) / (PHRASE_BAR_COUNT * beatsPerBar);
- const phraseSwell = 0.88 + Math.sin(phrasePosition * Math.PI) * 0.12;
+ }
+
+ private getBrushStreamFrame(
+ startTime: number,
+ activity: number
+ ): {
+ intensity: number;
+ selectedColorIndex: GardenAudioColorIndex | null;
+ } {
+ const layerStates = this.brushPhraseLayers.map((layer) => ({
+ layer,
+ intensity:
+ layer.energy *
+ this.getBrushPhraseFade(layer, startTime) *
+ (0.8 + layer.mirrorAmount * 0.45),
+ }));
+ const dominant = layerStates.reduce<
+ { layer: BrushPhraseLayer; intensity: number } | null
+ >((best, state) => {
+ if (state.intensity <= 0) {
+ return best;
+ }
+ return best === null || state.intensity > best.intensity ? state : best;
+ }, null);
+ const layeredIntensity = layerStates.reduce(
+ (sum, state) => sum + Math.max(0, state.intensity),
+ 0
+ );
return {
- midi,
- velocity: clamp(
- (role.velocityBase + expression * role.velocityActivityScale) *
- colorVoice.velocityMultiplier *
- beatAccent *
- phraseSwell,
- 0.06,
- 0.52
- ),
- durationSeconds:
- role.durationBase +
- expression * role.durationActivityScale +
- (beatInBar === 0 ? role.downbeatDurationBoost : 0),
- pan: clamp(role.pan + colorVoice.panOffset * 0.45, -1, 1),
- delaySend: clamp(role.delaySend * (1.15 - expression * 0.4), 0, 0.04),
- lowpassHz: this.getLowpassHz(profile, midi, expression),
- startTime,
+ intensity: clamp01(activity * 0.45 + layeredIntensity),
+ selectedColorIndex: dominant?.layer.selectedColorIndex ?? null,
};
}
- private chooseMidi({
- vibeId,
- profile,
- role,
- stepIndex,
- selectedColorIndex,
- beatInBar,
- beatsPerBar,
- barInPhrase,
- phraseIndex,
- }: {
- vibeId: string;
- profile: GardenAudioVibeProfile;
- role: PianoRole;
- stepIndex: number;
- selectedColorIndex: GardenAudioColorIndex;
- beatInBar: number;
- beatsPerBar: number;
- barInPhrase: number;
- phraseIndex: number;
- }): number {
- const chord = getChordAtStep(this.config, profile, stepIndex);
- const rootMidi = profile.rootMidi + chord.rootOffset;
- const offsets = this.getPitchOffsets({
- vibeId,
- profile,
- chord,
- selectedColorIndex,
- beatInBar,
- beatsPerBar,
- barInPhrase,
- phraseIndex,
- });
- const previousMidi = this.lastMidiByColor[selectedColorIndex] ?? role.preferredMidi;
- const candidates = this.getCandidates(rootMidi, offsets, role);
+ private getBrushStreamIntervalSeconds(intensity: number): number {
+ const intervalBeats =
+ intensity >= 0.85
+ ? BRUSH_STREAM_MANIC_INTERVAL_BEATS
+ : intensity >= 0.62
+ ? BRUSH_STREAM_INTENSE_INTERVAL_BEATS
+ : intensity >= 0.34
+ ? BRUSH_STREAM_ACTIVE_INTERVAL_BEATS
+ : BRUSH_STREAM_IDLE_INTERVAL_BEATS;
+ return this.getBeatDurationSeconds() * intervalBeats;
+ }
+
+ private getBrushPhraseFade(layer: BrushPhraseLayer, startTime: number): number {
+ const lifetimeSeconds = layer.expiresAt - layer.startedAt;
+ const ageSeconds = startTime - layer.startedAt;
+ return clamp01(1 - ageSeconds / Math.max(0.001, lifetimeSeconds));
+ }
+
+ private chooseMidi(
+ pitchSource: PitchSource,
+ register: Register,
+ previousMidi: number | null = null,
+ avoidRepeat = false
+ ): number {
+ const candidates = this.getCandidates(pitchSource, register);
+ const referenceMidi = previousMidi ?? register.preferredMidi;
if (candidates.length === 0) {
- return clampMidi(rootMidi + offsets[0], role.midiMin, role.midiMax);
+ return register.preferredMidi;
}
return candidates.reduce((best, candidate) =>
- this.scoreCandidate(candidate, role, previousMidi) <
- this.scoreCandidate(best, role, previousMidi)
+ this.scoreCandidate(candidate, register, referenceMidi, avoidRepeat) <
+ this.scoreCandidate(best, register, referenceMidi, avoidRepeat)
? candidate
: best
).midi;
}
- private getPitchOffsets({
- vibeId,
- profile,
- chord,
- selectedColorIndex,
- beatInBar,
- beatsPerBar,
- barInPhrase,
- phraseIndex,
- }: {
- vibeId: string;
- profile: GardenAudioVibeProfile;
- chord: GardenAudioChord;
- selectedColorIndex: GardenAudioColorIndex;
- beatInBar: number;
- beatsPerBar: number;
- barInPhrase: number;
- phraseIndex: number;
- }): Array {
- const chordIntervals = getChordIntervals(chord, false);
- const phraseBeat = barInPhrase * beatsPerBar + beatInBar;
- const contour = this.getPhraseContour(
- vibeId,
- phraseIndex,
- selectedColorIndex,
- phraseBeat
- );
- const colorVoice = this.config.colorVoices[selectedColorIndex];
-
- if (selectedColorIndex === 0) {
- return beatInBar === 0 ? [0, 7, 12] : [7, 0, 12];
- }
-
- if (selectedColorIndex === 1) {
- return [
- chordIntervals[1],
- chordIntervals[2],
- degreeToSemitone(profile, 2 + colorVoice.scaleDegreeOffset + contour),
- chordIntervals[1] + 12,
- ];
- }
-
- const degreeBase = 2 + colorVoice.scaleDegreeOffset + contour;
- return [
- degreeToSemitone(profile, degreeBase),
- degreeToSemitone(profile, degreeBase + 2),
- 12 + degreeToSemitone(profile, degreeBase - 1),
- 12 + degreeToSemitone(profile, degreeBase + 1),
- ];
- }
-
private getCandidates(
- rootMidi: number,
- offsets: ReadonlyArray,
- role: PianoRole
+ pitchSource: PitchSource,
+ register: Register
): Array {
const candidates: Array = [];
- offsets.forEach((offset, preference) => {
+ pitchSource.offsets.forEach((offset, preference) => {
for (let octave = -3; octave <= 3; octave += 1) {
- const midi = rootMidi + offset + octave * 12;
- if (midi >= role.midiMin && midi <= role.midiMax) {
+ const midi = pitchSource.baseMidi + offset + octave * 12;
+ if (midi >= register.midiMin && midi <= register.midiMax) {
candidates.push({ midi: Math.round(midi), preference });
}
}
@@ -508,86 +776,71 @@ export class GenerativePianoEngine {
private scoreCandidate(
candidate: PitchCandidate,
- role: PianoRole,
- previousMidi: number
+ register: Register,
+ previousMidi: number,
+ avoidRepeat: boolean
): number {
return (
Math.abs(candidate.midi - previousMidi) +
- Math.abs(candidate.midi - role.preferredMidi) * NOTE_SCORE_REGISTER_WEIGHT +
- candidate.preference * NOTE_SCORE_PREFERENCE_WEIGHT
+ Math.abs(candidate.midi - register.preferredMidi) * NOTE_SCORE_REGISTER_WEIGHT +
+ candidate.preference * NOTE_SCORE_PREFERENCE_WEIGHT +
+ (avoidRepeat && candidate.midi === previousMidi ? NOTE_SCORE_REPEAT_PENALTY : 0)
);
}
- private shouldPlayBeat({
- vibeId,
- role,
- activity,
- beatInBar,
- beatsPerBar,
- barInPhrase,
- phraseIndex,
- }: {
- vibeId: string;
- role: PianoRole;
- activity: number;
- beatInBar: number;
- beatsPerBar: number;
- barInPhrase: number;
- phraseIndex: number;
- }): boolean {
- const expression = this.getExpression(activity);
- const beats =
- expression < 0.34
- ? role.lowBeats
- : expression < 0.68
- ? role.mediumBeats
- : role.highBeats;
-
- if (!beats.includes(beatInBar)) {
- return false;
- }
-
- const phraseBeat = barInPhrase * beatsPerBar + beatInBar;
- if (
- expression < 0.34 &&
- phraseBeat % role.lowPhraseSpacing !== role.lowPhraseOffset
- ) {
- return false;
- }
-
- const keepChance =
- expression < 0.34
- ? role.lowKeepChance
- : expression < 0.68
- ? role.mediumKeepChance
- : role.highKeepChance;
- const gate = hashUnit(vibeId, role.name, phraseIndex, barInPhrase, beatInBar);
-
- if (this.isWaitingForFirstStrokeNote && beatInBar === 0) {
+ private shouldPlaySupport(expression: number, barIndex: number): boolean {
+ if (expression >= 0.55) {
return true;
}
- if (beatInBar === 0 && role.name !== 'spark' && expression >= 0.52) {
- return true;
- }
-
- return gate <= keepChance;
+ return barIndex % SUPPORT_BAR_SPACING === SUPPORT_BAR_OFFSET;
}
- private getPhraseContour(
- vibeId: string,
- phraseIndex: number,
- selectedColorIndex: GardenAudioColorIndex,
- phraseBeat: number
- ): number {
+ private shouldPlayTexture(expression: number, barIndex: number): boolean {
+ const spacing =
+ expression < 0.35
+ ? IDLE_TEXTURE_BAR_SPACING
+ : expression < 0.7
+ ? MEDIUM_TEXTURE_BAR_SPACING
+ : 1;
+
+ return barIndex % spacing === (spacing === 1 ? 0 : 1);
+ }
+
+ private getSupportOffsets(
+ chordIntervals: ReadonlyArray,
+ selectedColorIndex: GardenAudioColorIndex
+ ): Array {
if (selectedColorIndex === 0) {
- return 0;
+ return [0, chordIntervals[2], 12];
}
- const contourIndex =
- hashInt(vibeId, phraseIndex, selectedColorIndex) % PHRASE_CONTOURS.length;
- const contour = PHRASE_CONTOURS[contourIndex];
- return contour[phraseBeat % contour.length];
+ if (selectedColorIndex === 1) {
+ return [chordIntervals[1], chordIntervals[2], 0, 12];
+ }
+
+ return [chordIntervals[2], 12, chordIntervals[3], chordIntervals[1] + 12];
+ }
+
+ private getChord(
+ profile: GardenAudioVibeProfile,
+ barIndex: number
+ ): GardenAudioChord {
+ const progressionIndex =
+ Math.floor(barIndex / CHORD_BARS) % profile.progression.length;
+ return profile.progression[progressionIndex];
+ }
+
+ private getGlobalBarIndex(startTime: number): number {
+ const timelineStartedAt = this.timelineStartedAt ?? startTime;
+ const elapsedSeconds = Math.max(0, startTime - timelineStartedAt);
+ return Math.floor(elapsedSeconds / this.getBarDurationSeconds());
+ }
+
+ private getColorPan(selectedColorIndex: GardenAudioColorIndex): number {
+ const pool = COLOR_POOLS[selectedColorIndex];
+ const colorVoice = this.config.colorVoices[selectedColorIndex];
+ return clamp(pool.pan + colorVoice.panOffset * 0.35, -1, 1);
}
private getLowpassHz(
@@ -595,41 +848,28 @@ export class GenerativePianoEngine {
midi: number,
expression: number
): number {
- const midiLift = clamp01((midi - 48) / 36) * 1100;
+ const midiLift = clamp01((midi - 48) / 33) * 720;
return clamp(
- this.config.piano.lowpassHz * profile.brightness * (0.44 + expression * 0.44) +
+ this.config.piano.lowpassHz * profile.brightness * (0.58 + expression * 0.32) +
midiLift,
appConfig.audioEngine.piano.lowpassMinHz,
appConfig.audioEngine.piano.lowpassMaxHz
);
}
- private updatePace(now: number, activity: number): void {
- if (this.lastPaceUpdateAt === null) {
- this.lastPaceUpdateAt = now;
- return;
- }
-
- const elapsedSeconds = Math.max(0, now - this.lastPaceUpdateAt);
- this.lastPaceUpdateAt = now;
- const targetPace = PACE_MIN + (PACE_MAX - PACE_MIN) * this.getExpression(activity);
- const amount = 1 - Math.exp(-elapsedSeconds / PACE_RAMP_SECONDS);
- this.pace += (targetPace - this.pace) * amount;
- }
-
- private skipLateSteps(now: number, stepSeconds: number): void {
- if (this.nextStepAt === null) {
+ private skipLateBeats(now: number, beatSeconds: number): void {
+ if (this.nextBeatAt === null) {
return;
}
const earliestStart = now + appConfig.audioEngine.piano.scheduleAheadSeconds;
- if (this.nextStepAt >= earliestStart) {
+ if (this.nextBeatAt >= earliestStart) {
return;
}
- const skippedSteps = Math.floor((earliestStart - this.nextStepAt) / stepSeconds) + 1;
- this.nextStepAt += skippedSteps * stepSeconds;
- this.stepIndex += skippedSteps;
+ const skippedBeats = Math.floor((earliestStart - this.nextBeatAt) / beatSeconds) + 1;
+ this.nextBeatAt += skippedBeats * beatSeconds;
+ this.beatIndex += skippedBeats;
}
private getExpression(activity: number): number {
@@ -639,17 +879,12 @@ export class GenerativePianoEngine {
);
}
- private getStepDurationSeconds(): number {
- return 60 / this.config.rhythm.bpm / this.config.rhythm.stepsPerBeat / this.pace;
+ private getBeatDurationSeconds(): number {
+ return 60 / this.config.rhythm.bpm;
}
- private getNextDownbeatStepIndex(): number {
- const stepInBar = this.stepIndex % this.config.rhythm.stepsPerBar;
- if (stepInBar === 0) {
- return this.stepIndex;
- }
-
- return this.stepIndex + this.config.rhythm.stepsPerBar - stepInBar;
+ private getBarDurationSeconds(): number {
+ return this.getBeatDurationSeconds() * this.getBeatsPerBar();
}
private getBeatsPerBar(): number {
@@ -658,17 +893,8 @@ export class GenerativePianoEngine {
Math.round(this.config.rhythm.stepsPerBar / this.config.rhythm.stepsPerBeat)
);
}
-}
-const hashUnit = (...parts: Array): number =>
- hashInt(...parts) / 0xffffffff;
-
-const hashInt = (...parts: Array): number => {
- let hash = 2166136261;
- const input = parts.join(':');
- for (let index = 0; index < input.length; index += 1) {
- hash ^= input.charCodeAt(index);
- hash = Math.imul(hash, 16777619);
+ private rotate(values: ReadonlyArray, offset: number): Array {
+ return values.map((_, index) => values[(index + offset) % values.length]);
}
- return hash >>> 0;
-};
+}
diff --git a/src/audio/noise-burst-player.ts b/src/audio/noise-burst-player.ts
index 76c4e3a..3280978 100644
--- a/src/audio/noise-burst-player.ts
+++ b/src/audio/noise-burst-player.ts
@@ -1,9 +1,12 @@
-import { appConfig } from '../config';
+import type { GardenAudioEngineConfig } from '../config';
import { GardenAudioGraph } from './garden-audio-graph';
import { NoiseBurst } from './garden-audio-types';
export class NoiseBurstPlayer {
- public constructor(private readonly graph: GardenAudioGraph) {}
+ public constructor(
+ private readonly engineConfig: GardenAudioEngineConfig,
+ private readonly graph: GardenAudioGraph
+ ) {}
public play({ startTime, durationSeconds, gain, filterHz, pan }: NoiseBurst): void {
const { context, eventBus, noiseBuffer } = this.graph;
@@ -12,7 +15,7 @@ export class NoiseBurstPlayer {
}
const scheduledStart = Math.max(
- context.currentTime + appConfig.audioEngine.noiseBurst.scheduleAheadSeconds,
+ context.currentTime + this.engineConfig.noiseBurst.scheduleAheadSeconds,
startTime
);
const source = context.createBufferSource();
@@ -24,17 +27,17 @@ export class NoiseBurstPlayer {
source.buffer = noiseBuffer;
filter.type = 'bandpass';
filter.frequency.setValueAtTime(filterHz, scheduledStart);
- filter.Q.value = appConfig.audioEngine.noiseBurst.filterQ;
+ filter.Q.value = this.engineConfig.noiseBurst.filterQ;
envelope.gain.setValueAtTime(
- appConfig.audioEngine.noiseBurst.silentGain,
+ this.engineConfig.noiseBurst.silentGain,
scheduledStart
);
envelope.gain.exponentialRampToValueAtTime(
- Math.max(appConfig.audioEngine.noiseBurst.silentGain, gain),
- scheduledStart + appConfig.audioEngine.noiseBurst.attackSeconds
+ Math.max(this.engineConfig.noiseBurst.silentGain, gain),
+ scheduledStart + this.engineConfig.noiseBurst.attackSeconds
);
envelope.gain.exponentialRampToValueAtTime(
- appConfig.audioEngine.noiseBurst.silentGain,
+ this.engineConfig.noiseBurst.silentGain,
stopAt
);
panner.pan.setValueAtTime(pan, scheduledStart);
@@ -45,7 +48,7 @@ export class NoiseBurstPlayer {
panner.connect(eventBus);
source.start(
scheduledStart,
- Math.random() * appConfig.audioEngine.noiseBurst.offsetRandomSeconds
+ Math.random() * this.engineConfig.noiseBurst.offsetRandomSeconds
);
source.stop(stopAt);
source.addEventListener(
diff --git a/src/audio/piano-sampler.ts b/src/audio/piano-sampler.ts
index 25fedc7..d0a678e 100644
--- a/src/audio/piano-sampler.ts
+++ b/src/audio/piano-sampler.ts
@@ -1,4 +1,4 @@
-import { appConfig } from '../config';
+import type { GardenAudioEngineConfig } from '../config';
import { clamp, clamp01 } from '../utils/clamp';
import { GardenAudioConfig } from './garden-audio-config';
import { GardenAudioGraph } from './garden-audio-graph';
@@ -12,6 +12,7 @@ export class PianoSampler {
public constructor(
private readonly config: GardenAudioConfig,
+ private readonly engineConfig: GardenAudioEngineConfig,
private readonly graph: GardenAudioGraph
) {}
@@ -51,31 +52,39 @@ export class PianoSampler {
lowpassHz = this.config.piano.lowpassHz,
}: PianoNote): void {
const { context, eventBus, delayInput } = this.graph;
- if (!context || !eventBus || this.samples.length === 0) {
+ if (!context || !eventBus) {
return;
}
const sample = this.findNearestSample(midi);
if (!sample) {
+ this.playFallbackPluck({
+ midi,
+ velocity,
+ startTime,
+ durationSeconds,
+ pan,
+ delaySend,
+ lowpassHz,
+ });
return;
}
const scheduledStart = Math.max(
- context.currentTime + appConfig.audioEngine.piano.scheduleAheadSeconds,
+ context.currentTime + this.engineConfig.piano.scheduleAheadSeconds,
startTime
);
const noteVelocity = clamp01(velocity);
const noteGainValue = Math.max(
- appConfig.audioEngine.piano.minGain,
+ this.engineConfig.piano.minGain,
this.config.piano.gain * noteVelocity
);
const sustainSeconds =
this.config.piano.sustainSeconds *
- (appConfig.audioEngine.piano.sustainBase +
- noteVelocity * appConfig.audioEngine.piano.sustainVelocityRange);
+ (this.engineConfig.piano.sustainBase +
+ noteVelocity * this.engineConfig.piano.sustainVelocityRange);
const sustainAt =
- scheduledStart +
- Math.max(appConfig.audioEngine.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;
@@ -90,20 +99,18 @@ export class PianoSampler {
const oldest = this.activeVoices.shift();
oldest?.gain.gain.cancelScheduledValues(scheduledStart);
oldest?.gain.gain.setTargetAtTime(
- appConfig.audioEngine.piano.minGain,
+ this.engineConfig.piano.minGain,
scheduledStart,
- appConfig.audioEngine.piano.voiceStealFadeSeconds
- );
- oldest?.source.stop(
- scheduledStart + appConfig.audioEngine.piano.voiceStealStopSeconds
+ this.engineConfig.piano.voiceStealFadeSeconds
);
+ oldest?.source.stop(scheduledStart + this.engineConfig.piano.voiceStealStopSeconds);
}
source.buffer = sample.buffer;
source.playbackRate.setValueAtTime(
Math.pow(
2,
- (midi - sample.midi) / appConfig.audioEngine.piano.pitchSemitonesPerOctave
+ (midi - sample.midi) / this.engineConfig.piano.pitchSemitonesPerOctave
),
scheduledStart
);
@@ -111,30 +118,30 @@ export class PianoSampler {
filter.frequency.setValueAtTime(
clamp(
lowpassHz,
- appConfig.audioEngine.piano.lowpassMinHz,
- appConfig.audioEngine.piano.lowpassMaxHz
+ this.engineConfig.piano.lowpassMinHz,
+ this.engineConfig.piano.lowpassMaxHz
),
scheduledStart
);
- filter.Q.value = appConfig.audioEngine.piano.filterQ;
- gain.gain.setValueAtTime(appConfig.audioEngine.piano.minGain, scheduledStart);
+ filter.Q.value = this.engineConfig.piano.filterQ;
+ gain.gain.setValueAtTime(this.engineConfig.piano.minGain, scheduledStart);
gain.gain.exponentialRampToValueAtTime(
noteGainValue,
- scheduledStart + appConfig.audioEngine.piano.gainAttackSeconds
+ scheduledStart + this.engineConfig.piano.gainAttackSeconds
);
gain.gain.setTargetAtTime(
Math.max(
- appConfig.audioEngine.piano.minGain,
+ this.engineConfig.piano.minGain,
noteGainValue * this.config.piano.sustainLevel
),
sustainAt,
Math.max(
- appConfig.audioEngine.piano.minFadeSeconds,
- sustainSeconds * appConfig.audioEngine.piano.sustainBase
+ this.engineConfig.piano.minFadeSeconds,
+ sustainSeconds * this.engineConfig.piano.sustainBase
)
);
gain.gain.setTargetAtTime(
- appConfig.audioEngine.piano.minGain,
+ this.engineConfig.piano.minGain,
releaseAt,
releaseSeconds
);
@@ -153,7 +160,7 @@ export class PianoSampler {
}
source.start(scheduledStart);
- source.stop(stopAt + appConfig.audioEngine.piano.tailStopExtraSeconds);
+ source.stop(stopAt + this.engineConfig.piano.tailStopExtraSeconds);
this.activeVoices.push({ gain, source, startAt: scheduledStart, stopAt });
source.addEventListener(
@@ -170,41 +177,6 @@ export class PianoSampler {
);
}
- public fadeActive(
- now: number,
- fadeSeconds = appConfig.audioEngine.piano.defaultFadeSeconds
- ): void {
- this.activeVoices.forEach((voice) => {
- voice.gain.gain.cancelScheduledValues(now);
- if (voice.startAt > now) {
- voice.gain.gain.setValueAtTime(appConfig.audioEngine.piano.minGain, now);
- voice.stopAt = now;
- try {
- voice.source.stop(now);
- } catch {
- // The source may already have a stop time scheduled.
- }
- return;
- }
-
- const stopAt = Math.min(voice.stopAt, now + fadeSeconds);
- voice.gain.gain.setTargetAtTime(
- appConfig.audioEngine.piano.minGain,
- now,
- Math.max(
- appConfig.audioEngine.piano.minFadeSeconds,
- fadeSeconds * appConfig.audioEngine.piano.fadeTimeConstantRatio
- )
- );
- voice.stopAt = stopAt;
- try {
- voice.source.stop(stopAt);
- } catch {
- // The source may already have a stop time scheduled.
- }
- });
- }
-
public reset(): void {
this.sampleLoadPromise = null;
this.samples = [];
@@ -224,4 +196,90 @@ 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 }
+ );
+ }
}
diff --git a/src/audio/piano-samples.ts b/src/audio/piano-samples.ts
index 27834fb..097feab 100644
--- a/src/audio/piano-samples.ts
+++ b/src/audio/piano-samples.ts
@@ -1,4 +1,4 @@
-export interface PianoSampleDefinition {
+interface PianoSampleDefinition {
midi: number;
url: string;
}
diff --git a/src/config.ts b/src/config.ts
index 825daa6..d45cb61 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -1,463 +1,23 @@
-import type {
- GardenAudioChord,
- GardenAudioConfig,
- GardenAudioVibeProfile,
-} from './audio/garden-audio-config';
-import type { GameLoopSettings } from './game-loop/game-loop-settings';
-import type { AgentSettings } from './pipelines/agents/agent-settings';
-import type { BrushSettings } from './pipelines/brush/brush-settings';
-import type { DiffusionSettings } from './pipelines/diffusion/diffusion-settings';
-import type { RenderSettings } from './pipelines/render/render-settings';
+import { runtimeSettings } from './config/runtime-settings';
+import type { GardenAppConfig } from './config/types';
+import { audioVibes, defaultVibeId, vibePresets } from './config/vibe-presets';
-export type GardenRuntimeSettings = GameLoopSettings &
- AgentSettings &
- BrushSettings &
- DiffusionSettings &
- RenderSettings;
+export type {
+ AgentColorInteractionSettings,
+ GardenAppConfig,
+ GardenAudioEngineConfig,
+ GardenRuntimeSettings,
+ GardenSimulationConfig,
+ GardenStorageConfig,
+ GardenVibeSettings,
+ NumberControlConfig,
+ RuntimeSettingControlConfig,
+ VibePreset,
+} from './config/types';
-export type GardenVibeSettings = Partial<
- Pick<
- GardenRuntimeSettings,
- | 'agentBudgetMax'
- | 'brushSize'
- | 'clarity'
- | 'decayRateTrails'
- | 'diffusionRateTrails'
- | 'individualTrailWeight'
- | 'moveSpeed'
- | 'sensorOffsetAngle'
- | 'sensorOffsetDistance'
- | 'spawnPerPixel'
- | 'turnSpeed'
- >
->;
-
-export interface VibePreset {
- id: string;
- name: string;
- colors: [string, string, string];
- backgroundColor: string;
- settings: GardenVibeSettings;
- audio: GardenAudioVibeProfile;
-}
-
-export interface NumberControlConfig {
- folder: string;
- integer?: boolean;
- label?: string;
- max: number;
- min: number;
- step?: number;
-}
-
-export type RuntimeSettingControlConfig = {
- [Key in keyof GardenRuntimeSettings]: NumberControlConfig;
-};
-
-export interface GardenAppConfig {
- audio: GardenAudioConfig;
- audioEngine: {
- energy: {
- attackSeconds: number;
- decaySeconds: number;
- releaseSeconds: number;
- strokeDecaySeconds: number;
- };
- eraser: {
- canvasWidthRatioForFullSize: number;
- defaultSizePixels: number;
- durationSeconds: number;
- filterPressureWeight: number;
- filterSizeWeight: number;
- filterSpeedWeight: number;
- gainBase: number;
- gainPressureWeight: number;
- gainSizeWeight: number;
- gainSpeedWeight: number;
- };
- delay: {
- erasingActivity: number;
- };
- gestureFadeSeconds: number;
- graph: {
- closeGain: number;
- closeRampSeconds: number;
- delayActivityFeedbackWeight: number;
- delayFeedbackMax: number;
- delayFeedbackMin: number;
- delayOutputActivityWeight: number;
- delayOutputBase: number;
- delayTimeRampSeconds: number;
- eventBusGain: number;
- noiseMax: number;
- noiseMin: number;
- unlockBufferLength: number;
- unlockSampleRate: number;
- };
- input: {
- distanceEnergyBase: number;
- distanceEnergyScale: number;
- distanceForFullEnergyPixels: number;
- fallbackFrameSeconds: number;
- penMinPressure: number;
- strokeEnergyBase: number;
- strokeEnergyPressureWeight: number;
- strokeEnergySpeedWeight: number;
- };
- muteGain: number;
- muteRampSeconds: number;
- noiseBurst: {
- attackSeconds: number;
- filterQ: number;
- offsetRandomSeconds: number;
- scheduleAheadSeconds: number;
- silentGain: number;
- };
- piano: {
- fadeStopExtraSeconds: number;
- defaultFadeSeconds: number;
- fadeTimeConstantRatio: number;
- filterQ: number;
- gainAttackSeconds: number;
- lowpassMaxHz: number;
- lowpassMinHz: number;
- minDurationSeconds: number;
- minFadeSeconds: number;
- minGain: number;
- pitchSemitonesPerOctave: number;
- scheduleAheadSeconds: number;
- sustainBase: number;
- sustainVelocityRange: number;
- tailStopExtraSeconds: number;
- voiceStealFadeSeconds: number;
- voiceStealStopSeconds: number;
- };
- startDelaySeconds: number;
- vibeChangeStingerMinIntervalSeconds: number;
- };
- deltaTime: {
- fpsExponentialDecayStrength: number;
- maxDeltaTimeSeconds: number;
- minDeltaTimeSeconds: number;
- };
- export4k: {
- bytesPerPixel: number;
- height: number;
- jsHeapSafetyMultiplier: number;
- lowMemoryDeviceGiB: number;
- lowMemoryExportFraction: number;
- rowAlignmentBytes: number;
- width: number;
- };
- menuHider: {
- bottomRevealDistancePx: number;
- intervalMs: number;
- timeToLiveMs: number;
- };
- pipelines: {
- brush: {
- maxLineCount: number;
- };
- diffusion: {
- minDiffusionRate: number;
- };
- eraser: {
- maxSegmentCount: number;
- maxTextureLineCount: number;
- segmentFloatCount: number;
- workgroupSize: number;
- };
- };
- runtimeSettings: {
- controls: RuntimeSettingControlConfig;
- defaults: GardenRuntimeSettings;
- };
- simulation: {
- budget: {
- fpsHeadroom: number;
- fpsSmoothingNew: number;
- fpsSmoothingRetain: number;
- initialTargetAgentBudget: number;
- rampAgentsPerSecond: number;
- refreshTargetDecay: number;
- };
- brushEffectFramesPerSecond: number;
- globalAgentCap: number;
- initialAgentCount: number;
- intro: {
- angleJitterRadians: number;
- circleMaxSideRatio: number;
- circleMinSideRatio: number;
- drawHintClass: string;
- drawHintDelayMs: number;
- durationSeconds: number;
- entryJitterSideRatio: number;
- fontScaleDown: number;
- initialFontHeightRatio: number;
- initialFontWidthRatio: number;
- letterSpacingEm: number;
- maskAlphaThreshold: number;
- maskGradientThreshold: number;
- maskSampleDensity: number;
- maxHeightRatio: number;
- maxWidthRatio: number;
- minEntryJitterPx: number;
- minFontSizePx: number;
- minTargetJitterPx: number;
- radialJitterRatio: number;
- targetDelayDistanceMultiplier: number;
- targetDelayMax: number;
- targetDelayRandomMultiplier: number;
- targetJitterSideRatio: number;
- title: string;
- titleColorCutLetters: [number, number];
- titleRadiusMultiplier: number;
- titleStrokeWidthMinPx: number;
- titleStrokeWidthRatio: number;
- verticalAnchor: number;
- };
- introCameraZoom: number;
- introMoveSpeedBaseMultiplier: number;
- introMoveSpeedProgressMultiplier: number;
- maxMirrorSegmentCount: number;
- stroke: {
- angleJitterRadians: number;
- densityMultiplier: number;
- maxAgentCount: number;
- minAgentCount: number;
- };
- };
- storage: {
- audioMutedKey: string;
- vibeKey: string;
- };
- telemetry: {
- enabled: boolean;
- intervalMs: number;
- };
- toolbar: {
- eraser: {
- controlScaleMax: number;
- controlScaleMin: number;
- default: number;
- max: number;
- min: number;
- step: number;
- };
- mirror: {
- default: number;
- max: number;
- min: number;
- names: Record;
- step: number;
- };
- };
- tuningPane: {
- expandedDepth: number;
- startHidden: boolean;
- title: string;
- };
- vibes: {
- defaultVibeId: string;
- presets: Array;
- };
-}
-
-const majorProgression: Array = [
- { rootOffset: 0, quality: 'major' },
- { rootOffset: 9, quality: 'minor' },
- { rootOffset: 5, quality: 'major' },
- { rootOffset: 7, quality: 'major' },
-];
-
-const minorProgression: Array = [
- { rootOffset: 0, quality: 'minor' },
- { rootOffset: 8, quality: 'major' },
- { rootOffset: 3, quality: 'major' },
- { rootOffset: 10, quality: 'major' },
-];
-
-const majorPentatonic = [0, 2, 4, 7, 9];
-const minorPentatonic = [0, 3, 5, 7, 10];
-
-const defaultVibeId = 'candy-rain';
-
-const vibePresets: Array = [
- {
- id: 'candy-rain',
- name: 'Candy Rain',
- colors: ['#ff5da2', '#36d7d0', '#ffd84d'],
- backgroundColor: '#10151f',
- settings: {
- agentBudgetMax: 1_000_000,
- brushSize: 14,
- clarity: 0.62,
- decayRateTrails: 965,
- diffusionRateTrails: 0.22,
- individualTrailWeight: 0.07,
- moveSpeed: 82,
- sensorOffsetAngle: 34,
- sensorOffsetDistance: 38,
- spawnPerPixel: 0.22,
- turnSpeed: 58,
- },
- audio: {
- rootMidi: 57,
- scale: majorPentatonic,
- brightness: 1.04,
- delayTimeMultiplier: 0.92,
- progression: majorProgression,
- },
- },
- {
- id: 'sunlit-moss',
- name: 'Sunlit Moss',
- colors: ['#83d483', '#f6d76b', '#5ec1a1'],
- backgroundColor: '#172016',
- settings: {
- agentBudgetMax: 900_000,
- brushSize: 16,
- clarity: 0.68,
- decayRateTrails: 975,
- diffusionRateTrails: 0.18,
- individualTrailWeight: 0.06,
- moveSpeed: 70,
- sensorOffsetAngle: 28,
- sensorOffsetDistance: 46,
- spawnPerPixel: 0.18,
- turnSpeed: 44,
- },
- audio: {
- rootMidi: 53,
- scale: majorPentatonic,
- brightness: 0.92,
- delayTimeMultiplier: 1.08,
- progression: [
- { rootOffset: 0, quality: 'major' },
- { rootOffset: 7, quality: 'major' },
- { rootOffset: 9, quality: 'minor' },
- { rootOffset: 5, quality: 'major' },
- ],
- },
- },
- {
- id: 'coral-tide',
- name: 'Coral Tide',
- colors: ['#ff7f6e', '#40b8ff', '#f4f0a6'],
- backgroundColor: '#0f1822',
- settings: {
- agentBudgetMax: 1_000_000,
- brushSize: 13,
- clarity: 0.58,
- decayRateTrails: 955,
- diffusionRateTrails: 0.28,
- individualTrailWeight: 0.055,
- moveSpeed: 90,
- sensorOffsetAngle: 36,
- sensorOffsetDistance: 35,
- spawnPerPixel: 0.25,
- turnSpeed: 62,
- },
- audio: {
- rootMidi: 50,
- scale: minorPentatonic,
- brightness: 1,
- delayTimeMultiplier: 1.12,
- progression: minorProgression,
- },
- },
- {
- id: 'moon-orchid',
- name: 'Moon Orchid',
- colors: ['#c993ff', '#7dd8ff', '#f0f4ff'],
- backgroundColor: '#14121d',
- settings: {
- agentBudgetMax: 850_000,
- brushSize: 12,
- clarity: 0.64,
- decayRateTrails: 968,
- diffusionRateTrails: 0.2,
- individualTrailWeight: 0.065,
- moveSpeed: 76,
- sensorOffsetAngle: 32,
- sensorOffsetDistance: 42,
- spawnPerPixel: 0.2,
- turnSpeed: 52,
- },
- audio: {
- rootMidi: 49,
- scale: minorPentatonic,
- brightness: 0.9,
- delayTimeMultiplier: 1.24,
- progression: minorProgression,
- },
- },
- {
- id: 'peach-neon',
- name: 'Peach Neon',
- colors: ['#ff9b73', '#5bf0a9', '#6ea8ff'],
- backgroundColor: '#191716',
- settings: {
- agentBudgetMax: 1_000_000,
- brushSize: 15,
- clarity: 0.55,
- decayRateTrails: 948,
- diffusionRateTrails: 0.32,
- individualTrailWeight: 0.05,
- moveSpeed: 96,
- sensorOffsetAngle: 40,
- sensorOffsetDistance: 32,
- spawnPerPixel: 0.24,
- turnSpeed: 70,
- },
- audio: {
- rootMidi: 56,
- scale: majorPentatonic,
- brightness: 1.08,
- delayTimeMultiplier: 0.86,
- progression: majorProgression,
- },
- },
- {
- id: 'frost-bloom',
- name: 'Frost Bloom',
- colors: ['#b4f7ff', '#9ec8ff', '#ffb8d2'],
- backgroundColor: '#101820',
- settings: {
- agentBudgetMax: 750_000,
- brushSize: 18,
- clarity: 0.7,
- decayRateTrails: 982,
- diffusionRateTrails: 0.14,
- individualTrailWeight: 0.075,
- moveSpeed: 62,
- sensorOffsetAngle: 26,
- sensorOffsetDistance: 52,
- spawnPerPixel: 0.16,
- turnSpeed: 40,
- },
- audio: {
- rootMidi: 62,
- scale: majorPentatonic,
- brightness: 0.88,
- delayTimeMultiplier: 1.32,
- progression: [
- { rootOffset: 0, quality: 'major' },
- { rootOffset: 5, quality: 'major' },
- { rootOffset: 9, quality: 'minor' },
- { rootOffset: 7, quality: 'major' },
- ],
- },
- },
-];
-
-const audioVibes = Object.fromEntries(
- vibePresets.map((vibe) => [vibe.id, vibe.audio])
-) as Record;
-
-export const appConfig: GardenAppConfig = {
+export const appConfig = {
audio: {
- masterVolume: 0.32,
+ masterVolume: 0.42,
fadeInSeconds: 0.45,
updateRampSeconds: 0.08,
highPassFrequencyHz: 45,
@@ -470,26 +30,26 @@ export const appConfig: GardenAppConfig = {
releaseSeconds: 0.18,
},
delay: {
- timeSeconds: 0.42,
+ timeSeconds: 0.46,
feedback: 0.12,
- wetGain: 0.048,
+ wetGain: 0.044,
},
piano: {
- maxVoices: 32,
- gain: 0.42,
- sustainSeconds: 0.52,
- sustainLevel: 0.34,
- releaseSeconds: 0.16,
- lowpassHz: 9000,
+ maxVoices: 24,
+ gain: 0.48,
+ sustainSeconds: 0.42,
+ sustainLevel: 0.32,
+ releaseSeconds: 0.24,
+ lowpassHz: 7600,
},
input: {
pressureFallback: 0.48,
},
rhythm: {
- bpm: 82,
+ bpm: 74,
stepsPerBeat: 4,
stepsPerBar: 16,
- lookaheadSeconds: 0.18,
+ lookaheadSeconds: 0.3,
speedForFullEnergyPixelsPerSecond: 1800,
sparseActivity: 0.055,
},
@@ -540,7 +100,6 @@ export const appConfig: GardenAppConfig = {
delay: {
erasingActivity: 0.12,
},
- gestureFadeSeconds: 1.35,
graph: {
closeGain: 0.0001,
closeRampSeconds: 0.015,
@@ -576,9 +135,6 @@ export const appConfig: GardenAppConfig = {
silentGain: 0.0001,
},
piano: {
- fadeStopExtraSeconds: 0.05,
- defaultFadeSeconds: 0.9,
- fadeTimeConstantRatio: 0.3,
filterQ: 0.7,
gainAttackSeconds: 0.006,
lowpassMaxHz: 12000,
@@ -630,190 +186,12 @@ export const appConfig: GardenAppConfig = {
workgroupSize: 64,
},
},
- runtimeSettings: {
- defaults: {
- agentBudgetMax: 1_000_000,
- agentCount: 0,
- selectedColorIndex: 0,
- spawnPerPixel: 0.22,
-
- moveSpeed: 82,
- turnSpeed: 58,
- sensorOffsetAngle: 34,
- sensorOffsetDistance: 38,
- turnWhenLost: 0.8,
-
- individualTrailWeight: 0.07,
-
- diffusionRateTrails: 0.22,
- decayRateTrails: 965,
- diffusionRateBrush: 0.35,
- decayRateBrush: 18,
- brushEffectDuration: 8,
-
- clarity: 0.62,
- brushSize: 14,
- eraserSize: 96,
- mirrorSegmentCount: 1,
-
- brushSizeVariation: 0.5,
-
- startColorHue: 200,
-
- renderSpeed: 1,
- simulatedDelayMs: 0,
- },
- controls: {
- agentBudgetMax: {
- folder: 'Runtime',
- integer: true,
- min: 1_000,
- max: 1_000_000,
- step: 1_000,
- },
- agentCount: {
- folder: 'Runtime',
- integer: true,
- min: 0,
- max: 1_000_000,
- step: 1_000,
- },
- brushEffectDuration: {
- folder: 'Diffusion',
- min: 0.5,
- max: 20,
- step: 0.05,
- },
- brushSize: {
- folder: 'Brush',
- min: 1,
- max: 60,
- step: 0.25,
- },
- brushSizeVariation: {
- folder: 'Brush',
- min: 0,
- max: 1,
- step: 0.01,
- },
- clarity: {
- folder: 'Render',
- min: 0.00001,
- max: 1,
- step: 0.001,
- },
- decayRateBrush: {
- folder: 'Diffusion',
- min: 0.1,
- max: 100,
- step: 0.1,
- },
- decayRateTrails: {
- folder: 'Diffusion',
- min: 0.1,
- max: 5000,
- step: 1,
- },
- diffusionRateBrush: {
- folder: 'Diffusion',
- min: 0.001,
- max: 1,
- step: 0.001,
- },
- diffusionRateTrails: {
- folder: 'Diffusion',
- min: 0,
- max: 2,
- step: 0.001,
- },
- eraserSize: {
- folder: 'Brush',
- integer: true,
- min: 24,
- max: 240,
- step: 1,
- },
- individualTrailWeight: {
- folder: 'Agent',
- min: 0,
- max: 1,
- step: 0.001,
- },
- mirrorSegmentCount: {
- folder: 'Brush',
- integer: true,
- min: 1,
- max: 12,
- step: 1,
- },
- moveSpeed: {
- folder: 'Agent',
- min: 10,
- max: 500,
- step: 1,
- },
- renderSpeed: {
- folder: 'Runtime',
- integer: true,
- min: 1,
- max: 10,
- step: 1,
- },
- selectedColorIndex: {
- folder: 'Brush',
- integer: true,
- min: 0,
- max: 2,
- step: 1,
- },
- sensorOffsetAngle: {
- folder: 'Agent',
- min: 0,
- max: 90,
- step: 1,
- },
- sensorOffsetDistance: {
- folder: 'Agent',
- min: 0,
- max: 200,
- step: 1,
- },
- simulatedDelayMs: {
- folder: 'Runtime',
- integer: true,
- min: 0,
- max: 2000,
- step: 1,
- },
- spawnPerPixel: {
- folder: 'Agent',
- min: 0.01,
- max: 1,
- step: 0.001,
- },
- startColorHue: {
- folder: 'Render',
- min: 0,
- max: 360,
- step: 1,
- },
- turnSpeed: {
- folder: 'Agent',
- min: 1,
- max: 200,
- step: 1,
- },
- turnWhenLost: {
- folder: 'Agent',
- min: 0,
- max: 1,
- step: 0.001,
- },
- },
- },
+ runtimeSettings,
simulation: {
budget: {
- fpsHeadroom: 0.82,
+ adaptiveCapDecreaseAgentsPerSecond: 50_000,
+ adaptiveCapMin: 500_000,
+ fpsHeadroom: 0.95,
fpsSmoothingNew: 0.06,
fpsSmoothingRetain: 0.94,
initialTargetAgentBudget: 20_000,
@@ -821,7 +199,7 @@ export const appConfig: GardenAppConfig = {
refreshTargetDecay: 0.995,
},
brushEffectFramesPerSecond: 60,
- globalAgentCap: 1_000_000,
+ globalAgentCap: 10_000_000,
initialAgentCount: 180_000,
intro: {
angleJitterRadians: Math.PI * 0.08,
@@ -855,7 +233,6 @@ export const appConfig: GardenAppConfig = {
titleStrokeWidthRatio: 0.11,
verticalAnchor: 0.47,
},
- introCameraZoom: 0.12,
introMoveSpeedBaseMultiplier: 1.8,
introMoveSpeedProgressMultiplier: 0.35,
maxMirrorSegmentCount: 12,
@@ -912,4 +289,4 @@ export const appConfig: GardenAppConfig = {
defaultVibeId,
presets: vibePresets,
},
-};
+} satisfies GardenAppConfig;
diff --git a/src/config/color-interactions.ts b/src/config/color-interactions.ts
new file mode 100644
index 0000000..b6c870e
--- /dev/null
+++ b/src/config/color-interactions.ts
@@ -0,0 +1,71 @@
+import type {
+ AgentColorInteractionSettings,
+ NumberControlConfig,
+} from './types';
+
+const agentInteractionOptions: Record = {
+ Follow: 1,
+ Avoid: -1,
+ Ignore: 0,
+};
+
+export const defaultColorInteractionSettings: AgentColorInteractionSettings = {
+ color1ToColor1: 1,
+ color1ToColor2: 0,
+ color1ToColor3: 0,
+ color2ToColor1: 0,
+ color2ToColor2: 1,
+ color2ToColor3: 0,
+ color3ToColor1: 0,
+ color3ToColor2: 0,
+ color3ToColor3: 1,
+};
+
+const hashString = (value: string): number => {
+ let hash = 0x811c9dc5;
+ for (let i = 0; i < value.length; i++) {
+ hash ^= value.charCodeAt(i);
+ hash = Math.imul(hash, 0x01000193);
+ }
+ return hash >>> 0;
+};
+
+const createSeededRandom = (seed: number): (() => number) => {
+ let state = seed;
+ return () => {
+ let value = (state += 0x6d2b79f5);
+ value = Math.imul(value ^ (value >>> 15), value | 1);
+ value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
+ return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
+ };
+};
+
+export const createColorInteractionSettings = (
+ seedSource: string
+): AgentColorInteractionSettings => {
+ const random = createSeededRandom(hashString(seedSource));
+ const values = Object.values(agentInteractionOptions);
+ const randomInteraction = () =>
+ values[Math.floor(random() * values.length)] ?? defaultColorInteractionSettings.color1ToColor2;
+
+ return {
+ color1ToColor1: 1,
+ color1ToColor2: randomInteraction(),
+ color1ToColor3: randomInteraction(),
+ color2ToColor1: randomInteraction(),
+ color2ToColor2: 1,
+ color2ToColor3: randomInteraction(),
+ color3ToColor1: randomInteraction(),
+ color3ToColor2: randomInteraction(),
+ color3ToColor3: 1,
+ };
+};
+
+export const colorInteractionControl = (label: string): NumberControlConfig => ({
+ folder: 'Color Reactions',
+ label,
+ min: -1,
+ max: 1,
+ step: 1,
+ options: agentInteractionOptions,
+});
diff --git a/src/config/runtime-settings.ts b/src/config/runtime-settings.ts
new file mode 100644
index 0000000..2de328f
--- /dev/null
+++ b/src/config/runtime-settings.ts
@@ -0,0 +1,206 @@
+import {
+ colorInteractionControl,
+ defaultColorInteractionSettings,
+} from './color-interactions';
+import type { GardenAppConfig } from './types';
+
+export const runtimeSettings: GardenAppConfig['runtimeSettings'] = {
+ defaults: {
+ agentBudgetMax: 1_000_000,
+ agentCount: 0,
+ selectedColorIndex: 0,
+ spawnPerPixel: 0.22,
+
+ moveSpeed: 82,
+ turnSpeed: 58,
+ sensorOffsetAngle: 34,
+ sensorOffsetDistance: 38,
+ turnWhenLost: 0.8,
+
+ individualTrailWeight: 0.07,
+ ...defaultColorInteractionSettings,
+
+ diffusionRateTrails: 0.22,
+ decayRateTrails: 965,
+ diffusionRateBrush: 0.35,
+ decayRateBrush: 18,
+ brushEffectDuration: 8,
+
+ clarity: 0.62,
+ brushSize: 14,
+ brushCurveResolution: 12,
+ eraserSize: 96,
+ mirrorSegmentCount: 1,
+
+ brushSizeVariation: 0.5,
+
+ startColorHue: 200,
+
+ renderSpeed: 1,
+ simulatedDelayMs: 0,
+ },
+ controls: {
+ agentBudgetMax: {
+ folder: 'Runtime',
+ integer: true,
+ min: 500_000,
+ max: 10_000_000,
+ step: 50_000,
+ },
+ agentCount: {
+ folder: 'Runtime',
+ integer: true,
+ min: 0,
+ max: 1_000_000,
+ step: 1_000,
+ },
+ color1ToColor1: colorInteractionControl('1 -> 1'),
+ color1ToColor2: colorInteractionControl('1 -> 2'),
+ color1ToColor3: colorInteractionControl('1 -> 3'),
+ color2ToColor1: colorInteractionControl('2 -> 1'),
+ color2ToColor2: colorInteractionControl('2 -> 2'),
+ color2ToColor3: colorInteractionControl('2 -> 3'),
+ color3ToColor1: colorInteractionControl('3 -> 1'),
+ color3ToColor2: colorInteractionControl('3 -> 2'),
+ color3ToColor3: colorInteractionControl('3 -> 3'),
+ brushEffectDuration: {
+ folder: 'Diffusion',
+ min: 0.5,
+ max: 20,
+ step: 0.05,
+ },
+ brushSize: {
+ folder: 'Brush',
+ min: 1,
+ max: 60,
+ step: 0.25,
+ },
+ brushSizeVariation: {
+ folder: 'Brush',
+ min: 0,
+ max: 1,
+ step: 0.01,
+ },
+ brushCurveResolution: {
+ folder: 'Brush',
+ integer: true,
+ label: 'curve resolution',
+ min: 1,
+ max: 32,
+ step: 1,
+ },
+ clarity: {
+ folder: 'Render',
+ min: 0.00001,
+ max: 1,
+ step: 0.001,
+ },
+ decayRateBrush: {
+ folder: 'Diffusion',
+ min: 0.1,
+ max: 100,
+ step: 0.1,
+ },
+ decayRateTrails: {
+ folder: 'Diffusion',
+ min: 0.1,
+ max: 5000,
+ step: 1,
+ },
+ diffusionRateBrush: {
+ folder: 'Diffusion',
+ min: 0.001,
+ max: 1,
+ step: 0.001,
+ },
+ diffusionRateTrails: {
+ folder: 'Diffusion',
+ min: 0,
+ max: 2,
+ step: 0.001,
+ },
+ eraserSize: {
+ folder: 'Brush',
+ integer: true,
+ min: 24,
+ max: 240,
+ step: 1,
+ },
+ individualTrailWeight: {
+ folder: 'Agent',
+ min: 0,
+ max: 1,
+ step: 0.001,
+ },
+ mirrorSegmentCount: {
+ folder: 'Brush',
+ integer: true,
+ min: 1,
+ max: 12,
+ step: 1,
+ },
+ moveSpeed: {
+ folder: 'Agent',
+ min: 10,
+ max: 500,
+ step: 1,
+ },
+ renderSpeed: {
+ folder: 'Runtime',
+ integer: true,
+ min: 1,
+ max: 10,
+ step: 1,
+ },
+ selectedColorIndex: {
+ folder: 'Brush',
+ integer: true,
+ min: 0,
+ max: 2,
+ step: 1,
+ },
+ sensorOffsetAngle: {
+ folder: 'Agent',
+ min: 0,
+ max: 90,
+ step: 1,
+ },
+ sensorOffsetDistance: {
+ folder: 'Agent',
+ min: 0,
+ max: 200,
+ step: 1,
+ },
+ simulatedDelayMs: {
+ folder: 'Runtime',
+ integer: true,
+ min: 0,
+ max: 2000,
+ step: 1,
+ },
+ spawnPerPixel: {
+ folder: 'Agent',
+ min: 0.01,
+ max: 1,
+ step: 0.001,
+ },
+ startColorHue: {
+ folder: 'Render',
+ min: 0,
+ max: 360,
+ step: 1,
+ },
+ turnSpeed: {
+ folder: 'Agent',
+ min: 1,
+ max: 200,
+ step: 1,
+ },
+ turnWhenLost: {
+ folder: 'Agent',
+ min: 0,
+ max: 1,
+ step: 0.001,
+ },
+ },
+};
diff --git a/src/config/types.ts b/src/config/types.ts
new file mode 100644
index 0000000..6557e4f
--- /dev/null
+++ b/src/config/types.ts
@@ -0,0 +1,287 @@
+import type {
+ GardenAudioConfig,
+ GardenAudioVibeProfile,
+} from '../audio/garden-audio-config';
+import type { GameLoopSettings } from '../game-loop/game-loop-settings';
+import type { AgentSettings } from '../pipelines/agents/agent-settings';
+import type { BrushSettings } from '../pipelines/brush/brush-settings';
+import type { DiffusionSettings } from '../pipelines/diffusion/diffusion-settings';
+import type { RenderSettings } from '../pipelines/render/render-settings';
+
+export type GardenRuntimeSettings = GameLoopSettings &
+ AgentSettings &
+ BrushSettings &
+ DiffusionSettings &
+ RenderSettings;
+
+export type AgentColorInteractionSettings = Pick<
+ AgentSettings,
+ | 'color1ToColor1'
+ | 'color1ToColor2'
+ | 'color1ToColor3'
+ | 'color2ToColor1'
+ | 'color2ToColor2'
+ | 'color2ToColor3'
+ | 'color3ToColor1'
+ | 'color3ToColor2'
+ | 'color3ToColor3'
+>;
+
+export type GardenVibeSettings = Partial<
+ Pick<
+ GardenRuntimeSettings,
+ | 'agentBudgetMax'
+ | 'brushSize'
+ | 'color1ToColor1'
+ | 'color1ToColor2'
+ | 'color1ToColor3'
+ | 'color2ToColor1'
+ | 'color2ToColor2'
+ | 'color2ToColor3'
+ | 'color3ToColor1'
+ | 'color3ToColor2'
+ | 'color3ToColor3'
+ | 'clarity'
+ | 'decayRateTrails'
+ | 'diffusionRateTrails'
+ | 'individualTrailWeight'
+ | 'moveSpeed'
+ | 'sensorOffsetAngle'
+ | 'sensorOffsetDistance'
+ | 'spawnPerPixel'
+ | 'turnSpeed'
+ >
+>;
+
+export interface VibePreset {
+ id: string;
+ name: string;
+ colors: [string, string, string];
+ backgroundColor: string;
+ settings: GardenVibeSettings;
+ audio: GardenAudioVibeProfile;
+}
+
+export interface NumberControlConfig {
+ folder: string;
+ integer?: boolean;
+ label?: string;
+ max: number;
+ min: number;
+ options?: Record;
+ step?: number;
+}
+
+export type RuntimeSettingControlConfig = {
+ [Key in keyof GardenRuntimeSettings]: NumberControlConfig;
+};
+
+export interface GardenAppConfig {
+ audio: GardenAudioConfig;
+ audioEngine: {
+ energy: {
+ attackSeconds: number;
+ decaySeconds: number;
+ releaseSeconds: number;
+ strokeDecaySeconds: number;
+ };
+ eraser: {
+ canvasWidthRatioForFullSize: number;
+ defaultSizePixels: number;
+ durationSeconds: number;
+ filterPressureWeight: number;
+ filterSizeWeight: number;
+ filterSpeedWeight: number;
+ gainBase: number;
+ gainPressureWeight: number;
+ gainSizeWeight: number;
+ gainSpeedWeight: number;
+ };
+ delay: {
+ erasingActivity: number;
+ };
+ graph: {
+ closeGain: number;
+ closeRampSeconds: number;
+ delayActivityFeedbackWeight: number;
+ delayFeedbackMax: number;
+ delayFeedbackMin: number;
+ delayOutputActivityWeight: number;
+ delayOutputBase: number;
+ delayTimeRampSeconds: number;
+ eventBusGain: number;
+ noiseMax: number;
+ noiseMin: number;
+ unlockBufferLength: number;
+ unlockSampleRate: number;
+ };
+ input: {
+ distanceEnergyBase: number;
+ distanceEnergyScale: number;
+ distanceForFullEnergyPixels: number;
+ fallbackFrameSeconds: number;
+ penMinPressure: number;
+ strokeEnergyBase: number;
+ strokeEnergyPressureWeight: number;
+ strokeEnergySpeedWeight: number;
+ };
+ muteGain: number;
+ muteRampSeconds: number;
+ noiseBurst: {
+ attackSeconds: number;
+ filterQ: number;
+ offsetRandomSeconds: number;
+ scheduleAheadSeconds: number;
+ silentGain: number;
+ };
+ piano: {
+ filterQ: number;
+ gainAttackSeconds: number;
+ lowpassMaxHz: number;
+ lowpassMinHz: number;
+ minDurationSeconds: number;
+ minFadeSeconds: number;
+ minGain: number;
+ pitchSemitonesPerOctave: number;
+ scheduleAheadSeconds: number;
+ sustainBase: number;
+ sustainVelocityRange: number;
+ tailStopExtraSeconds: number;
+ voiceStealFadeSeconds: number;
+ voiceStealStopSeconds: number;
+ };
+ startDelaySeconds: number;
+ vibeChangeStingerMinIntervalSeconds: number;
+ };
+ deltaTime: {
+ fpsExponentialDecayStrength: number;
+ maxDeltaTimeSeconds: number;
+ minDeltaTimeSeconds: number;
+ };
+ export4k: {
+ bytesPerPixel: number;
+ height: number;
+ jsHeapSafetyMultiplier: number;
+ lowMemoryDeviceGiB: number;
+ lowMemoryExportFraction: number;
+ rowAlignmentBytes: number;
+ width: number;
+ };
+ menuHider: {
+ bottomRevealDistancePx: number;
+ intervalMs: number;
+ timeToLiveMs: number;
+ };
+ pipelines: {
+ brush: {
+ maxLineCount: number;
+ };
+ diffusion: {
+ minDiffusionRate: number;
+ };
+ eraser: {
+ maxSegmentCount: number;
+ maxTextureLineCount: number;
+ segmentFloatCount: number;
+ workgroupSize: number;
+ };
+ };
+ runtimeSettings: {
+ controls: RuntimeSettingControlConfig;
+ defaults: GardenRuntimeSettings;
+ };
+ simulation: {
+ budget: {
+ adaptiveCapDecreaseAgentsPerSecond: number;
+ adaptiveCapMin: number;
+ fpsHeadroom: number;
+ fpsSmoothingNew: number;
+ fpsSmoothingRetain: number;
+ initialTargetAgentBudget: number;
+ rampAgentsPerSecond: number;
+ refreshTargetDecay: number;
+ };
+ brushEffectFramesPerSecond: number;
+ globalAgentCap: number;
+ initialAgentCount: number;
+ intro: {
+ angleJitterRadians: number;
+ circleMaxSideRatio: number;
+ circleMinSideRatio: number;
+ drawHintClass: string;
+ drawHintDelayMs: number;
+ durationSeconds: number;
+ entryJitterSideRatio: number;
+ fontScaleDown: number;
+ initialFontHeightRatio: number;
+ initialFontWidthRatio: number;
+ letterSpacingEm: number;
+ maskAlphaThreshold: number;
+ maskGradientThreshold: number;
+ maskSampleDensity: number;
+ maxHeightRatio: number;
+ maxWidthRatio: number;
+ minEntryJitterPx: number;
+ minFontSizePx: number;
+ minTargetJitterPx: number;
+ radialJitterRatio: number;
+ targetDelayDistanceMultiplier: number;
+ targetDelayMax: number;
+ targetDelayRandomMultiplier: number;
+ targetJitterSideRatio: number;
+ title: string;
+ titleColorCutLetters: [number, number];
+ titleRadiusMultiplier: number;
+ titleStrokeWidthMinPx: number;
+ titleStrokeWidthRatio: number;
+ verticalAnchor: number;
+ };
+ introMoveSpeedBaseMultiplier: number;
+ introMoveSpeedProgressMultiplier: number;
+ maxMirrorSegmentCount: number;
+ stroke: {
+ angleJitterRadians: number;
+ densityMultiplier: number;
+ maxAgentCount: number;
+ minAgentCount: number;
+ };
+ };
+ storage: {
+ audioMutedKey: string;
+ vibeKey: string;
+ };
+ telemetry: {
+ enabled: boolean;
+ intervalMs: number;
+ };
+ toolbar: {
+ eraser: {
+ controlScaleMax: number;
+ controlScaleMin: number;
+ default: number;
+ max: number;
+ min: number;
+ step: number;
+ };
+ mirror: {
+ default: number;
+ max: number;
+ min: number;
+ names: Record;
+ step: number;
+ };
+ };
+ tuningPane: {
+ expandedDepth: number;
+ startHidden: boolean;
+ title: string;
+ };
+ vibes: {
+ defaultVibeId: string;
+ presets: Array;
+ };
+}
+
+export type GardenAudioEngineConfig = GardenAppConfig['audioEngine'];
+export type GardenSimulationConfig = GardenAppConfig['simulation'];
+export type GardenStorageConfig = GardenAppConfig['storage'];
diff --git a/src/config/vibe-presets.ts b/src/config/vibe-presets.ts
new file mode 100644
index 0000000..d4fd90f
--- /dev/null
+++ b/src/config/vibe-presets.ts
@@ -0,0 +1,204 @@
+import type {
+ GardenAudioChord,
+ GardenAudioVibeProfile,
+} from '../audio/garden-audio-config';
+import { createColorInteractionSettings } from './color-interactions';
+import type { VibePreset } from './types';
+
+const majorProgression: Array = [
+ { rootOffset: 0, quality: 'major' },
+ { rootOffset: 9, quality: 'minor' },
+ { rootOffset: 5, quality: 'major' },
+ { rootOffset: 7, quality: 'major' },
+];
+
+const minorProgression: Array = [
+ { rootOffset: 0, quality: 'minor' },
+ { rootOffset: 8, quality: 'major' },
+ { rootOffset: 3, quality: 'major' },
+ { rootOffset: 10, quality: 'major' },
+];
+
+const majorPentatonic = [0, 2, 4, 7, 9];
+const minorPentatonic = [0, 3, 5, 7, 10];
+
+export const defaultVibeId = 'candy-rain';
+
+export const vibePresets: Array = [
+ {
+ id: 'candy-rain',
+ name: 'Candy Rain',
+ colors: ['#ff5da2', '#36d7d0', '#ffd84d'],
+ backgroundColor: '#10151f',
+ settings: {
+ agentBudgetMax: 1_000_000,
+ brushSize: 14,
+ clarity: 0.62,
+ decayRateTrails: 965,
+ diffusionRateTrails: 0.22,
+ individualTrailWeight: 0.07,
+ moveSpeed: 82,
+ sensorOffsetAngle: 34,
+ sensorOffsetDistance: 38,
+ spawnPerPixel: 0.22,
+ turnSpeed: 58,
+ ...createColorInteractionSettings('candy-rain'),
+ },
+ audio: {
+ rootMidi: 57,
+ scale: majorPentatonic,
+ brightness: 1.04,
+ delayTimeMultiplier: 0.92,
+ progression: majorProgression,
+ },
+ },
+ {
+ id: 'sunlit-moss',
+ name: 'Sunlit Moss',
+ colors: ['#83d483', '#f6d76b', '#5ec1a1'],
+ backgroundColor: '#172016',
+ settings: {
+ agentBudgetMax: 1_000_000,
+ brushSize: 16,
+ clarity: 0.68,
+ decayRateTrails: 975,
+ diffusionRateTrails: 0.18,
+ individualTrailWeight: 0.06,
+ moveSpeed: 70,
+ sensorOffsetAngle: 28,
+ sensorOffsetDistance: 46,
+ spawnPerPixel: 0.18,
+ turnSpeed: 44,
+ ...createColorInteractionSettings('sunlit-moss'),
+ },
+ audio: {
+ rootMidi: 53,
+ scale: majorPentatonic,
+ brightness: 0.92,
+ delayTimeMultiplier: 1.08,
+ progression: [
+ { rootOffset: 0, quality: 'major' },
+ { rootOffset: 7, quality: 'major' },
+ { rootOffset: 9, quality: 'minor' },
+ { rootOffset: 5, quality: 'major' },
+ ],
+ },
+ },
+ {
+ id: 'coral-tide',
+ name: 'Coral Tide',
+ colors: ['#ff7f6e', '#40b8ff', '#f4f0a6'],
+ backgroundColor: '#0f1822',
+ settings: {
+ agentBudgetMax: 1_000_000,
+ brushSize: 13,
+ clarity: 0.58,
+ decayRateTrails: 955,
+ diffusionRateTrails: 0.28,
+ individualTrailWeight: 0.055,
+ moveSpeed: 90,
+ sensorOffsetAngle: 36,
+ sensorOffsetDistance: 35,
+ spawnPerPixel: 0.25,
+ turnSpeed: 62,
+ ...createColorInteractionSettings('coral-tide'),
+ },
+ audio: {
+ rootMidi: 50,
+ scale: minorPentatonic,
+ brightness: 1,
+ delayTimeMultiplier: 1.12,
+ progression: minorProgression,
+ },
+ },
+ {
+ id: 'moon-orchid',
+ name: 'Moon Orchid',
+ colors: ['#c993ff', '#7dd8ff', '#f0f4ff'],
+ backgroundColor: '#14121d',
+ settings: {
+ agentBudgetMax: 1_000_000,
+ brushSize: 12,
+ clarity: 0.64,
+ decayRateTrails: 968,
+ diffusionRateTrails: 0.2,
+ individualTrailWeight: 0.065,
+ moveSpeed: 76,
+ sensorOffsetAngle: 32,
+ sensorOffsetDistance: 42,
+ spawnPerPixel: 0.2,
+ turnSpeed: 52,
+ ...createColorInteractionSettings('moon-orchid'),
+ },
+ audio: {
+ rootMidi: 49,
+ scale: minorPentatonic,
+ brightness: 0.9,
+ delayTimeMultiplier: 1.24,
+ progression: minorProgression,
+ },
+ },
+ {
+ id: 'peach-neon',
+ name: 'Peach Neon',
+ colors: ['#ff9b73', '#5bf0a9', '#6ea8ff'],
+ backgroundColor: '#191716',
+ settings: {
+ agentBudgetMax: 1_000_000,
+ brushSize: 15,
+ clarity: 0.55,
+ decayRateTrails: 948,
+ diffusionRateTrails: 0.32,
+ individualTrailWeight: 0.05,
+ moveSpeed: 96,
+ sensorOffsetAngle: 40,
+ sensorOffsetDistance: 32,
+ spawnPerPixel: 0.24,
+ turnSpeed: 70,
+ ...createColorInteractionSettings('peach-neon'),
+ },
+ audio: {
+ rootMidi: 56,
+ scale: majorPentatonic,
+ brightness: 1.08,
+ delayTimeMultiplier: 0.86,
+ progression: majorProgression,
+ },
+ },
+ {
+ id: 'frost-bloom',
+ name: 'Frost Bloom',
+ colors: ['#b4f7ff', '#9ec8ff', '#ffb8d2'],
+ backgroundColor: '#101820',
+ settings: {
+ agentBudgetMax: 1_000_000,
+ brushSize: 18,
+ clarity: 0.7,
+ decayRateTrails: 982,
+ diffusionRateTrails: 0.14,
+ individualTrailWeight: 0.075,
+ moveSpeed: 62,
+ sensorOffsetAngle: 26,
+ sensorOffsetDistance: 52,
+ spawnPerPixel: 0.16,
+ turnSpeed: 40,
+ ...createColorInteractionSettings('frost-bloom'),
+ },
+ audio: {
+ rootMidi: 62,
+ scale: majorPentatonic,
+ brightness: 0.88,
+ delayTimeMultiplier: 1.32,
+ progression: [
+ { rootOffset: 0, quality: 'major' },
+ { rootOffset: 5, quality: 'major' },
+ { rootOffset: 9, quality: 'minor' },
+ { rootOffset: 7, quality: 'major' },
+ ],
+ },
+ },
+];
+
+export const audioVibes = Object.fromEntries(
+ vibePresets.map((vibe) => [vibe.id, vibe.audio])
+) as Record;
diff --git a/src/constants.ts b/src/constants.ts
deleted file mode 100644
index 24bf445..0000000
--- a/src/constants.ts
+++ /dev/null
@@ -1 +0,0 @@
-export const isProduction: boolean = import.meta.env.PROD;
diff --git a/src/game-loop/agent-population.test.ts b/src/game-loop/agent-population.test.ts
new file mode 100644
index 0000000..93dbcba
--- /dev/null
+++ b/src/game-loop/agent-population.test.ts
@@ -0,0 +1,86 @@
+import { vec2 } from 'gl-matrix';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.hoisted(() => {
+ Object.defineProperty(globalThis, 'localStorage', {
+ configurable: true,
+ value: {
+ getItem: vi.fn(() => null),
+ setItem: vi.fn(),
+ },
+ });
+});
+
+import { appConfig } from '../config';
+import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
+import { settings } from '../settings';
+import { AgentPopulation } from './agent-population';
+
+const originalAgentBudgetMax = settings.agentBudgetMax;
+const originalBrushSize = settings.brushSize;
+const originalSelectedColorIndex = settings.selectedColorIndex;
+const originalSpawnPerPixel = settings.spawnPerPixel;
+
+const createPopulation = () => {
+ const pipeline = {
+ maxAgentCount: 10_000_000,
+ writeAgents: vi.fn(),
+ resizeAgents: vi.fn(),
+ compactAgents: vi.fn(),
+ } as unknown as AgentGenerationPipeline;
+
+ return new AgentPopulation(pipeline);
+};
+
+const setPopulationCounts = (
+ population: AgentPopulation,
+ activeCount: number,
+ targetBudget: number
+) => {
+ Object.assign(population as unknown as Record, {
+ activeCount,
+ targetBudget,
+ });
+};
+
+describe('AgentPopulation adaptive budget', () => {
+ beforeEach(() => {
+ settings.agentBudgetMax = 1_000_000;
+ settings.brushSize = 1;
+ settings.selectedColorIndex = 0;
+ settings.spawnPerPixel = 1;
+ });
+
+ afterEach(() => {
+ settings.agentBudgetMax = originalAgentBudgetMax;
+ settings.brushSize = originalBrushSize;
+ settings.selectedColorIndex = originalSelectedColorIndex;
+ settings.spawnPerPixel = originalSpawnPerPixel;
+ });
+
+ 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);
+
+ population.growBudget(1 / 60, 60, 60);
+ population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(16, 0));
+
+ expect(settings.agentBudgetMax).toBeGreaterThan(1_000_000);
+ expect(population.activeAgentCount).toBeGreaterThan(1_000_000);
+ expect(settings.agentBudgetMax).toBeLessThanOrEqual(
+ appConfig.simulation.globalAgentCap
+ );
+ });
+
+ 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);
+
+ population.growBudget(10, 50, 60);
+
+ expect(settings.agentBudgetMax).toBe(appConfig.simulation.budget.adaptiveCapMin);
+ expect(population.activeAgentCount).toBe(
+ appConfig.simulation.budget.adaptiveCapMin
+ );
+ });
+});
diff --git a/src/game-loop/agent-population.ts b/src/game-loop/agent-population.ts
index 97bb999..8919472 100644
--- a/src/game-loop/agent-population.ts
+++ b/src/game-loop/agent-population.ts
@@ -12,11 +12,15 @@ const INITIAL_AGENT_COUNT = appConfig.simulation.initialAgentCount;
const MIN_STROKE_AGENT_COUNT = appConfig.simulation.stroke.minAgentCount;
const MAX_STROKE_AGENT_COUNT = appConfig.simulation.stroke.maxAgentCount;
const STROKE_AGENT_DENSITY_MULTIPLIER = appConfig.simulation.stroke.densityMultiplier;
+const ADAPTIVE_CAP_MIN = appConfig.simulation.budget.adaptiveCapMin;
+const ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND =
+ appConfig.simulation.budget.adaptiveCapDecreaseAgentsPerSecond;
export class AgentPopulation {
private activeCount = 0;
private targetBudget = appConfig.simulation.budget.initialTargetAgentBudget;
private replacementCursor = 0;
+ private canExpandAdaptiveCap = true;
private shouldCompactAfterErase = false;
private isCompacting = false;
private readonly strokeAgentData = new Float32Array(
@@ -38,6 +42,7 @@ export class AgentPopulation {
}
public initializeIntroAgents(canvasSize: vec2): void {
+ settings.agentBudgetMax = this.clampAdaptiveCap(settings.agentBudgetMax);
this.targetBudget = Math.min(
this.pipeline.maxAgentCount,
settings.agentBudgetMax,
@@ -53,6 +58,7 @@ export class AgentPopulation {
}
public onVibeChanged(): void {
+ settings.agentBudgetMax = this.clampAdaptiveCap(settings.agentBudgetMax);
this.targetBudget = Math.min(
this.targetBudget,
settings.agentBudgetMax,
@@ -65,7 +71,9 @@ export class AgentPopulation {
smoothedFps: number,
refreshTargetFps: number
): void {
- const cap = Math.min(settings.agentBudgetMax, this.pipeline.maxAgentCount);
+ this.updateAdaptiveCap(deltaTime, smoothedFps, refreshTargetFps);
+
+ const cap = this.clampAdaptiveCap(settings.agentBudgetMax);
if (
this.targetBudget < cap &&
smoothedFps > refreshTargetFps * appConfig.simulation.budget.fpsHeadroom
@@ -147,6 +155,8 @@ export class AgentPopulation {
}
const count = data.length / AGENT_FLOAT_COUNT;
+ this.expandAdaptiveCapForPendingAgents(count);
+
const available = Math.max(0, this.targetBudget - this.activeCount);
const appendCount = Math.min(count, available);
@@ -178,4 +188,60 @@ export class AgentPopulation {
this.replacementCursor = (targetAgentOffset + chunkAgentCount) % this.activeCount;
}
}
+
+ private updateAdaptiveCap(
+ deltaTime: number,
+ smoothedFps: number,
+ refreshTargetFps: number
+ ): void {
+ const previousCap = this.clampAdaptiveCap(settings.agentBudgetMax);
+ this.canExpandAdaptiveCap =
+ smoothedFps >= refreshTargetFps * appConfig.simulation.budget.fpsHeadroom;
+
+ if (this.canExpandAdaptiveCap) {
+ settings.agentBudgetMax = previousCap;
+ return;
+ }
+
+ const decrease = Math.max(
+ 1,
+ Math.ceil(ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND * deltaTime)
+ );
+ const nextCap = this.clampAdaptiveCap(previousCap - decrease);
+ settings.agentBudgetMax = nextCap;
+ this.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;
+ }
+ }
+
+ private expandAdaptiveCapForPendingAgents(requestedAgentCount: number): void {
+ const available = Math.max(0, this.targetBudget - this.activeCount);
+ if (requestedAgentCount <= available || !this.canExpandAdaptiveCap) {
+ return;
+ }
+
+ const currentCap = this.clampAdaptiveCap(settings.agentBudgetMax);
+ if (this.targetBudget < currentCap) {
+ 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)
+ );
+ }
+
+ private clampAdaptiveCap(value: number): number {
+ const pipelineCap = Math.max(0, Math.floor(this.pipeline.maxAgentCount));
+ const minCap = Math.min(ADAPTIVE_CAP_MIN, pipelineCap);
+ const finiteValue = Number.isFinite(value) ? value : minCap;
+ return Math.min(pipelineCap, Math.max(minCap, Math.round(finiteValue)));
+ }
}
diff --git a/src/game-loop/export-4k.ts b/src/game-loop/export-4k.ts
index b756ba6..dffec92 100644
--- a/src/game-loop/export-4k.ts
+++ b/src/game-loop/export-4k.ts
@@ -1,8 +1,8 @@
import { appConfig } from '../config';
import { RuntimeError } from '../utils/error-handler';
-export const EXPORT_4K_WIDTH = appConfig.export4k.width;
-export const EXPORT_4K_HEIGHT = appConfig.export4k.height;
+const EXPORT_4K_WIDTH = appConfig.export4k.width;
+const EXPORT_4K_HEIGHT = appConfig.export4k.height;
const BYTES_PER_PIXEL = appConfig.export4k.bytesPerPixel;
const ROW_ALIGNMENT_BYTES = appConfig.export4k.rowAlignmentBytes;
@@ -11,7 +11,7 @@ const LOW_MEMORY_DEVICE_GIB = appConfig.export4k.lowMemoryDeviceGiB;
const LOW_MEMORY_EXPORT_FRACTION = appConfig.export4k.lowMemoryExportFraction;
const JS_HEAP_SAFETY_MULTIPLIER = appConfig.export4k.jsHeapSafetyMultiplier;
-export interface Export4KMemoryEstimate {
+interface Export4KMemoryEstimate {
width: number;
height: number;
bytesPerPixel: number;
@@ -26,18 +26,18 @@ export interface Export4KMemoryEstimate {
estimatedPeakBytes: number;
}
-export interface Export4KDimensions {
+interface Export4KDimensions {
width: number;
height: number;
}
-export interface BrowserMemoryInfo {
+interface BrowserMemoryInfo {
deviceMemoryBytes?: number;
jsHeapSizeLimitBytes?: number;
usedJsHeapSizeBytes?: number;
}
-export interface Export4KPreflightOptions {
+interface Export4KPreflightOptions {
limits: Pick;
memoryInfo?: BrowserMemoryInfo;
estimate?: Export4KMemoryEstimate;
diff --git a/src/game-loop/game-loop-intro.test.ts b/src/game-loop/game-loop-intro.test.ts
new file mode 100644
index 0000000..9131f18
--- /dev/null
+++ b/src/game-loop/game-loop-intro.test.ts
@@ -0,0 +1,28 @@
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { describe, expect, it } from 'vitest';
+
+const gameLoopSource = readFileSync(
+ join(process.cwd(), 'src/game-loop/game-loop.ts'),
+ 'utf8'
+);
+
+const getStartDrawingHandlerSource = () => {
+ const start = gameLoopSource.indexOf('onStartDrawing:');
+ const end = gameLoopSource.indexOf('onEraseGestureEnded:', start);
+
+ if (start < 0 || end < 0) {
+ throw new Error('Could not find the pointer drawing intro handler');
+ }
+
+ return gameLoopSource.slice(start, end);
+};
+
+describe('GameLoop intro drawing policy', () => {
+ it('allows drawing to start without completing the intro sequence', () => {
+ const handlerSource = getStartDrawingHandlerSource();
+
+ expect(handlerSource).toContain('this.introPrompt.markStartedDrawing()');
+ expect(handlerSource).not.toContain('this.introPrompt.complete(');
+ });
+});
diff --git a/src/game-loop/game-loop.ts b/src/game-loop/game-loop.ts
index de5220d..01c8448 100644
--- a/src/game-loop/game-loop.ts
+++ b/src/game-loop/game-loop.ts
@@ -19,6 +19,7 @@ import { RenderInputCache } from './render-input-cache';
export default class GameLoop {
private static readonly MAX_MIRROR_SEGMENT_COUNT =
appConfig.simulation.maxMirrorSegmentCount;
+ private static readonly DEV_STATS_INTERVAL_MS = 250;
private readonly resources: GameLoopResources;
private readonly audio = new GardenAudio(gardenAudioConfig);
@@ -29,10 +30,12 @@ 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 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 hasFinished = false;
private readonly finished = Promise.withResolvers();
@@ -43,6 +46,9 @@ export default class GameLoop {
ui: GardenUi
) {
this.resize();
+ if (import.meta.env.DEV) {
+ this.devStatsElement = this.createDevStatsElement();
+ }
this.resources = new GameLoopResources(canvas, device, this.canvasSize);
this.introPrompt = new IntroPrompt(ui.prompt);
this.eraserPreview = new EraserPreview(canvas, ui.eraserPreview);
@@ -58,10 +64,7 @@ export default class GameLoop {
getCanvasSize: () => this.canvasSize,
getDevicePixelRatio: () => this.devicePixelRatio,
getMirrorSegmentCount: () => this.mirrorSegmentCount,
- onStartDrawing: () => {
- this.introPrompt.markStartedDrawing();
- this.introPrompt.complete();
- },
+ onStartDrawing: () => this.introPrompt.markStartedDrawing(),
onEraseGestureEnded: () => this.agentPopulation.requestCompactionAfterErase(),
spawnStrokeAgents: (from, to) => this.agentPopulation.spawnStrokeAgents(from, to),
});
@@ -133,6 +136,7 @@ export default class GameLoop {
window.removeEventListener('resize', this.resizeListener);
window.removeEventListener('keydown', this.keydownListener);
this.pointerInput.detach();
+ this.devStatsElement?.remove();
this.introPrompt.destroy();
this.resources.destroy();
await this.audio.destroy();
@@ -159,7 +163,7 @@ export default class GameLoop {
const scaledTime = time * settings.renderSpeed;
const { channelColors, backgroundColor } = this.renderInputs.get();
const introProgress = this.introPrompt.progress;
- const cameraZoom = 1 + (1 - introProgress) * appConfig.simulation.introCameraZoom;
+ const cameraZoom = 1;
const cameraCenter: [number, number] = [
this.canvas.width / 2,
this.canvas.height / 2,
@@ -172,6 +176,7 @@ export default class GameLoop {
vibe: activeVibe,
selectedColorIndex: settings.selectedColorIndex,
isErasing,
+ mirrorSegmentCount: this.mirrorSegmentCount,
});
this.resources.setFrameParameters({
@@ -205,6 +210,7 @@ export default class GameLoop {
devicePixelRatio: this.devicePixelRatio,
renderSpeed: settings.renderSpeed,
});
+ this.updateDevStats(time);
if (settings.simulatedDelayMs > 0) {
await sleep(settings.simulatedDelayMs);
@@ -213,6 +219,42 @@ export default class GameLoop {
requestAnimationFrame(this.render);
};
+ private createDevStatsElement(): HTMLDivElement | null {
+ const container = this.canvas.parentElement;
+ if (!container) {
+ return null;
+ }
+
+ const element = document.createElement('div');
+ element.className = 'dev-stats-overlay';
+ element.setAttribute('aria-hidden', 'true');
+ container.appendChild(element);
+ return element;
+ }
+
+ private updateDevStats(time: DOMHighResTimeStamp): void {
+ if (
+ !this.devStatsElement ||
+ time - this.lastDevStatsUpdateAt < GameLoop.DEV_STATS_INTERVAL_MS
+ ) {
+ return;
+ }
+
+ this.lastDevStatsUpdateAt = time;
+ this.devStatsElement.textContent = [
+ `FPS ${this.framePerformance.smoothedFps.toFixed(1)} / ${Math.round(
+ this.framePerformance.refreshTargetFps
+ )}`,
+ `Agents ${this.formatDevStatNumber(this.agentPopulation.activeAgentCount)}`,
+ `Target ${this.formatDevStatNumber(this.agentPopulation.targetAgentBudget)}`,
+ `Cap ${this.formatDevStatNumber(settings.agentBudgetMax)}`,
+ ].join('\n');
+ }
+
+ private formatDevStatNumber(value: number): string {
+ return Math.max(0, Math.round(value)).toLocaleString('en-US');
+ }
+
private resize(): void {
const width = Math.max(
1,
diff --git a/src/game-loop/game-presentation.ts b/src/game-loop/game-presentation.ts
deleted file mode 100644
index 7332da3..0000000
--- a/src/game-loop/game-presentation.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { vec3 } from 'gl-matrix';
-
-import { settings } from '../settings';
-import { hsl } from '../utils/hsl';
-import { Random } from '../utils/random';
-
-const hues = [settings.startColorHue];
-
-for (let i = 0; i < 100; i++) {
- hues.push((hues[hues.length - 1] + Random.randomBetween(90, 240)) % 360);
-}
-
-const colors = hues.map((hue) =>
- hsl(hue, Random.randomBetween(90, 100), Random.randomBetween(20, 30))
-);
-
-export class GamePresentation {
- public static getGenerationColor(generation: number): vec3 {
- return colors[generation % colors.length];
- }
-}
diff --git a/src/game-loop/game-rules.ts b/src/game-loop/game-rules.ts
deleted file mode 100644
index 87bf9e4..0000000
--- a/src/game-loop/game-rules.ts
+++ /dev/null
@@ -1,124 +0,0 @@
-import { vec2 } from 'gl-matrix';
-
-import { GenerationCounts } from '../pipelines/agents/agent-generation/generation-counts';
-import { settings } from '../settings';
-import { clamp, clamp01 } from '../utils/clamp';
-import { mix } from '../utils/mix';
-import { Random } from '../utils/random';
-
-export interface SpawnAction {
- generation: number;
- position: vec2;
- radius: number;
-}
-
-export class GameRules {
- private static readonly DEFAULT_SPAWN_INTERVAL = 8;
- private static readonly DEFAULT_SPAWN_TIME_LENGTH = 2;
- private static readonly DEFAULT_SPAWN_RADIUS = 20;
-
- private lastSpawnTimeInSeconds = 0;
- private currentSpawnInterval = 0;
- private currentSpawnRadius = 0;
- private lastGenerationChangeTimeInSeconds = 0;
-
- public nextGenerationId = 1;
- public generationCounts: {
- currentGenerationCount: number;
- nextGenerationCount: number;
- } = {
- currentGenerationCount: 0,
- nextGenerationCount: 1,
- };
-
- public constructor(startingTimeInSeconds: number) {
- this.lastSpawnTimeInSeconds = startingTimeInSeconds;
- this.lastGenerationChangeTimeInSeconds = startingTimeInSeconds;
- }
-
- private lastSpawnAction: SpawnAction | undefined;
-
- public getSpawnAction(timeInSeconds: number, canvasSize: vec2): SpawnAction {
- if (
- this.lastSpawnAction &&
- timeInSeconds - this.lastSpawnTimeInSeconds < GameRules.DEFAULT_SPAWN_TIME_LENGTH
- ) {
- return this.lastSpawnAction;
- }
-
- this.currentSpawnInterval = mix(
- GameRules.DEFAULT_SPAWN_INTERVAL,
- GameRules.DEFAULT_SPAWN_INTERVAL / 5,
- clamp01((timeInSeconds - this.lastGenerationChangeTimeInSeconds) / 120)
- );
-
- this.currentSpawnRadius = mix(
- GameRules.DEFAULT_SPAWN_RADIUS,
- GameRules.DEFAULT_SPAWN_RADIUS * 3,
- clamp01((timeInSeconds - this.lastGenerationChangeTimeInSeconds) / 120)
- );
-
- const q = this.generationCounts.nextGenerationCount / settings.agentCount;
-
- if (
- timeInSeconds - this.lastSpawnTimeInSeconds < this.currentSpawnInterval ||
- q > 0.05
- ) {
- return {
- generation: this.nextGenerationId,
- position: vec2.create(),
- radius: 0,
- };
- }
-
- this.lastSpawnTimeInSeconds = timeInSeconds;
-
- this.lastSpawnAction = {
- generation: this.nextGenerationId,
- position: vec2.fromValues(
- Random.randomBetween(0, canvasSize[0]),
- Random.randomBetween(0, canvasSize[1])
- ),
- radius: this.currentSpawnRadius,
- };
-
- return this.lastSpawnAction;
- }
-
- public updateGenerationCounts({
- evenGenerationCount,
- oddGenerationCount,
- }: GenerationCounts): void {
- const nextGenerationCount =
- this.nextGenerationId % 2 === 1 ? oddGenerationCount : evenGenerationCount;
- const currentGenerationCount =
- this.nextGenerationId % 2 === 1 ? evenGenerationCount : oddGenerationCount;
-
- const q = currentGenerationCount / settings.agentCount;
-
- if (currentGenerationCount <= 100 && q < 0.05) {
- this.nextGenerationId++;
- this.lastGenerationChangeTimeInSeconds = performance.now() / 1000;
- }
-
- this.generationCounts = {
- currentGenerationCount,
- nextGenerationCount,
- };
- }
-
- public getNextGenerationMoveSpeed(): number {
- const q = this.generationCounts.nextGenerationCount / settings.agentCount;
- return mix(settings.moveSpeed / 8, settings.moveSpeed, q ** 2);
- }
-
- public getInfectionProbability(): number {
- const q = this.generationCounts.nextGenerationCount / settings.agentCount;
- return clamp(mix(0.3, 1, q * 5), 0, 0.9);
- }
-
- public getSensorOffset(): number {
- const q = this.generationCounts.nextGenerationCount / settings.agentCount;
- return mix(20, settings.sensorOffsetDistance, q);
- }
-}
diff --git a/src/game-loop/pointer-input.test.ts b/src/game-loop/pointer-input.test.ts
new file mode 100644
index 0000000..8f99179
--- /dev/null
+++ b/src/game-loop/pointer-input.test.ts
@@ -0,0 +1,277 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+type PointerListener = (event: PointerEvent) => void;
+
+const makePointerEvent = (
+ type: string,
+ event: Partial = {}
+): PointerEvent =>
+ ({
+ buttons: 1,
+ clientX: 10,
+ clientY: 20,
+ isTrusted: true,
+ pointerId: 1,
+ pointerType: 'mouse',
+ pressure: 0.5,
+ timeStamp: 100,
+ type,
+ ...event,
+ }) as PointerEvent;
+
+const toPoint = (point: ArrayLike): Array => Array.from(point);
+
+class FakeCanvas {
+ public readonly capturedPointerIds: Array = [];
+ public readonly releasedPointerIds: Array = [];
+ public width = 300;
+ public height = 200;
+
+ private readonly listeners = new Map>();
+
+ public addEventListener(
+ type: string,
+ listener: EventListenerOrEventListenerObject
+ ): void {
+ const listeners = this.listeners.get(type) ?? new Set();
+ const pointerListener =
+ typeof listener === 'function'
+ ? listener
+ : (event: Event) => listener.handleEvent(event);
+ listeners.add(pointerListener as PointerListener);
+ this.listeners.set(type, listeners);
+ }
+
+ public removeEventListener(
+ type: string,
+ listener: EventListenerOrEventListenerObject
+ ): void {
+ const listeners = this.listeners.get(type);
+ if (!listeners) {
+ return;
+ }
+
+ listeners.delete(listener as PointerListener);
+ }
+
+ public dispatchPointerEvent(type: string, event: Partial = {}): void {
+ const pointerEvent = makePointerEvent(type, event);
+
+ this.listeners.get(type)?.forEach((listener) => listener(pointerEvent));
+ }
+
+ public getBoundingClientRect(): DOMRect {
+ return {
+ bottom: this.height,
+ height: this.height,
+ left: 0,
+ right: this.width,
+ toJSON: () => ({}),
+ top: 0,
+ width: this.width,
+ x: 0,
+ y: 0,
+ } as DOMRect;
+ }
+
+ public setPointerCapture(pointerId: number): void {
+ this.capturedPointerIds.push(pointerId);
+ }
+
+ public releasePointerCapture(pointerId: number): void {
+ this.releasedPointerIds.push(pointerId);
+ }
+}
+
+const makeSwipePipeline = () => ({
+ addSwipeSegment: vi.fn(),
+ clearSwipes: vi.fn(),
+});
+
+const createPointerInput = async () => {
+ const { GardenPointerInput } = await import('./pointer-input');
+ const { settings: runtimeSettings } = await import('../settings');
+ const canvas = new FakeCanvas();
+ const audio = {
+ beginGesture: vi.fn(),
+ endGesture: vi.fn(),
+ start: vi.fn(),
+ stroke: vi.fn(),
+ touchDown: vi.fn(),
+ };
+ const brushPipeline = makeSwipePipeline();
+ const eraserAgentPipeline = makeSwipePipeline();
+ const eraserTexturePipeline = makeSwipePipeline();
+ const eraserPreview = {
+ isPointerInsideCanvas: vi.fn(() => true),
+ setEraseMode: vi.fn(),
+ setPointerHoveringCanvas: vi.fn(),
+ update: vi.fn(),
+ };
+ const onStartDrawing = vi.fn();
+ const onEraseGestureEnded = vi.fn();
+ const spawnStrokeAgents = vi.fn();
+ const input = new GardenPointerInput({
+ audio,
+ brushPipeline,
+ canvas: canvas as unknown as HTMLCanvasElement,
+ eraserAgentPipeline,
+ eraserPreview,
+ eraserTexturePipeline,
+ getCanvasSize: () => [canvas.width, canvas.height],
+ getDevicePixelRatio: () => 1,
+ getMirrorSegmentCount: () => 1,
+ onEraseGestureEnded,
+ onStartDrawing,
+ spawnStrokeAgents,
+ } as unknown as ConstructorParameters[0]);
+
+ input.attach();
+
+ return {
+ audio,
+ brushPipeline,
+ canvas,
+ input,
+ onStartDrawing,
+ runtimeSettings,
+ spawnStrokeAgents,
+ };
+};
+
+describe('GardenPointerInput drawing startup', () => {
+ beforeEach(() => {
+ vi.resetModules();
+ vi.stubGlobal('localStorage', {
+ clear: vi.fn(),
+ getItem: vi.fn(() => null),
+ removeItem: vi.fn(),
+ setItem: vi.fn(),
+ });
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('allows pointer drawing immediately', async () => {
+ const { audio, brushPipeline, canvas, onStartDrawing, spawnStrokeAgents } =
+ await createPointerInput();
+
+ canvas.dispatchPointerEvent('pointerdown', { pointerId: 7 });
+ canvas.dispatchPointerEvent('pointermove', {
+ clientX: 60,
+ clientY: 80,
+ pointerId: 7,
+ timeStamp: 120,
+ });
+
+ expect(onStartDrawing).toHaveBeenCalledTimes(1);
+ expect(audio.start).toHaveBeenCalledWith(expect.anything(), { userGesture: true });
+ expect(audio.beginGesture).toHaveBeenCalledTimes(1);
+ expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(2);
+ expect(spawnStrokeAgents).toHaveBeenCalledTimes(2);
+ expect(canvas.capturedPointerIds).toEqual([7]);
+ });
+
+ it('starts drawing from a fresh pointerdown', async () => {
+ const { audio, brushPipeline, canvas, onStartDrawing, spawnStrokeAgents } =
+ await createPointerInput();
+
+ canvas.dispatchPointerEvent('pointerdown', { pointerId: 9 });
+
+ expect(onStartDrawing).toHaveBeenCalledTimes(1);
+ expect(audio.start).toHaveBeenCalledWith(expect.anything(), { userGesture: true });
+ expect(audio.beginGesture).toHaveBeenCalledTimes(1);
+ expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(1);
+ expect(spawnStrokeAgents).toHaveBeenCalledTimes(1);
+ expect(canvas.capturedPointerIds).toEqual([9]);
+ });
+
+ it('flushes the delayed smoothed stroke tail on pointerup', async () => {
+ const { brushPipeline, canvas } = await createPointerInput();
+
+ canvas.dispatchPointerEvent('pointerdown', { pointerId: 9 });
+ canvas.dispatchPointerEvent('pointermove', {
+ clientX: 60,
+ clientY: 80,
+ pointerId: 9,
+ timeStamp: 120,
+ });
+ canvas.dispatchPointerEvent('pointerup', {
+ clientX: 60,
+ clientY: 80,
+ pointerId: 9,
+ timeStamp: 140,
+ });
+
+ expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(3);
+ expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[1][0])).toEqual([10, 20]);
+ expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[1][1])).toEqual([35, 50]);
+ expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[2][0])).toEqual([35, 50]);
+ expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[2][1])).toEqual([60, 80]);
+ });
+
+ it('uses coalesced pointer samples for smoother brush segments', async () => {
+ const { audio, brushPipeline, canvas, spawnStrokeAgents } =
+ await createPointerInput();
+
+ canvas.dispatchPointerEvent('pointerdown', { pointerId: 9 });
+ audio.stroke.mockClear();
+ brushPipeline.addSwipeSegment.mockClear();
+ spawnStrokeAgents.mockClear();
+
+ canvas.dispatchPointerEvent('pointermove', {
+ clientX: 40,
+ clientY: 20,
+ getCoalescedEvents: () => [
+ makePointerEvent('pointermove', {
+ clientX: 20,
+ clientY: 20,
+ pointerId: 9,
+ timeStamp: 110,
+ }),
+ makePointerEvent('pointermove', {
+ clientX: 30,
+ clientY: 20,
+ pointerId: 9,
+ timeStamp: 115,
+ }),
+ makePointerEvent('pointermove', {
+ clientX: 40,
+ clientY: 20,
+ pointerId: 9,
+ timeStamp: 120,
+ }),
+ ],
+ pointerId: 9,
+ timeStamp: 120,
+ });
+
+ expect(audio.stroke).toHaveBeenCalledTimes(3);
+ expect(spawnStrokeAgents).toHaveBeenCalledTimes(3);
+ expect(brushPipeline.addSwipeSegment.mock.calls.length).toBeGreaterThan(3);
+ });
+
+ it('caps curve tessellation with the brush curve resolution setting', async () => {
+ const { brushPipeline, canvas, runtimeSettings } = await createPointerInput();
+ runtimeSettings.brushCurveResolution = 2;
+ runtimeSettings.brushSize = 1;
+
+ canvas.dispatchPointerEvent('pointerdown', { pointerId: 9 });
+ canvas.dispatchPointerEvent('pointermove', {
+ clientX: 10,
+ clientY: 60,
+ pointerId: 9,
+ timeStamp: 120,
+ });
+ canvas.dispatchPointerEvent('pointermove', {
+ clientX: 60,
+ clientY: 60,
+ pointerId: 9,
+ timeStamp: 140,
+ });
+
+ expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(4);
+ });
+});
diff --git a/src/game-loop/pointer-input.ts b/src/game-loop/pointer-input.ts
index 58067d4..6ac1c35 100644
--- a/src/game-loop/pointer-input.ts
+++ b/src/game-loop/pointer-input.ts
@@ -26,10 +26,16 @@ interface GardenPointerInputOptions {
}
export class GardenPointerInput {
+ private static readonly MIN_SMOOTH_SAMPLE_DISTANCE_SQUARED = 0.25;
+ private static readonly MIN_CURVE_SEGMENT_SPACING_PIXELS = 4;
+ private static readonly CURVE_SEGMENT_BRUSH_RADIUS_RATIO = 0.65;
+
private activePointerId: number | null = null;
private lastPointerPosition: vec2 | null = null;
private lastPointerEventTimeMs: number | null = null;
private lastPointerPressure = 0.5;
+ private smoothedStrokePoints: Array = [];
+ private lastSmoothedBrushPosition: vec2 | null = null;
private isErasing = false;
public constructor(private readonly options: GardenPointerInputOptions) {}
@@ -75,6 +81,14 @@ export class GardenPointerInput {
if (this.lastPointerPosition !== null) {
vec2.mul(this.lastPointerPosition, this.lastPointerPosition, scale);
}
+
+ this.smoothedStrokePoints.forEach((point) => {
+ vec2.mul(point, point, scale);
+ });
+
+ if (this.lastSmoothedBrushPosition !== null) {
+ vec2.mul(this.lastSmoothedBrushPosition, this.lastSmoothedBrushPosition, scale);
+ }
}
public get isSwipeActive(): boolean {
@@ -98,6 +112,13 @@ export class GardenPointerInput {
this.options.audio.start(activeVibe, { userGesture: event.isTrusted });
this.options.audio.beginGesture();
+ this.options.audio.touchDown({
+ vibe: activeVibe,
+ colorIndex: settings.selectedColorIndex,
+ mirrorSegmentCount: this.options.getMirrorSegmentCount(),
+ pressure: this.getPointerPressure(event),
+ pointerType: event.pointerType,
+ });
this.options.onStartDrawing();
this.activePointerId = event.pointerId;
this.canvas.setPointerCapture(event.pointerId);
@@ -106,8 +127,9 @@ export class GardenPointerInput {
this.options.eraserTexturePipeline.clearSwipes();
this.lastPointerPosition = null;
this.lastPointerEventTimeMs = null;
+ this.clearSmoothedStroke();
this.lastPointerPressure = this.getPointerPressure(event);
- this.addSwipeAt(event);
+ this.addSwipeAt(event, { emitAudio: false });
};
private readonly onPointerMove = (event: PointerEvent) => {
@@ -115,7 +137,9 @@ export class GardenPointerInput {
if (event.pointerId !== this.activePointerId) {
return;
}
- this.addSwipeAt(event);
+ this.getCoalescedPointerEvents(event).forEach((coalescedEvent) => {
+ this.addSwipeAt(coalescedEvent);
+ });
};
private readonly onPointerUp = (event: PointerEvent) => {
@@ -123,6 +147,7 @@ export class GardenPointerInput {
return;
}
this.addSwipeAt(event, { emitAudio: false });
+ this.finishSmoothedStroke();
this.options.audio.endGesture();
if (this.isErasing) {
this.options.onEraseGestureEnded();
@@ -131,6 +156,7 @@ export class GardenPointerInput {
this.activePointerId = null;
this.lastPointerPosition = null;
this.lastPointerEventTimeMs = null;
+ this.clearSmoothedStroke();
this.options.eraserPreview.setPointerHoveringCanvas(
this.options.eraserPreview.isPointerInsideCanvas(event)
);
@@ -169,14 +195,14 @@ export class GardenPointerInput {
? [{ from: previousPosition, to: position }]
: this.getMirroredStrokeSegments(previousPosition, position);
- segments.forEach((segment) => {
- if (this.isErasing) {
+ if (this.isErasing) {
+ segments.forEach((segment) => {
this.options.eraserAgentPipeline.addSwipeSegment(segment.from, segment.to);
this.options.eraserTexturePipeline.addSwipeSegment(segment.from, segment.to);
- } else {
- this.options.brushPipeline.addSwipeSegment(segment.from, segment.to);
- }
- });
+ });
+ } else {
+ this.addSmoothedBrushSample(position);
+ }
if (!this.isErasing) {
segments.forEach((segment) => {
@@ -194,6 +220,7 @@ export class GardenPointerInput {
pressure: pressure > 0 ? pressure : this.lastPointerPressure,
velocityPixelsPerSecond,
eraserSizePixels: settings.eraserSize * devicePixelRatio,
+ mirrorSegmentCount: this.options.getMirrorSegmentCount(),
pointerType: event.pointerType,
});
}
@@ -201,6 +228,113 @@ export class GardenPointerInput {
this.lastPointerEventTimeMs = event.timeStamp;
}
+ private addSmoothedBrushSample(position: vec2): void {
+ const previousSample =
+ this.smoothedStrokePoints[this.smoothedStrokePoints.length - 1];
+ if (
+ previousSample !== undefined &&
+ vec2.squaredDistance(previousSample, position) <=
+ GardenPointerInput.MIN_SMOOTH_SAMPLE_DISTANCE_SQUARED
+ ) {
+ return;
+ }
+
+ this.smoothedStrokePoints.push(vec2.clone(position));
+
+ if (this.smoothedStrokePoints.length > 3) {
+ this.smoothedStrokePoints.shift();
+ }
+
+ if (this.smoothedStrokePoints.length === 1) {
+ this.addMirroredBrushSegment(position, position);
+ this.lastSmoothedBrushPosition = vec2.clone(position);
+ return;
+ }
+
+ if (this.smoothedStrokePoints.length === 2) {
+ const [start, end] = this.smoothedStrokePoints;
+ const midpoint = getMidpoint(start, end);
+ this.addMirroredBrushSegment(start, midpoint);
+ this.lastSmoothedBrushPosition = midpoint;
+ return;
+ }
+
+ const [start, control, end] = this.smoothedStrokePoints;
+ const curveStart = getMidpoint(start, control);
+ const curveEnd = getMidpoint(control, end);
+ this.addQuadraticBrushSegments(curveStart, control, curveEnd);
+ this.lastSmoothedBrushPosition = curveEnd;
+ }
+
+ private addQuadraticBrushSegments(start: vec2, control: vec2, end: vec2): void {
+ const curveLength = vec2.distance(start, control) + vec2.distance(control, end);
+ const brushRadius = Math.max(1, settings.brushSize / 2);
+ const segmentSpacing = Math.max(
+ GardenPointerInput.MIN_CURVE_SEGMENT_SPACING_PIXELS,
+ brushRadius * GardenPointerInput.CURVE_SEGMENT_BRUSH_RADIUS_RATIO
+ );
+ const mirrorSegmentCount = Math.max(1, this.options.getMirrorSegmentCount());
+ const curveResolution = getBrushCurveResolution();
+ const maxCurveSegments = Math.max(
+ 1,
+ Math.floor(curveResolution / Math.sqrt(mirrorSegmentCount))
+ );
+ const segmentCount = Math.min(
+ maxCurveSegments,
+ Math.max(1, Math.ceil(curveLength / segmentSpacing))
+ );
+
+ let previousPoint = start;
+ for (let i = 1; i <= segmentCount; i++) {
+ const point = getQuadraticPoint(start, control, end, i / segmentCount);
+ this.addMirroredBrushSegment(previousPoint, point);
+ previousPoint = point;
+ }
+ }
+
+ private addMirroredBrushSegment(from: vec2, to: vec2): void {
+ this.getMirroredStrokeSegments(from, to).forEach((segment) => {
+ this.options.brushPipeline.addSwipeSegment(segment.from, segment.to);
+ });
+ }
+
+ private finishSmoothedStroke(): void {
+ if (this.isErasing || this.smoothedStrokePoints.length === 0) {
+ return;
+ }
+
+ const finalSample = this.smoothedStrokePoints[this.smoothedStrokePoints.length - 1];
+ if (
+ this.lastSmoothedBrushPosition !== null &&
+ vec2.squaredDistance(this.lastSmoothedBrushPosition, finalSample) >
+ GardenPointerInput.MIN_SMOOTH_SAMPLE_DISTANCE_SQUARED
+ ) {
+ this.addMirroredBrushSegment(this.lastSmoothedBrushPosition, finalSample);
+ }
+ }
+
+ private clearSmoothedStroke(): void {
+ this.smoothedStrokePoints.length = 0;
+ this.lastSmoothedBrushPosition = null;
+ }
+
+ private getCoalescedPointerEvents(event: PointerEvent): Array {
+ const getCoalescedEvents = (
+ event as PointerEvent & { getCoalescedEvents?: () => Array }
+ ).getCoalescedEvents;
+ const coalescedEvents =
+ typeof getCoalescedEvents === 'function' ? getCoalescedEvents.call(event) : [];
+
+ if (coalescedEvents.length === 0) {
+ return [event];
+ }
+
+ const lastEvent = coalescedEvents[coalescedEvents.length - 1];
+ return isSamePointerSample(lastEvent, event)
+ ? coalescedEvents
+ : [...coalescedEvents, event];
+ }
+
private getMirroredStrokeSegments(from: vec2, to: vec2): Array {
const segmentCount = this.options.getMirrorSegmentCount();
if (segmentCount <= 1) {
@@ -246,3 +380,27 @@ const rotatePointAround = (point: vec2, center: vec2, angle: number): vec2 => {
center[1] + offsetX * sin + offsetY * cos
);
};
+
+const getMidpoint = (from: vec2, to: vec2): vec2 =>
+ vec2.fromValues((from[0] + to[0]) / 2, (from[1] + to[1]) / 2);
+
+const getQuadraticPoint = (start: vec2, control: vec2, end: vec2, t: number): vec2 => {
+ const inverseT = 1 - t;
+ return vec2.fromValues(
+ inverseT * inverseT * start[0] + 2 * inverseT * t * control[0] + t * t * end[0],
+ inverseT * inverseT * start[1] + 2 * inverseT * t * control[1] + t * t * end[1]
+ );
+};
+
+const getBrushCurveResolution = (): number => {
+ const resolution = Number.isFinite(settings.brushCurveResolution)
+ ? settings.brushCurveResolution
+ : appConfig.runtimeSettings.defaults.brushCurveResolution;
+ return Math.max(1, Math.floor(resolution));
+};
+
+const isSamePointerSample = (left: PointerEvent, right: PointerEvent): boolean =>
+ left.clientX === right.clientX &&
+ left.clientY === right.clientY &&
+ left.pressure === right.pressure &&
+ left.buttons === right.buttons;
diff --git a/src/game-loop/simulation-frame.ts b/src/game-loop/simulation-frame.ts
index 5615063..43ad661 100644
--- a/src/game-loop/simulation-frame.ts
+++ b/src/game-loop/simulation-frame.ts
@@ -7,7 +7,7 @@ import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeli
import { RenderPipeline } from '../pipelines/render/render-pipeline';
import { SimulationTextures } from './simulation-textures';
-export interface SimulationFramePipelines {
+interface SimulationFramePipelines {
copyPipeline: CopyPipeline;
agentPipeline: AgentPipeline;
brushPipeline: BrushPipeline;
diff --git a/src/index.dom-contract.test.ts b/src/index.dom-contract.test.ts
index ade0170..a1fd0a6 100644
--- a/src/index.dom-contract.test.ts
+++ b/src/index.dom-contract.test.ts
@@ -45,9 +45,9 @@ const selectorExists = (selector: string) => {
};
describe('index DOM selector contract', () => {
- it('keeps every boot-time querySelector target present in index.html', () => {
+ it('keeps every boot-time required selector target present in index.html', () => {
const selectors = Array.from(
- indexSource.matchAll(/document\.querySelector(?:All)?\(\s*'([^']+)'\s*\)/g),
+ indexSource.matchAll(/queryRequiredElements?\(\s*'([^']+)'\s*,/g),
(match) => match[1]
);
diff --git a/src/index.scss b/src/index.scss
index e4d6608..84ff5c0 100644
--- a/src/index.scss
+++ b/src/index.scss
@@ -4,4 +4,6 @@
@use 'style/control-dock';
@use 'style/toolbar';
@use 'style/panels';
+@use 'style/config-pane';
+@use 'style/loading';
@use 'style/motion';
diff --git a/src/index.ts b/src/index.ts
index 6e35a16..c4a78a1 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -8,7 +8,9 @@ import { ConfigPane } from './page/config-pane';
import { FullScreenHandler } from './page/full-screen-handler';
import { MenuHider } from './page/menu-hider';
import { activeVibe, applyVibeSettings, resetSettings, settings } from './settings';
+import { readBrowserStorage, writeBrowserStorage } from './utils/browser-storage';
import { DeltaTimeCalculator } from './utils/delta-time-calculator';
+import { queryRequiredElement, queryRequiredElements } from './utils/dom';
import { ErrorHandler, Severity } from './utils/error-handler';
import { initializeGpu } from './utils/graphics/initialize-gpu';
import { VIBE_PRESETS } from './vibes';
@@ -37,10 +39,13 @@ const getMirrorSegmentRatio = (count: number): number =>
(count - appConfig.toolbar.mirror.min) /
(appConfig.toolbar.mirror.max - appConfig.toolbar.mirror.min);
+const mirrorSegmentNames: Readonly> =
+ appConfig.toolbar.mirror.names;
+
const formatMirrorSegmentCount = (count: number): string =>
count === appConfig.toolbar.mirror.default
? 'Mirror off'
- : `${count} ${appConfig.toolbar.mirror.names[count] ?? 'slices'}`;
+ : `${count} ${mirrorSegmentNames[count] ?? 'slices'}`;
const renderRuntimeMessage = (
container: HTMLElement,
@@ -63,40 +68,52 @@ const renderRuntimeMessage = (
};
const elements = {
- aside: document.querySelector('aside') as HTMLDivElement,
- infoButton: document.querySelector('button.info') as HTMLButtonElement,
- infoElement: document.querySelector('.info-page') as HTMLDivElement,
- minimizeFullScreenButton: document.querySelector(
- 'button.minimize-full-screen'
- ) as HTMLButtonElement,
- maximizeFullScreenButton: document.querySelector(
- 'button.maximize-full-screen'
- ) as HTMLButtonElement,
- settingsButton: document.querySelector('button.settings') as HTMLButtonElement,
- soundButton: document.querySelector('button.sound') as HTMLButtonElement,
- restartButton: document.querySelector('button.restart') as HTMLButtonElement,
- canvas: document.querySelector('canvas') as HTMLCanvasElement,
- eraserPreview: document.querySelector('.eraser-preview') as HTMLDivElement,
- errorContainer: document.querySelector('.errors-container') as HTMLDivElement,
- previousVibe: document.querySelector('.previous-vibe') as HTMLButtonElement,
- nextVibe: document.querySelector('.next-vibe') as HTMLButtonElement,
- swatches: Array.from(
- document.querySelectorAll('.color-swatch')
- ) as Array,
- eraserSizeControl: document.querySelector('.eraser-size-control') as HTMLLabelElement,
- eraserSizeSlider: document.querySelector('.eraser-size-slider') as HTMLInputElement,
- mirrorSegmentControl: document.querySelector(
- '.mirror-segment-control'
- ) as HTMLLabelElement,
- mirrorSegmentSlider: document.querySelector(
- '.mirror-segment-slider'
- ) as HTMLInputElement,
- export4k: document.querySelector('.export-4k') as HTMLButtonElement,
- exportStatus: document.querySelector('.export-status') as HTMLSpanElement,
- prompt: document.querySelector('.garden-prompt') as HTMLDivElement,
+ aside: queryRequiredElement('aside', HTMLDivElement),
+ infoButton: queryRequiredElement('button.info', HTMLButtonElement),
+ infoElement: queryRequiredElement('.info-page', HTMLDivElement),
+ minimizeFullScreenButton: queryRequiredElement(
+ 'button.minimize-full-screen',
+ HTMLButtonElement
+ ),
+ maximizeFullScreenButton: queryRequiredElement(
+ 'button.maximize-full-screen',
+ HTMLButtonElement
+ ),
+ settingsButton: queryRequiredElement('button.settings', HTMLButtonElement),
+ soundButton: queryRequiredElement('button.sound', HTMLButtonElement),
+ restartButton: queryRequiredElement('button.restart', HTMLButtonElement),
+ canvas: queryRequiredElement('canvas', HTMLCanvasElement),
+ eraserPreview: queryRequiredElement('.eraser-preview', HTMLDivElement),
+ errorContainer: queryRequiredElement('.errors-container', HTMLDivElement),
+ 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
+ ),
+ export4k: queryRequiredElement('.export-4k', HTMLButtonElement),
+ exportStatus: queryRequiredElement('.export-status', HTMLSpanElement),
+ prompt: queryRequiredElement('.garden-prompt', HTMLDivElement),
+ loadingIndicator: queryRequiredElement('.loading-indicator', HTMLDivElement),
+ loadingStatus: queryRequiredElement('.loading-status', HTMLDivElement),
+ loadingProgress: queryRequiredElement('.loading-progress', HTMLDivElement),
};
-let isAudioMuted = localStorage.getItem(appConfig.storage.audioMutedKey) === '1';
+const setLoadingStage = (label: string, ratio: number) => {
+ const percent = Math.round(Math.max(0, Math.min(1, ratio)) * 100);
+ elements.loadingStatus.textContent = label;
+ elements.loadingIndicator.style.setProperty('--loading-progress', `${percent}%`);
+ elements.loadingProgress.setAttribute('aria-valuenow', String(percent));
+};
+
+let isAudioMuted = readBrowserStorage(appConfig.storage.audioMutedKey) === '1';
const renderAudioUi = (game: GameLoop | null) => {
elements.soundButton.classList.toggle('muted', isAudioMuted);
@@ -185,6 +202,7 @@ const main = async () => {
ErrorHandler.addOnErrorListener((error, _metadata) => {
renderRuntimeMessage(elements.errorContainer, error);
if (error.severity === Severity.ERROR) {
+ document.body.classList.remove('is-loading');
game?.destroy();
shouldStop = true;
}
@@ -223,7 +241,8 @@ const main = async () => {
() =>
FullScreenHandler.isInFullScreenMode() &&
!configPane.isOpen &&
- !infoPageHandler.isOpen
+ !infoPageHandler.isOpen,
+ { persistentElement: elements.settingsButton }
);
new FullScreenHandler(
elements.minimizeFullScreenButton,
@@ -232,13 +251,16 @@ const main = async () => {
);
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;
- localStorage.setItem(appConfig.storage.audioMutedKey, isAudioMuted ? '1' : '0');
+ writeBrowserStorage(appConfig.storage.audioMutedKey, isAudioMuted ? '1' : '0');
renderAudioUi(game);
if (!isAudioMuted) {
game?.startAudio(event.isTrusted);
@@ -323,6 +345,7 @@ const main = async () => {
renderMirrorSegmentUi();
renderAudioUi(game);
+ let isFirstStart = true;
while (!shouldStop) {
game = new GameLoop(elements.canvas, gpu, deltaTimeCalculator, {
prompt: elements.prompt,
@@ -334,9 +357,18 @@ const main = async () => {
renderMirrorSegmentUi();
renderAudioUi(game);
- await game.start();
+ const startPromise = game.start();
+ if (isFirstStart) {
+ isFirstStart = false;
+ setLoadingStage('Ready', 1);
+ requestAnimationFrame(() =>
+ requestAnimationFrame(() => document.body.classList.remove('is-loading'))
+ );
+ }
+ await startPromise;
}
} catch (e) {
+ document.body.classList.remove('is-loading');
ErrorHandler.addException(e);
console.error(e);
}
diff --git a/src/page/config-pane.ts b/src/page/config-pane.ts
index 0a78d3d..bf27c78 100644
--- a/src/page/config-pane.ts
+++ b/src/page/config-pane.ts
@@ -9,6 +9,32 @@ import { activeVibe, settings } from '../settings';
import { VIBE_PRESETS } from '../vibes';
type PaneContainer = Pick;
+type ColorReactionKey = (typeof colorReactionRows)[number]['keys'][number];
+
+const colorReactionRows = [
+ {
+ colorIndex: 0,
+ label: '1',
+ keys: ['color1ToColor1', 'color1ToColor2', 'color1ToColor3'],
+ },
+ {
+ colorIndex: 1,
+ label: '2',
+ keys: ['color2ToColor1', 'color2ToColor2', 'color2ToColor3'],
+ },
+ {
+ colorIndex: 2,
+ label: '3',
+ keys: ['color3ToColor1', 'color3ToColor2', 'color3ToColor3'],
+ },
+] as const;
+
+const colorReactionKeySet = new Set(
+ colorReactionRows.flatMap((row) => [...row.keys])
+);
+
+const isColorReactionKey = (key: string): key is ColorReactionKey =>
+ colorReactionKeySet.has(key);
interface ConfigPaneOptions {
onConfigChange: () => void;
@@ -37,6 +63,14 @@ const toLabel = (value: string): string =>
.trim();
const normalizeNumber = (value: number, config: NumberControlConfig): number => {
+ if (config.options) {
+ const optionValues = Object.values(config.options);
+ if (optionValues.includes(value)) {
+ return value;
+ }
+ return optionValues.includes(0) ? 0 : (optionValues[0] ?? config.min);
+ }
+
const finiteValue = Number.isFinite(value) ? value : config.min;
const clampedValue = Math.min(config.max, Math.max(config.min, finiteValue));
return config.integer ? Math.round(clampedValue) : clampedValue;
@@ -49,12 +83,21 @@ const getNumberBindingParams = (
label: config.label ?? toLabel(key),
min: config.min,
max: config.max,
+ options: config.options,
step: config.step,
});
export class ConfigPane {
private readonly container: HTMLDivElement;
private readonly pane: Pane;
+ private readonly colorReactionSelects = new Map<
+ ColorReactionKey,
+ HTMLSelectElement
+ >();
+ private readonly colorReactionSwatches: Array<{
+ colorIndex: number;
+ element: HTMLElement;
+ }> = [];
private readonly state = {
activeVibeId: activeVibe.id,
};
@@ -106,6 +149,7 @@ export class ConfigPane {
public refresh(): void {
this.state.activeVibeId = activeVibe.id;
this.pane.refresh();
+ this.syncColorReactionMatrix();
this.syncButton();
}
@@ -148,7 +192,19 @@ export class ConfigPane {
.on('click', () => this.options.onRestart());
const folders = new Map();
+ let hasAddedColorReactionMatrix = false;
Object.entries(appConfig.runtimeSettings.controls).forEach(([key, config]) => {
+ const settingKey = key as keyof GardenRuntimeSettings & string;
+ settings[settingKey] = normalizeNumber(settings[settingKey], config);
+
+ if (isColorReactionKey(key)) {
+ if (!hasAddedColorReactionMatrix) {
+ this.addColorReactionMatrix(container);
+ hasAddedColorReactionMatrix = true;
+ }
+ return;
+ }
+
const folder =
folders.get(config.folder) ??
container.addFolder({
@@ -157,8 +213,6 @@ export class ConfigPane {
});
folders.set(config.folder, folder);
- const settingKey = key as keyof GardenRuntimeSettings & string;
- settings[settingKey] = normalizeNumber(settings[settingKey], config);
folder
.addBinding(settings, settingKey, getNumberBindingParams(settingKey, config))
.on('change', () => {
@@ -170,6 +224,117 @@ export class ConfigPane {
this.options.onRuntimeChange();
});
});
+ this.syncColorReactionMatrix();
+ }
+
+ private addColorReactionMatrix(container: PaneContainer): void {
+ const folder = container.addFolder({
+ title: 'Color Reactions',
+ expanded: true,
+ });
+ folder.element.classList.add('color-reaction-folder');
+
+ const content = Array.from(folder.element.children).find((child) =>
+ child.classList.contains('tp-fldv_c')
+ );
+ if (!(content instanceof HTMLElement)) {
+ return;
+ }
+
+ const doc = folder.element.ownerDocument;
+ const matrix = doc.createElement('div');
+ matrix.className = 'color-reaction-matrix';
+
+ matrix.appendChild(this.createColorReactionCorner(doc));
+ colorReactionRows.forEach((row) => {
+ matrix.appendChild(this.createColorReactionHeader(doc, row.colorIndex, row.label));
+ });
+
+ colorReactionRows.forEach((row) => {
+ matrix.appendChild(this.createColorReactionHeader(doc, row.colorIndex, row.label));
+ row.keys.forEach((key, columnIndex) => {
+ matrix.appendChild(
+ this.createColorReactionCell(doc, key, row.colorIndex, columnIndex)
+ );
+ });
+ });
+
+ content.appendChild(matrix);
+ this.syncColorReactionMatrix();
+ }
+
+ private createColorReactionCorner(doc: Document): HTMLDivElement {
+ const corner = doc.createElement('div');
+ corner.className = 'color-reaction-matrix__corner';
+ corner.textContent = 'agent';
+ return corner;
+ }
+
+ private createColorReactionHeader(
+ doc: Document,
+ colorIndex: number,
+ label: string
+ ): HTMLDivElement {
+ const header = doc.createElement('div');
+ header.className = 'color-reaction-matrix__header';
+
+ const swatch = doc.createElement('span');
+ swatch.className = 'color-reaction-matrix__swatch';
+ this.colorReactionSwatches.push({ colorIndex, element: swatch });
+ header.appendChild(swatch);
+
+ const text = doc.createElement('span');
+ text.textContent = label;
+ header.appendChild(text);
+
+ return header;
+ }
+
+ private createColorReactionCell(
+ doc: Document,
+ key: ColorReactionKey,
+ sourceColorIndex: number,
+ targetColorIndex: number
+ ): HTMLLabelElement {
+ const cell = doc.createElement('label');
+ cell.className = 'color-reaction-matrix__cell';
+
+ const select = doc.createElement('select');
+ select.setAttribute(
+ 'aria-label',
+ `Color ${sourceColorIndex + 1} agents reacting to color ${targetColorIndex + 1}`
+ );
+
+ const config = appConfig.runtimeSettings.controls[key];
+ Object.entries(config.options ?? {}).forEach(([label, value]) => {
+ const option = doc.createElement('option');
+ option.value = String(value);
+ option.textContent = label;
+ select.appendChild(option);
+ });
+
+ select.addEventListener('change', () => {
+ settings[key] = normalizeNumber(Number(select.value), config);
+ select.value = String(settings[key]);
+ this.options.onRuntimeChange();
+ });
+
+ this.colorReactionSelects.set(key, select);
+ cell.appendChild(select);
+
+ return cell;
+ }
+
+ private syncColorReactionMatrix(): void {
+ this.colorReactionSelects.forEach((select, key) => {
+ const config = appConfig.runtimeSettings.controls[key];
+ settings[key] = normalizeNumber(settings[key], config);
+ select.value = String(settings[key]);
+ });
+
+ this.colorReactionSwatches.forEach(({ colorIndex, element }) => {
+ element.style.backgroundColor = activeVibe.colors[colorIndex] ?? '#ffffff';
+ });
}
private setUpConfigTab(container: PaneContainer): void {
@@ -252,6 +417,7 @@ export class ConfigPane {
}
private syncButton(): void {
+ this.options.settingsButton.classList.toggle('active', this.isOpen);
this.options.settingsButton.setAttribute('aria-expanded', String(this.isOpen));
this.options.settingsButton.setAttribute(
'aria-label',
diff --git a/src/page/full-screen-handler.ts b/src/page/full-screen-handler.ts
index 84c9150..6bf081e 100644
--- a/src/page/full-screen-handler.ts
+++ b/src/page/full-screen-handler.ts
@@ -32,12 +32,11 @@ export class FullScreenHandler {
return document.fullscreenElement !== null;
}
- private updateButtons() {
- this.minimizeButton.style.display = FullScreenHandler.isInFullScreenMode()
- ? 'block'
- : 'none';
- this.maximizeButton.style.display = FullScreenHandler.isInFullScreenMode()
- ? 'none'
- : 'block';
+ private updateButtons(): void {
+ const isInFullScreenMode = FullScreenHandler.isInFullScreenMode();
+ this.minimizeButton.style.display = isInFullScreenMode ? 'block' : 'none';
+ this.maximizeButton.style.display = isInFullScreenMode ? 'none' : 'block';
+ this.minimizeButton.classList.toggle('active', isInFullScreenMode);
+ this.maximizeButton.classList.toggle('active', isInFullScreenMode);
}
}
diff --git a/src/page/menu-hider.ts b/src/page/menu-hider.ts
index c4ba2c2..b2ea459 100644
--- a/src/page/menu-hider.ts
+++ b/src/page/menu-hider.ts
@@ -1,17 +1,33 @@
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;
private timeToLive = MenuHider.DEFAULT_TIME_TO_LIVE;
private isHidden = false;
public constructor(
private readonly element: HTMLElement,
- private readonly shouldBeHidden: () => boolean
+ private readonly shouldBeHidden: () => boolean,
+ private readonly options: MenuHiderOptions = {}
) {
+ this.interactiveElements = Array.from(
+ element.querySelectorAll(
+ 'a[href], button, input, select, textarea, [tabindex]'
+ )
+ );
+
+ if (options.persistentElement) {
+ element.classList.add('has-persistent-settings');
+ }
+
setInterval(() => {
this.timeToLive = Math.max(0, this.timeToLive - MenuHider.INTERVAL);
this.updateVisibility();
@@ -61,8 +77,31 @@ export class MenuHider {
this.isHidden = shouldHide;
this.element.classList.toggle('menu-hidden', shouldHide);
- this.element.style.opacity = shouldHide ? '0' : '1';
- this.element.setAttribute('aria-hidden', String(shouldHide));
- this.element.inert = 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;
+ return;
+ }
+
+ this.element.style.opacity = '';
+ this.element.setAttribute('aria-hidden', 'false');
+ this.element.inert = false;
+
+ this.interactiveElements.forEach((interactiveElement) => {
+ const isPersistentElement = interactiveElement === persistentElement;
+
+ interactiveElement.inert = shouldHide && !isPersistentElement;
+ interactiveElement.toggleAttribute(
+ 'aria-hidden',
+ shouldHide && !isPersistentElement
+ );
+ });
}
}
diff --git a/src/pipelines/agents/agent-generation/agent.ts b/src/pipelines/agents/agent-generation/agent.ts
index 0a81207..630e017 100644
--- a/src/pipelines/agents/agent-generation/agent.ts
+++ b/src/pipelines/agents/agent-generation/agent.ts
@@ -1,13 +1,2 @@
-import { vec2 } from 'gl-matrix';
-
-export interface Agent {
- position: vec2;
- angle: number;
- colorIndex: number;
- targetPosition: vec2;
- targetAngle: number;
- introDelay: number;
-}
-
export const AGENT_FLOAT_COUNT = 8;
export const AGENT_SIZE_IN_BYTES = AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT;
diff --git a/src/pipelines/agents/agent-pipeline.ts b/src/pipelines/agents/agent-pipeline.ts
index 4f80aff..4e1515f 100644
--- a/src/pipelines/agents/agent-pipeline.ts
+++ b/src/pipelines/agents/agent-pipeline.ts
@@ -11,7 +11,7 @@ import shader from './agent.wgsl?raw';
export class AgentPipeline {
private static readonly WORKGROUP_SIZE = 64;
- private static readonly UNIFORM_COUNT = 8;
+ private static readonly UNIFORM_COUNT = 17;
private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly pipeline: GPUComputePipeline;
@@ -58,6 +58,15 @@ export class AgentPipeline {
sensorOffsetDistance,
turnWhenLost,
individualTrailWeight,
+ color1ToColor1,
+ color1ToColor2,
+ color1ToColor3,
+ color2ToColor1,
+ color2ToColor2,
+ color2ToColor3,
+ color3ToColor1,
+ color3ToColor2,
+ color3ToColor3,
agentCount,
introProgress,
}: AgentSettings & {
@@ -74,6 +83,15 @@ export class AgentPipeline {
this.uniformValues[5] = individualTrailWeight;
this.uniformValues[6] = agentCount;
this.uniformValues[7] = introProgress ?? 1;
+ this.uniformValues[8] = color1ToColor1;
+ this.uniformValues[9] = color1ToColor2;
+ this.uniformValues[10] = color1ToColor3;
+ this.uniformValues[11] = color2ToColor1;
+ this.uniformValues[12] = color2ToColor2;
+ this.uniformValues[13] = color2ToColor3;
+ this.uniformValues[14] = color3ToColor1;
+ this.uniformValues[15] = color3ToColor2;
+ this.uniformValues[16] = color3ToColor3;
writeFloat32BufferIfChanged(
this.device,
this.uniforms,
diff --git a/src/pipelines/agents/agent-settings.ts b/src/pipelines/agents/agent-settings.ts
index 55d4e70..cdd4601 100644
--- a/src/pipelines/agents/agent-settings.ts
+++ b/src/pipelines/agents/agent-settings.ts
@@ -1,4 +1,13 @@
export interface AgentSettings {
+ color1ToColor1: number;
+ color1ToColor2: number;
+ color1ToColor3: number;
+ color2ToColor1: number;
+ color2ToColor2: number;
+ color2ToColor3: number;
+ color3ToColor1: number;
+ color3ToColor2: number;
+ color3ToColor3: number;
moveSpeed: number;
turnSpeed: number;
sensorOffsetAngle: number;
diff --git a/src/pipelines/agents/agent.wgsl b/src/pipelines/agents/agent.wgsl
index 527af63..1516f7a 100644
--- a/src/pipelines/agents/agent.wgsl
+++ b/src/pipelines/agents/agent.wgsl
@@ -7,6 +7,15 @@ struct Settings {
individualTrailWeight: f32,
agentCount: f32,
introProgress: f32,
+ color1ToColor1: f32,
+ color1ToColor2: f32,
+ color1ToColor3: f32,
+ color2ToColor1: f32,
+ color2ToColor2: f32,
+ color2ToColor3: f32,
+ color3ToColor1: f32,
+ color3ToColor2: f32,
+ color3ToColor3: f32,
};
@group(1) @binding(0) var settings: Settings;
@@ -58,17 +67,19 @@ fn main(
let sourceRightSample = textureLoad(sourceMap, rightSensor, 0);
let channelMask = get_channel_mask(agent.colorIndex);
- let friendForward = dot(trailForward.rgb, channelMask);
- let friendLeft = dot(trailLeft.rgb, channelMask);
- let friendRight = dot(trailRight.rgb, channelMask);
+ let reactionMask = get_reaction_mask(agent.colorIndex);
- let sourceForward = dot(sourceForwardSample.rgb, channelMask);
- let sourceLeft = dot(sourceLeftSample.rgb, channelMask);
- let sourceRight = dot(sourceRightSample.rgb, channelMask);
+ let trailForwardWeight = dot(trailForward.rgb, reactionMask);
+ let trailLeftWeight = dot(trailLeft.rgb, reactionMask);
+ let trailRightWeight = dot(trailRight.rgb, reactionMask);
- let weightForward = friendForward + sourceForward * 24.0;
- let weightLeft = friendLeft + sourceLeft * 24.0;
- let weightRight = friendRight + sourceRight * 24.0;
+ let sourceForwardWeight = dot(sourceForwardSample.rgb, reactionMask);
+ let sourceLeftWeight = dot(sourceLeftSample.rgb, reactionMask);
+ let sourceRightWeight = dot(sourceRightSample.rgb, reactionMask);
+
+ let weightForward = trailForwardWeight + sourceForwardWeight * 24.0;
+ let weightLeft = trailLeftWeight + sourceLeftWeight * 24.0;
+ let weightRight = trailRightWeight + sourceRightWeight * 24.0;
var rotation = (random.r - 0.5) * settings.turnWhenLost;
if weightForward >= weightLeft && weightForward >= weightRight {
@@ -78,7 +89,8 @@ fn main(
}
let sourceAtAgent = textureLoad(sourceMap, vec2(agent.position), 0);
- let sourceAtAgentStrength = clamp(dot(sourceAtAgent.rgb, channelMask), 0.0, 1.0);
+ let positiveReactionMask = max(reactionMask, vec3(0.0));
+ let sourceAtAgentStrength = clamp(dot(sourceAtAgent.rgb, positiveReactionMask), 0.0, 1.0);
var moveRate = settings.moveRate * mix(1.0, 0.08, sourceAtAgentStrength);
var introTargetOffset = vec2(0.0, 0.0);
var introTargetDistance = 0.0;
@@ -111,7 +123,7 @@ fn main(
}
let sourceBelow = textureLoad(sourceMap, vec2(nextPosition), 0);
- let sourceBelowStrength = dot(sourceBelow.rgb, channelMask);
+ let sourceBelowStrength = clamp(dot(sourceBelow.rgb, positiveReactionMask), 0.0, 1.0);
let trailWeight = settings.individualTrailWeight * (1.0 + sourceBelowStrength * 16.0);
var trailBelow = textureLoad(trailMapIn, vec2(nextPosition), 0);
trailBelow = vec4(
@@ -145,6 +157,28 @@ fn get_channel_mask(colorIndex: f32) -> vec3 {
return vec3(0, 0, 1);
}
+fn get_reaction_mask(colorIndex: f32) -> vec3 {
+ if colorIndex < 0.5 {
+ return vec3(
+ settings.color1ToColor1,
+ settings.color1ToColor2,
+ settings.color1ToColor3
+ );
+ }
+ if colorIndex < 1.5 {
+ return vec3(
+ settings.color2ToColor1,
+ settings.color2ToColor2,
+ settings.color2ToColor3
+ );
+ }
+ return vec3(
+ settings.color3ToColor1,
+ settings.color3ToColor2,
+ settings.color3ToColor3
+ );
+}
+
fn angle_delta(sourceAngle: f32, targetAngle: f32) -> f32 {
return atan2(sin(targetAngle - sourceAngle), cos(targetAngle - sourceAngle));
}
diff --git a/src/pipelines/brush/brush-settings.ts b/src/pipelines/brush/brush-settings.ts
index 32c4539..15ef872 100644
--- a/src/pipelines/brush/brush-settings.ts
+++ b/src/pipelines/brush/brush-settings.ts
@@ -1,5 +1,6 @@
export interface BrushSettings {
brushSize: number;
+ brushCurveResolution: number;
eraserSize: number;
mirrorSegmentCount: number;
brushSizeVariation: number;
diff --git a/src/pipelines/wgsl-uniform-layout.test.ts b/src/pipelines/wgsl-uniform-layout.test.ts
index 8f6444d..e611f17 100644
--- a/src/pipelines/wgsl-uniform-layout.test.ts
+++ b/src/pipelines/wgsl-uniform-layout.test.ts
@@ -110,6 +110,15 @@ describe('WGSL uniform layout contracts', () => {
'individualTrailWeight',
'agentCount',
'introProgress',
+ 'color1ToColor1',
+ 'color1ToColor2',
+ 'color1ToColor3',
+ 'color2ToColor1',
+ 'color2ToColor2',
+ 'color2ToColor3',
+ 'color3ToColor1',
+ 'color3ToColor2',
+ 'color3ToColor3',
],
});
expectStructUniformLayout({
diff --git a/src/settings.ts b/src/settings.ts
index 128bc4e..b043fb1 100644
--- a/src/settings.ts
+++ b/src/settings.ts
@@ -1,4 +1,5 @@
import { appConfig, type GardenRuntimeSettings } from './config';
+import { writeBrowserStorage } from './utils/browser-storage';
import { getInitialVibe, VIBE_PRESETS, type VibePreset } from './vibes';
const buildInitialValues = (vibe: VibePreset): GardenRuntimeSettings => ({
@@ -32,7 +33,7 @@ export const applyVibeSettings = (vibeId: string) => {
selectedColorIndex: Math.min(settings.selectedColorIndex, vibe.colors.length - 1),
});
- localStorage.setItem(appConfig.storage.vibeKey, vibe.id);
+ writeBrowserStorage(appConfig.storage.vibeKey, vibe.id);
return activeVibe;
};
diff --git a/src/style/_app-shell.scss b/src/style/_app-shell.scss
index 5a942c7..86d78b9 100644
--- a/src/style/_app-shell.scss
+++ b/src/style/_app-shell.scss
@@ -54,6 +54,27 @@ html > body {
}
}
+ > .dev-stats-overlay {
+ position: absolute;
+ top: max(10px, env(safe-area-inset-top, 0px));
+ left: max(10px, env(safe-area-inset-left, 0px));
+ z-index: 6;
+ padding: 7px 9px;
+ border: 1px solid rgb(255 255 255 / 18%);
+ border-radius: 6px;
+ background: rgb(9 12 18 / 72%);
+ box-shadow: 0 8px 24px rgb(0 0 0 / 22%);
+ color: rgb(255 255 255 / 90%);
+ font-family:
+ ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
+ monospace;
+ font-size: 11px;
+ line-height: 1.45;
+ pointer-events: none;
+ user-select: none;
+ white-space: pre;
+ }
+
> .errors-container {
position: absolute;
top: 0;
diff --git a/src/style/_config-pane.scss b/src/style/_config-pane.scss
new file mode 100644
index 0000000..6fafa4b
--- /dev/null
+++ b/src/style/_config-pane.scss
@@ -0,0 +1,69 @@
+.config-pane {
+ .color-reaction-folder > .tp-fldv_c {
+ padding: 6px 8px 8px;
+ }
+}
+
+.color-reaction-matrix {
+ display: grid;
+ grid-template-columns: minmax(42px, max-content) repeat(3, minmax(0, 1fr));
+ gap: 4px;
+ align-items: stretch;
+}
+
+.color-reaction-matrix__corner,
+.color-reaction-matrix__header {
+ display: flex;
+ min-width: 0;
+ min-height: 28px;
+ align-items: center;
+ justify-content: center;
+ gap: 5px;
+ color: rgb(255 255 255 / 76%);
+ font-size: 11px;
+ line-height: 1;
+}
+
+.color-reaction-matrix__corner {
+ justify-content: flex-start;
+ padding-left: 2px;
+ color: rgb(255 255 255 / 62%);
+}
+
+.color-reaction-matrix__swatch {
+ flex: 0 0 auto;
+ width: 12px;
+ height: 12px;
+ border: 1px solid rgb(255 255 255 / 55%);
+ border-radius: 999px;
+ box-shadow: 0 0 0 1px rgb(0 0 0 / 18%);
+}
+
+.color-reaction-matrix__cell {
+ display: block;
+ min-width: 0;
+}
+
+.color-reaction-matrix__cell > select {
+ width: 100%;
+ min-width: 0;
+ height: 28px;
+ border: 1px solid rgb(255 255 255 / 16%);
+ border-radius: 4px;
+ padding: 0 4px;
+ appearance: auto;
+ background: rgb(255 255 255 / 8%);
+ color: white;
+ font: inherit;
+ font-size: 11px;
+}
+
+.color-reaction-matrix__cell > select:focus-visible {
+ outline: 2px solid rgb(255 255 255 / 72%);
+ outline-offset: 1px;
+}
+
+.color-reaction-matrix__cell > select > option {
+ background: rgb(28 31 38);
+ color: white;
+}
diff --git a/src/style/_control-dock.scss b/src/style/_control-dock.scss
index 56213d9..6949610 100644
--- a/src/style/_control-dock.scss
+++ b/src/style/_control-dock.scss
@@ -4,12 +4,14 @@ html > body > aside.control-dock {
bottom: env(safe-area-inset-bottom);
z-index: 4;
width: min(calc(100vw - 1rem), 980px);
- transform: translateX(-50%);
+ transform: translate(-50%, 0);
+ translate: 0 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,
@@ -32,4 +34,32 @@ 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;
+ }
+ }
}
diff --git a/src/style/_loading.scss b/src/style/_loading.scss
new file mode 100644
index 0000000..ff97098
--- /dev/null
+++ b/src/style/_loading.scss
@@ -0,0 +1,120 @@
+.loading-indicator {
+ --loading-progress: 0%;
+
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ display: flex;
+ flex-direction: column;
+ gap: 18px;
+ align-items: center;
+ justify-content: center;
+ z-index: 3;
+ width: min(78vw, 320px);
+ transform: translate(-50%, -50%);
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity var(--transition-time-long);
+
+ > .loading-dots {
+ display: flex;
+ gap: 14px;
+ align-items: center;
+ justify-content: center;
+
+ > .loading-dot {
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: rgb(255 255 255 / 92%);
+ box-shadow:
+ 0 0 18px rgb(255 255 255 / 38%),
+ 0 0 4px rgb(255 255 255 / 60%);
+ transform: scale(0.5);
+ opacity: 0.4;
+ animation: loading-bloom 1.4s ease-in-out infinite;
+
+ &:nth-child(2) {
+ animation-delay: 0.18s;
+ }
+
+ &:nth-child(3) {
+ animation-delay: 0.36s;
+ }
+ }
+ }
+
+ > .loading-status {
+ color: rgb(255 255 255 / 88%);
+ font:
+ 600 16px/1.25 'Open Sans',
+ sans-serif;
+ text-align: center;
+ text-shadow: 0 1px 12px rgb(0 0 0 / 60%);
+ letter-spacing: 0.01em;
+ min-height: 1.25em;
+ }
+
+ > .loading-progress {
+ position: relative;
+ width: 100%;
+ height: 3px;
+ overflow: hidden;
+ border-radius: 999px;
+ background: rgb(255 255 255 / 14%);
+ box-shadow: 0 1px 6px rgb(0 0 0 / 28%);
+
+ > .loading-progress-fill {
+ position: absolute;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ width: var(--loading-progress);
+ border-radius: inherit;
+ 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;
+ }
+ }
+}
+
+html > body.is-loading {
+ .loading-indicator {
+ opacity: 1;
+ }
+
+ .eraser-preview {
+ display: none;
+ }
+
+ aside.control-dock {
+ opacity: 0;
+ visibility: hidden;
+ translate: 0 36px;
+ }
+}
+
+@keyframes loading-bloom {
+ 0%,
+ 100% {
+ transform: scale(0.5);
+ opacity: 0.35;
+ }
+
+ 50% {
+ transform: scale(1);
+ opacity: 1;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .loading-indicator > .loading-dots > .loading-dot {
+ animation: none;
+ transform: scale(0.85);
+ opacity: 0.85;
+ }
+}
diff --git a/src/style/_panels.scss b/src/style/_panels.scss
index 401dfe7..5b3fe9a 100644
--- a/src/style/_panels.scss
+++ b/src/style/_panels.scss
@@ -38,6 +38,42 @@ html > body > aside.control-dock > .pages {
outline-offset: 3px;
}
+ &.info-page {
+ background:
+ linear-gradient(180deg, rgb(255 255 255 / 97%), rgb(243 247 239 / 96%)),
+ rgb(255 255 255);
+ border-color: rgb(255 255 255 / 78%);
+ color: rgb(24 30 27);
+ box-shadow:
+ 0 20px 54px rgb(0 0 0 / 38%),
+ 0 2px 12px rgb(0 0 0 / 22%);
+
+ > section {
+ gap: 0.85rem;
+
+ h1 {
+ margin-bottom: 0;
+ color: rgb(16 24 20);
+ }
+
+ p {
+ max-width: 54ch;
+ margin-bottom: 0;
+ color: rgb(42 48 45);
+ }
+
+ a {
+ color: rgb(0 84 120);
+ font-weight: 700;
+
+ &:focus-visible {
+ outline: 2px solid currentColor;
+ outline-offset: 3px;
+ }
+ }
+ }
+ }
+
&.hidden {
max-height: 0;
margin-bottom: 0;
diff --git a/src/utils/browser-storage.ts b/src/utils/browser-storage.ts
new file mode 100644
index 0000000..b02db6c
--- /dev/null
+++ b/src/utils/browser-storage.ts
@@ -0,0 +1,17 @@
+export const readBrowserStorage = (key: string): string | null => {
+ try {
+ return typeof localStorage === 'undefined' ? null : localStorage.getItem(key);
+ } catch {
+ return null;
+ }
+};
+
+export const writeBrowserStorage = (key: string, value: string): void => {
+ try {
+ if (typeof localStorage !== 'undefined') {
+ localStorage.setItem(key, value);
+ }
+ } catch {
+ // Storage can be unavailable in private browsing or embedded contexts.
+ }
+};
diff --git a/src/utils/dom.ts b/src/utils/dom.ts
new file mode 100644
index 0000000..7ba4aed
--- /dev/null
+++ b/src/utils/dom.ts
@@ -0,0 +1,63 @@
+import { ErrorCode, RuntimeError } from './error-handler';
+
+type ElementConstructor = abstract new () => T;
+
+export const queryRequiredElement = (
+ selector: string,
+ constructor: ElementConstructor,
+ root: ParentNode = document
+): T => {
+ const element = root.querySelector(selector);
+ if (!(element instanceof constructor)) {
+ throw new RuntimeError(
+ ErrorCode.DOM_ELEMENT_MISSING,
+ `Missing required DOM element: ${selector}`,
+ {
+ details: {
+ expectedType: constructor.name,
+ selector,
+ },
+ }
+ );
+ }
+
+ return element;
+};
+
+export const queryRequiredElements = (
+ selector: string,
+ constructor: ElementConstructor,
+ root: ParentNode = document
+): Array => {
+ const elements = Array.from(root.querySelectorAll(selector));
+ if (elements.length === 0) {
+ throw new RuntimeError(
+ ErrorCode.DOM_ELEMENT_MISSING,
+ `Missing required DOM elements: ${selector}`,
+ {
+ details: {
+ expectedType: constructor.name,
+ selector,
+ },
+ }
+ );
+ }
+
+ return elements.map((element) => {
+ if (!(element instanceof constructor)) {
+ throw new RuntimeError(
+ ErrorCode.DOM_ELEMENT_MISSING,
+ `DOM element has the wrong type: ${selector}`,
+ {
+ details: {
+ actualType: element.constructor.name,
+ expectedType: constructor.name,
+ selector,
+ },
+ }
+ );
+ }
+
+ return element;
+ });
+};
diff --git a/src/utils/error-handler.ts b/src/utils/error-handler.ts
index af24c3e..d969a2e 100644
--- a/src/utils/error-handler.ts
+++ b/src/utils/error-handler.ts
@@ -14,16 +14,17 @@ export enum ErrorCode {
WEBGPU_CONTEXT_CONFIGURATION_FAILED = 'webgpu-context-configuration-failed',
WEBGPU_UNCAPTURED_ERROR = 'webgpu-uncaptured-error',
WEBGPU_DEVICE_LOST = 'webgpu-device-lost',
+ DOM_ELEMENT_MISSING = 'dom-element-missing',
}
type ErrorMetadataPrimitive = string | number | boolean | null;
-export type ErrorMetadataValue =
+type ErrorMetadataValue =
| ErrorMetadataPrimitive
| Array
| { [key: string]: ErrorMetadataValue };
-export type ErrorMetadata = { [key: string]: ErrorMetadataValue };
+type ErrorMetadata = { [key: string]: ErrorMetadataValue };
-export interface RuntimeErrorOptions {
+interface RuntimeErrorOptions {
cause?: unknown;
details?: Record;
}
@@ -48,19 +49,19 @@ export class RuntimeError extends Error {
}
}
-export interface ErrorHandlerError {
+interface ErrorHandlerError {
severity: Severity;
message: string;
code?: ErrorCode | string;
details?: ErrorMetadata;
}
-export interface ErrorHandlerErrorOptions {
+interface ErrorHandlerErrorOptions {
code?: ErrorCode | string;
details?: Record;
}
-export interface ErrorHandlerExceptionOptions extends ErrorHandlerErrorOptions {
+interface ErrorHandlerExceptionOptions extends ErrorHandlerErrorOptions {
fallbackMessage?: string;
severity?: Severity;
}
diff --git a/src/utils/format-number.test.ts b/src/utils/format-number.test.ts
deleted file mode 100644
index c434967..0000000
--- a/src/utils/format-number.test.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import { describe, expect, it } from 'vitest';
-
-import { formatNumber } from './format-number';
-
-describe('formatNumber', () => {
- it('renders integers without decimals', () => {
- expect(formatNumber(42)).toBe('42 ');
- });
- it('renders fractional values with two decimals', () => {
- expect(formatNumber(3.14159)).toBe('3.14 ');
- });
- it('renders thousands compactly', () => {
- expect(formatNumber(2500)).toBe('2.5 thousand ');
- });
- it('renders millions compactly', () => {
- expect(formatNumber(1_500_000)).toBe('1.5 million ');
- });
- it('appends the unit when provided', () => {
- expect(formatNumber(5, 'agents')).toBe('5 agents');
- expect(formatNumber(2_000_000, 'agents')).toBe('2.0 million agents');
- });
-});
diff --git a/src/utils/format-number.ts b/src/utils/format-number.ts
deleted file mode 100644
index a57812e..0000000
--- a/src/utils/format-number.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-export const formatNumber = (value: number, unit = ''): string => {
- if (value >= 1e6) {
- return `${(value / 1e6).toFixed(1)} million ${unit}`;
- }
-
- if (value >= 1e3) {
- return `${(value / 1e3).toFixed(1)} thousand ${unit}`;
- }
-
- return `${value === Math.floor(value) ? value : value.toFixed(2)} ${unit}`;
-};
diff --git a/src/utils/graphics/cached-buffer-write.ts b/src/utils/graphics/cached-buffer-write.ts
index 6216548..bab79a7 100644
--- a/src/utils/graphics/cached-buffer-write.ts
+++ b/src/utils/graphics/cached-buffer-write.ts
@@ -1,4 +1,4 @@
-export interface CachedFloat32BufferWrite {
+interface CachedFloat32BufferWrite {
hasValue: boolean;
previous: Float32Array;
}
diff --git a/src/vibes.test.ts b/src/vibes.test.ts
index 4d3d2a4..dd8dd9f 100644
--- a/src/vibes.test.ts
+++ b/src/vibes.test.ts
@@ -4,6 +4,17 @@ import { gardenAudioConfig } from './audio/garden-audio-config';
import { getInitialVibe, hexToRgb, VIBE_PRESETS } from './vibes';
const originalLocalStorage = globalThis.localStorage;
+const colorInteractionKeys = [
+ 'color1ToColor1',
+ 'color1ToColor2',
+ 'color1ToColor3',
+ 'color2ToColor1',
+ 'color2ToColor2',
+ 'color2ToColor3',
+ 'color3ToColor1',
+ 'color3ToColor2',
+ 'color3ToColor3',
+] as const;
const setBrowserVibeState = ({
storedVibeId = null,
@@ -85,4 +96,15 @@ describe('vibe and audio config contract', () => {
expect(Math.abs(voice.panOffset)).toBeLessThanOrEqual(1);
});
});
+
+ it('uses discrete color interaction matrices for every vibe', () => {
+ VIBE_PRESETS.forEach((vibe) => {
+ colorInteractionKeys.forEach((key) => {
+ expect([-1, 0, 1]).toContain(vibe.settings[key]);
+ });
+ expect(vibe.settings.color1ToColor1).toBe(1);
+ expect(vibe.settings.color2ToColor2).toBe(1);
+ expect(vibe.settings.color3ToColor3).toBe(1);
+ });
+ });
});
diff --git a/src/vibes.ts b/src/vibes.ts
index 2c035cc..085f10d 100644
--- a/src/vibes.ts
+++ b/src/vibes.ts
@@ -1,4 +1,5 @@
import { appConfig, type VibePreset } from './config';
+import { readBrowserStorage } from './utils/browser-storage';
export type { GardenVibeSettings, VibePreset } from './config';
@@ -14,7 +15,7 @@ export const hexToRgb = (hex: string): [number, number, number] => {
};
export const getInitialVibe = (): VibePreset => {
- const id = localStorage.getItem(appConfig.storage.vibeKey);
+ const id = readBrowserStorage(appConfig.storage.vibeKey);
return (
VIBE_PRESETS.find((vibe) => vibe.id === id) ??
VIBE_PRESETS.find((vibe) => vibe.id === appConfig.vibes.defaultVibeId) ??
diff --git a/tsconfig.playwright.json b/tsconfig.playwright.json
new file mode 100644
index 0000000..139efca
--- /dev/null
+++ b/tsconfig.playwright.json
@@ -0,0 +1,7 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "types": ["node", "@playwright/test"]
+ },
+ "include": ["playwright.config.ts", "e2e/**/*.ts"]
+}