clean up
This commit is contained in:
parent
d6a8f898d1
commit
15e99380b5
84 changed files with 1659 additions and 4726 deletions
|
|
@ -57,4 +57,4 @@ jobs:
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
run: |
|
run: |
|
||||||
apt update && apt install -y rsync
|
apt update && apt install -y rsync
|
||||||
rsync -a --delete dist/ /pages/fleeting-garden
|
rsync -a --delete dist/ /pages/fleeting
|
||||||
|
|
|
||||||
45
.gitignore
vendored
45
.gitignore
vendored
|
|
@ -1,47 +1,2 @@
|
||||||
# Dependency directory
|
|
||||||
node_modules
|
node_modules
|
||||||
modules/
|
|
||||||
ts-node--*/
|
|
||||||
rss.xml
|
|
||||||
|
|
||||||
dist
|
dist
|
||||||
playwright-report
|
|
||||||
test-results
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
|
|
||||||
# Runtime data
|
|
||||||
pids
|
|
||||||
*.pid
|
|
||||||
*.seed
|
|
||||||
*.ssh
|
|
||||||
*.ppk
|
|
||||||
v8-compile-cache-0/
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# node-waf configuration
|
|
||||||
.lock-wscript
|
|
||||||
|
|
||||||
# Compiled binary addons (http://nodejs.org/api/addons.html)
|
|
||||||
build/Release
|
|
||||||
bin
|
|
||||||
ts-node
|
|
||||||
|
|
||||||
# Personal Scripts
|
|
||||||
*.bat
|
|
||||||
*.ssh
|
|
||||||
*.sh
|
|
||||||
!system.min.js
|
|
||||||
|
|
||||||
# Editors
|
|
||||||
.vscode
|
|
||||||
.markdownlint.json
|
|
||||||
|
|
||||||
# Build Files
|
|
||||||
temp
|
|
||||||
*.js
|
|
||||||
*.map
|
|
||||||
!webpack.*
|
|
||||||
|
|
|
||||||
2
definitions.d.ts
vendored
2
definitions.d.ts
vendored
|
|
@ -6,5 +6,3 @@ declare module '*.wgsl?raw' {
|
||||||
interface HTMLCanvasElement {
|
interface HTMLCanvasElement {
|
||||||
getContext(contextId: 'webgpu'): GPUCanvasContext | null;
|
getContext(contextId: 'webgpu'): GPUCanvasContext | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare var webkitOfflineAudioContext: typeof OfflineAudioContext | undefined;
|
|
||||||
|
|
|
||||||
|
|
@ -133,3 +133,37 @@ test('shows a clear fallback when WebGPU is unavailable', async ({ page }) => {
|
||||||
await expect(fallback).toContainText('webgpu-unsupported');
|
await expect(fallback).toContainText('webgpu-unsupported');
|
||||||
expect(browserFailures).toEqual([]);
|
expect(browserFailures).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('keeps audio focus outlines scoped to the active control', async ({ page }) => {
|
||||||
|
await disableWebGpu(page);
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
|
||||||
|
|
||||||
|
const audioControl = page.locator('.audio-control');
|
||||||
|
const soundButton = page.locator('button.sound');
|
||||||
|
const volumeSlider = page.locator('.volume-slider');
|
||||||
|
|
||||||
|
await soundButton.click();
|
||||||
|
await expect(audioControl).toHaveCSS('outline-style', 'none');
|
||||||
|
await expect(soundButton).toHaveCSS('outline-style', 'none');
|
||||||
|
|
||||||
|
await page.mouse.click(10, 10);
|
||||||
|
for (let tabIndex = 0; tabIndex < 12; tabIndex += 1) {
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
const activeClass = await page.evaluate(() =>
|
||||||
|
String(document.activeElement?.className ?? '')
|
||||||
|
);
|
||||||
|
if (activeClass.includes('sound')) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(soundButton).toBeFocused();
|
||||||
|
await expect(soundButton).toHaveCSS('outline-style', 'solid');
|
||||||
|
await expect(soundButton).toHaveCSS('outline-offset', '-4px');
|
||||||
|
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await expect(volumeSlider).toBeFocused();
|
||||||
|
await expect(volumeSlider).toHaveCSS('outline-style', 'solid');
|
||||||
|
await expect(volumeSlider).toHaveCSS('outline-offset', '-4px');
|
||||||
|
});
|
||||||
|
|
|
||||||
14
index.html
14
index.html
|
|
@ -47,11 +47,14 @@
|
||||||
<div class="garden-prompt" aria-live="polite"></div>
|
<div class="garden-prompt" aria-live="polite"></div>
|
||||||
|
|
||||||
<div class="loading-indicator" role="status">
|
<div class="loading-indicator" role="status">
|
||||||
<div class="loading-dots" aria-hidden="true">
|
<div class="splash">
|
||||||
<span class="loading-dot"></span>
|
<h1 class="splash-title">Fleeting Garden</h1>
|
||||||
<span class="loading-dot"></span>
|
<p class="splash-description">
|
||||||
<span class="loading-dot"></span>
|
Draw coloured paths and watch them bloom into a living WebGPU garden.
|
||||||
|
</p>
|
||||||
|
<button class="start-button" type="button" disabled>Start</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="loading-bar" hidden>
|
||||||
<div class="loading-status">Starting up…</div>
|
<div class="loading-status">Starting up…</div>
|
||||||
<div
|
<div
|
||||||
class="loading-progress"
|
class="loading-progress"
|
||||||
|
|
@ -62,6 +65,7 @@
|
||||||
aria-valuenow="0"
|
aria-valuenow="0"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section class="errors-container">
|
<section class="errors-container">
|
||||||
<noscript>JavaScript is required for this website.</noscript>
|
<noscript>JavaScript is required for this website.</noscript>
|
||||||
|
|
@ -141,6 +145,7 @@
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<nav class="buttons" aria-label="App controls">
|
<nav class="buttons" aria-label="App controls">
|
||||||
<button
|
<button
|
||||||
|
|
@ -190,7 +195,6 @@
|
||||||
title="Restart simulation"
|
title="Restart simulation"
|
||||||
></button>
|
></button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
|
||||||
|
|
||||||
<button class="next-vibe vibe-button" aria-label="Next vibe" title="Next vibe">
|
<button class="next-vibe vibe-button" aria-label="Next vibe" title="Next vibe">
|
||||||
›
|
›
|
||||||
|
|
|
||||||
33
package-lock.json
generated
33
package-lock.json
generated
|
|
@ -22,6 +22,7 @@
|
||||||
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
||||||
"@webgpu/types": "^0.1.69",
|
"@webgpu/types": "^0.1.69",
|
||||||
"browserslist": "^4.28.2",
|
"browserslist": "^4.28.2",
|
||||||
|
"browserslist-to-esbuild": "^2.1.1",
|
||||||
"eslint": "^10.3.0",
|
"eslint": "^10.3.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"gl-matrix": "^3.4.4",
|
"gl-matrix": "^3.4.4",
|
||||||
|
|
@ -2832,6 +2833,25 @@
|
||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/browserslist-to-esbuild": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/browserslist-to-esbuild/-/browserslist-to-esbuild-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-KN+mty6C3e9AN8Z5dI1xeN15ExcRNeISoC3g7V0Kax/MMF9MSoYA2G7lkTTcVUFntiEjkpI0HNgqJC1NjdyNUw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"meow": "^13.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"browserslist-to-esbuild": "cli/index.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"browserslist": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cac": {
|
"node_modules/cac": {
|
||||||
"version": "6.7.14",
|
"version": "6.7.14",
|
||||||
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
||||||
|
|
@ -3954,6 +3974,19 @@
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/meow": {
|
||||||
|
"version": "13.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz",
|
||||||
|
"integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/micromatch": {
|
"node_modules/micromatch": {
|
||||||
"version": "4.0.8",
|
"version": "4.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@
|
||||||
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
||||||
"@webgpu/types": "^0.1.69",
|
"@webgpu/types": "^0.1.69",
|
||||||
"browserslist": "^4.28.2",
|
"browserslist": "^4.28.2",
|
||||||
|
"browserslist-to-esbuild": "^2.1.1",
|
||||||
"eslint": "^10.3.0",
|
"eslint": "^10.3.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"gl-matrix": "^3.4.4",
|
"gl-matrix": "^3.4.4",
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,10 @@ export const trackVibeChange = ({
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const trackStart = () => {
|
||||||
|
track('Start');
|
||||||
|
};
|
||||||
|
|
||||||
export const trackExport = ({ vibeId }: { vibeId: VibeId }) => {
|
export const trackExport = ({ vibeId }: { vibeId: VibeId }) => {
|
||||||
track('Export', {
|
track('Export', {
|
||||||
props: {
|
props: {
|
||||||
|
|
|
||||||
|
|
@ -8,201 +8,6 @@ export interface GardenAudioChord {
|
||||||
quality: GardenAudioChordQuality;
|
quality: GardenAudioChordQuality;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GardenAudioStyleVoice {
|
|
||||||
scaleDegreeOffset: number;
|
|
||||||
velocityMultiplier: number;
|
|
||||||
panOffset: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GardenAudioRegister {
|
|
||||||
midiMin: number;
|
|
||||||
midiMax: number;
|
|
||||||
preferredMidi: number;
|
|
||||||
pan: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GardenAudioStylePool extends GardenAudioRegister {
|
|
||||||
scaleDegrees: Array<number>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GardenAudioGenerativePianoConfig {
|
|
||||||
stylePools: [GardenAudioStylePool, GardenAudioStylePool, GardenAudioStylePool];
|
|
||||||
padRegisters: [GardenAudioRegister, GardenAudioRegister, GardenAudioRegister];
|
|
||||||
chordVoicings: {
|
|
||||||
majorOpen: Array<number>;
|
|
||||||
minorOpen: Array<number>;
|
|
||||||
majorClosed: Array<number>;
|
|
||||||
minorClosed: Array<number>;
|
|
||||||
};
|
|
||||||
vibeChangeStinger: {
|
|
||||||
velocities: [number, number, number];
|
|
||||||
pans: [number, number, number];
|
|
||||||
delaySends: [number, number, number];
|
|
||||||
lowpassExpression: number;
|
|
||||||
};
|
|
||||||
highActivityExtra: {
|
|
||||||
barOffset: number;
|
|
||||||
expressionMultiplier: number;
|
|
||||||
};
|
|
||||||
padChord: {
|
|
||||||
velocities: [number, number, number];
|
|
||||||
expressionVelocityWeight: number;
|
|
||||||
delaySend: number;
|
|
||||||
lowpassExpressionWeight: number;
|
|
||||||
};
|
|
||||||
supportNote: {
|
|
||||||
velocityBase: number;
|
|
||||||
velocityExpressionWeight: number;
|
|
||||||
durationBaseSeconds: number;
|
|
||||||
durationExpressionSeconds: number;
|
|
||||||
delaySendBase: number;
|
|
||||||
delaySendExpressionWeight: number;
|
|
||||||
lowpassExpressionWeight: number;
|
|
||||||
expressionThreshold: number;
|
|
||||||
offsetsByStyle: [Array<number>, Array<number>, Array<number>];
|
|
||||||
};
|
|
||||||
textureNote: {
|
|
||||||
velocityBase: number;
|
|
||||||
velocityExpressionWeight: number;
|
|
||||||
durationBaseSeconds: number;
|
|
||||||
durationExpressionSeconds: number;
|
|
||||||
delaySendBase: number;
|
|
||||||
delaySendExpressionWeight: number;
|
|
||||||
idleExpressionThreshold: number;
|
|
||||||
mediumExpressionThreshold: number;
|
|
||||||
intenseSpacing: number;
|
|
||||||
idlePhase: number;
|
|
||||||
};
|
|
||||||
gestureAccent: {
|
|
||||||
rotationStrengthMultiplier: number;
|
|
||||||
quantizeStepLookahead: number;
|
|
||||||
velocityBase: number;
|
|
||||||
velocityStrengthWeight: number;
|
|
||||||
durationBaseSeconds: number;
|
|
||||||
durationStrengthSeconds: number;
|
|
||||||
delaySend: number;
|
|
||||||
};
|
|
||||||
touchNote: {
|
|
||||||
registerBiasManiaAmount: number;
|
|
||||||
velocityBase: number;
|
|
||||||
velocityStrengthWeight: number;
|
|
||||||
durationBaseSeconds: number;
|
|
||||||
durationStrengthSeconds: number;
|
|
||||||
delaySend: number;
|
|
||||||
lowpassBaseExpression: number;
|
|
||||||
lowpassStrengthWeight: number;
|
|
||||||
};
|
|
||||||
brushPhrase: {
|
|
||||||
initialMotifOffset: number;
|
|
||||||
energyDecaySeconds: number;
|
|
||||||
maniaDecaySeconds: number;
|
|
||||||
fadeMinimumLifetimeSeconds: number;
|
|
||||||
layerIntensityBase: number;
|
|
||||||
layerIntensityManiaWeight: number;
|
|
||||||
frameActivityWeight: number;
|
|
||||||
frameManiaWeight: number;
|
|
||||||
};
|
|
||||||
brushStream: {
|
|
||||||
inferredManiaThreshold: number;
|
|
||||||
inferredManiaRange: number;
|
|
||||||
registerManiaShift: number;
|
|
||||||
chordToneEverySteps: number;
|
|
||||||
durationBaseSeconds: number;
|
|
||||||
durationIntensitySeconds: number;
|
|
||||||
durationManiaSeconds: number;
|
|
||||||
durationMinSeconds: number;
|
|
||||||
durationMaxSeconds: number;
|
|
||||||
delaySendBase: number;
|
|
||||||
delaySendIntensityWeight: number;
|
|
||||||
delaySendManiaWeight: number;
|
|
||||||
delaySendMin: number;
|
|
||||||
delaySendMax: number;
|
|
||||||
velocityBase: number;
|
|
||||||
velocityIntensityWeight: number;
|
|
||||||
lowpassBaseExpression: number;
|
|
||||||
lowpassIntensityWeight: number;
|
|
||||||
lowpassManiaWeight: number;
|
|
||||||
manicThreshold: number;
|
|
||||||
intenseThreshold: number;
|
|
||||||
activeThreshold: number;
|
|
||||||
};
|
|
||||||
brushStreamEcho: {
|
|
||||||
maniaThreshold: number;
|
|
||||||
stepModulo: number;
|
|
||||||
stepRemainder: number;
|
|
||||||
intensityThreshold: number;
|
|
||||||
octaveSemitones: number;
|
|
||||||
maxMidi: number;
|
|
||||||
velocityBase: number;
|
|
||||||
velocityIntensityWeight: number;
|
|
||||||
durationMinSeconds: number;
|
|
||||||
durationScale: number;
|
|
||||||
panScale: number;
|
|
||||||
delaySendMin: number;
|
|
||||||
delaySendScale: number;
|
|
||||||
lowpassBaseExpression: number;
|
|
||||||
lowpassManiaWeight: number;
|
|
||||||
};
|
|
||||||
brushMotif: {
|
|
||||||
highThreshold: number;
|
|
||||||
mediumThreshold: number;
|
|
||||||
highOffset: number;
|
|
||||||
mediumOffset: number;
|
|
||||||
lowOffset: number;
|
|
||||||
minOffset: number;
|
|
||||||
maxOffset: number;
|
|
||||||
};
|
|
||||||
registerBias: {
|
|
||||||
maniaShiftSemitones: number;
|
|
||||||
midiMin: number;
|
|
||||||
midiMaxForMin: number;
|
|
||||||
minimumSpan: number;
|
|
||||||
midiMax: number;
|
|
||||||
};
|
|
||||||
candidateOctaveSearch: {
|
|
||||||
min: number;
|
|
||||||
max: number;
|
|
||||||
};
|
|
||||||
stylePanOffsetScale: number;
|
|
||||||
lowpass: {
|
|
||||||
midiBase: number;
|
|
||||||
midiRange: number;
|
|
||||||
midiLiftHz: number;
|
|
||||||
expressionBase: number;
|
|
||||||
expressionWeight: number;
|
|
||||||
};
|
|
||||||
styleRotationBars: number;
|
|
||||||
chordBars: number;
|
|
||||||
supportBarSpacing: number;
|
|
||||||
supportBarOffset: number;
|
|
||||||
idleTextureBarSpacing: number;
|
|
||||||
mediumTextureBarSpacing: number;
|
|
||||||
textureBeat: number;
|
|
||||||
highActivityExtraBeat: number;
|
|
||||||
highActivityExtraThreshold: number;
|
|
||||||
noteScorePreferenceWeight: number;
|
|
||||||
noteScoreRegisterWeight: number;
|
|
||||||
noteScoreChordToneWeight: number;
|
|
||||||
noteScoreRepeatPenalty: number;
|
|
||||||
gestureAccentMinIntervalSeconds: number;
|
|
||||||
strokeAccentMinSteps: number;
|
|
||||||
strokeAccentThreshold: number;
|
|
||||||
stingerDurationSeconds: number;
|
|
||||||
stingerSpacingSeconds: number;
|
|
||||||
maxBrushPhraseLayers: number;
|
|
||||||
maxBrushStreamNotesPerBar: number;
|
|
||||||
brushLayerBaseSeconds: number;
|
|
||||||
brushLayerEnergySeconds: number;
|
|
||||||
brushLayerMinIntensity: number;
|
|
||||||
brushStreamIdleIntervalBeats: number;
|
|
||||||
brushStreamActiveIntervalBeats: number;
|
|
||||||
brushStreamIntenseIntervalBeats: number;
|
|
||||||
brushStreamManicIntervalBeats: number;
|
|
||||||
brushMotifMaxSteps: number;
|
|
||||||
brushMotifCanonDelaySeconds: number;
|
|
||||||
padDurationBarScale: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GardenAudioVibeProfile {
|
export interface GardenAudioVibeProfile {
|
||||||
rootMidi: number;
|
rootMidi: number;
|
||||||
scale: Array<number>;
|
scale: Array<number>;
|
||||||
|
|
@ -215,7 +20,6 @@ export interface GardenAudioConfig {
|
||||||
masterVolume: number;
|
masterVolume: number;
|
||||||
fadeInSeconds: number;
|
fadeInSeconds: number;
|
||||||
updateRampSeconds: number;
|
updateRampSeconds: number;
|
||||||
highPassFrequencyHz: number;
|
|
||||||
delay: {
|
delay: {
|
||||||
timeSeconds: number;
|
timeSeconds: number;
|
||||||
feedback: number;
|
feedback: number;
|
||||||
|
|
@ -228,44 +32,24 @@ export interface GardenAudioConfig {
|
||||||
outputBase: number;
|
outputBase: number;
|
||||||
outputActivityDuck: number;
|
outputActivityDuck: number;
|
||||||
timeRampSeconds: number;
|
timeRampSeconds: number;
|
||||||
feedbackHighPassHz: number;
|
|
||||||
feedbackLowPassHz: number;
|
|
||||||
returnLowPassHz: number;
|
|
||||||
};
|
};
|
||||||
piano: {
|
piano: {
|
||||||
maxVoices: number;
|
maxVoices: number;
|
||||||
filterType: BiquadFilterType;
|
|
||||||
gain: number;
|
gain: number;
|
||||||
sustainSeconds: number;
|
sustainSeconds: number;
|
||||||
sustainLevel: number;
|
sustainLevel: number;
|
||||||
releaseSeconds: number;
|
releaseSeconds: number;
|
||||||
lowpassHz: number;
|
lowpassHz: number;
|
||||||
filterQ: number;
|
|
||||||
gainAttackSeconds: number;
|
gainAttackSeconds: number;
|
||||||
lowpassMaxHz: number;
|
lowpassMaxHz: number;
|
||||||
lowpassMinHz: number;
|
lowpassMinHz: number;
|
||||||
minDurationSeconds: number;
|
|
||||||
minFadeSeconds: number;
|
|
||||||
minGain: number;
|
|
||||||
pitchSemitonesPerOctave: number;
|
|
||||||
scheduleAheadSeconds: number;
|
|
||||||
sustainBase: number;
|
sustainBase: number;
|
||||||
sustainVelocityRange: number;
|
sustainVelocityRange: number;
|
||||||
tailStopExtraSeconds: number;
|
|
||||||
voiceStealFadeSeconds: number;
|
|
||||||
voiceStealStopSeconds: number;
|
|
||||||
sampleBaseUrl: string;
|
|
||||||
preloadDecode: {
|
|
||||||
channels: number;
|
|
||||||
frames: number;
|
|
||||||
sampleRateHz: number;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
rhythm: {
|
rhythm: {
|
||||||
bpm: number;
|
bpm: number;
|
||||||
stepsPerBeat: number;
|
stepsPerBeat: number;
|
||||||
stepsPerBar: number;
|
stepsPerBar: number;
|
||||||
lookaheadSeconds: number;
|
|
||||||
sparseActivity: number;
|
sparseActivity: number;
|
||||||
};
|
};
|
||||||
eraser: {
|
eraser: {
|
||||||
|
|
@ -285,32 +69,11 @@ export interface GardenAudioConfig {
|
||||||
strokeDecaySeconds: number;
|
strokeDecaySeconds: number;
|
||||||
};
|
};
|
||||||
graph: {
|
graph: {
|
||||||
closeGain: number;
|
|
||||||
closeRampSeconds: number;
|
|
||||||
delayMaxSeconds: number;
|
|
||||||
eventBusGain: number;
|
|
||||||
noiseMax: number;
|
|
||||||
noiseMin: number;
|
|
||||||
unlockTickFrequencyHz: number;
|
|
||||||
unlockTickSeconds: number;
|
|
||||||
unlockTickType: OscillatorType;
|
|
||||||
latencyHint: AudioContextLatencyCategory;
|
|
||||||
outputFilterType: BiquadFilterType;
|
|
||||||
noiseBufferChannels: number;
|
|
||||||
noiseBufferDurationSeconds: number;
|
|
||||||
pianoBusGains: Record<PianoNoteRole, number>;
|
pianoBusGains: Record<PianoNoteRole, number>;
|
||||||
pianoBusActivityDucking: Record<PianoNoteRole, number>;
|
pianoBusActivityDucking: Record<PianoNoteRole, number>;
|
||||||
noiseBusGain: number;
|
noiseBusGain: number;
|
||||||
compressor: {
|
|
||||||
thresholdDb: number;
|
|
||||||
kneeDb: number;
|
|
||||||
ratio: number;
|
|
||||||
attackSeconds: number;
|
|
||||||
releaseSeconds: number;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
input: {
|
input: {
|
||||||
fallbackFrameSeconds: number;
|
|
||||||
fullActivitySpeed: number;
|
fullActivitySpeed: number;
|
||||||
activityNoiseFloorSpeed: number;
|
activityNoiseFloorSpeed: number;
|
||||||
activityCurve: number;
|
activityCurve: number;
|
||||||
|
|
@ -321,22 +84,7 @@ export interface GardenAudioConfig {
|
||||||
manicActivityThreshold: number;
|
manicActivityThreshold: number;
|
||||||
manicReleaseThreshold: number;
|
manicReleaseThreshold: number;
|
||||||
maniaSmoothingSeconds: number;
|
maniaSmoothingSeconds: number;
|
||||||
minElapsedSeconds: number;
|
|
||||||
};
|
};
|
||||||
muteGain: number;
|
|
||||||
muteRampSeconds: number;
|
|
||||||
noiseBurst: {
|
|
||||||
attackSeconds: number;
|
|
||||||
filterQ: number;
|
|
||||||
offsetRandomSeconds: number;
|
|
||||||
scheduleAheadSeconds: number;
|
|
||||||
silentGain: number;
|
|
||||||
filterType: BiquadFilterType;
|
|
||||||
};
|
|
||||||
startDelaySeconds: number;
|
|
||||||
vibeChangeStingerMinIntervalSeconds: number;
|
|
||||||
generativePiano: GardenAudioGenerativePianoConfig;
|
|
||||||
styleVoices: [GardenAudioStyleVoice, GardenAudioStyleVoice, GardenAudioStyleVoice];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const gardenAudioConfig: GardenAudioConfig = appConfig.audio;
|
export const gardenAudioConfig: GardenAudioConfig = appConfig.audio;
|
||||||
|
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import { gardenAudioConfig } from './garden-audio-config';
|
|
||||||
import { GardenAudioEnergy } from './garden-audio-energy';
|
|
||||||
|
|
||||||
describe('GardenAudioEnergy', () => {
|
|
||||||
it('suspends activity but keeps a fading level when the gesture ends', () => {
|
|
||||||
const energy = new GardenAudioEnergy(gardenAudioConfig);
|
|
||||||
|
|
||||||
energy.beginGesture(0);
|
|
||||||
energy.recordStroke(0.8, 0.1);
|
|
||||||
energy.update(0.1);
|
|
||||||
energy.update(0.2);
|
|
||||||
|
|
||||||
const levelBeforeLift = energy.getLevel();
|
|
||||||
expect(energy.getActivity()).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
energy.endGesture();
|
|
||||||
|
|
||||||
expect(energy.getActivity()).toBe(0);
|
|
||||||
expect(energy.getLevel()).toBe(levelBeforeLift);
|
|
||||||
energy.update(0.3);
|
|
||||||
expect(energy.getLevel()).toBeLessThan(levelBeforeLift);
|
|
||||||
expect(energy.getLevel()).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses recent stroke intensity rather than gesture duration alone', () => {
|
|
||||||
const energy = new GardenAudioEnergy(gardenAudioConfig);
|
|
||||||
|
|
||||||
energy.beginGesture(0);
|
|
||||||
energy.recordStroke(1, 0.1);
|
|
||||||
energy.update(0.1);
|
|
||||||
energy.update(0.2);
|
|
||||||
const activeLevel = energy.getActivity();
|
|
||||||
|
|
||||||
energy.update(1.2);
|
|
||||||
|
|
||||||
expect(energy.getActivity()).toBeLessThan(activeLevel);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('raises activity immediately when a stroke is recorded', () => {
|
|
||||||
const energy = new GardenAudioEnergy(gardenAudioConfig);
|
|
||||||
|
|
||||||
energy.beginGesture(0);
|
|
||||||
energy.recordStroke(0.12, 0.05);
|
|
||||||
|
|
||||||
expect(energy.getActivity()).toBeGreaterThan(0.09);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { clamp01 } from '../utils/clamp';
|
import { approach, clamp01 } from '../utils/math';
|
||||||
import type { GardenAudioConfig } from './garden-audio-config';
|
import type { GardenAudioConfig } from './garden-audio-config';
|
||||||
|
|
||||||
export class GardenAudioEnergy {
|
export class GardenAudioEnergy {
|
||||||
|
|
@ -59,8 +59,7 @@ export class GardenAudioEnergy {
|
||||||
} else if (target > this.energy) {
|
} else if (target > this.energy) {
|
||||||
timeConstant = this.config.energy.attackSeconds;
|
timeConstant = this.config.energy.attackSeconds;
|
||||||
}
|
}
|
||||||
const amount = 1 - Math.exp(-elapsedSeconds / timeConstant);
|
this.energy = approach(this.energy, target, elapsedSeconds, timeConstant);
|
||||||
this.energy += (target - this.energy) * amount;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public getActivity(): number {
|
public getActivity(): number {
|
||||||
|
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import { gardenAudioConfig } from './garden-audio-config';
|
|
||||||
import { GardenAudioGestureState } from './garden-audio-gesture-state';
|
|
||||||
import type { GardenAudioStrokeMetrics } from './garden-audio-input';
|
|
||||||
|
|
||||||
const makeMetrics = ({
|
|
||||||
elapsedSeconds,
|
|
||||||
normalizedDistance,
|
|
||||||
}: {
|
|
||||||
elapsedSeconds: number;
|
|
||||||
normalizedDistance: number;
|
|
||||||
}): GardenAudioStrokeMetrics => ({
|
|
||||||
distancePixels: normalizedDistance * 1000,
|
|
||||||
elapsedSeconds,
|
|
||||||
normalizedDistance,
|
|
||||||
normalizedSpeed: normalizedDistance / elapsedSeconds,
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('GardenAudioGestureState', () => {
|
|
||||||
it('ignores tiny jitter below the activity speed floor', () => {
|
|
||||||
const state = new GardenAudioGestureState(gardenAudioConfig.input);
|
|
||||||
|
|
||||||
state.beginGesture();
|
|
||||||
|
|
||||||
expect(
|
|
||||||
state.recordStroke({
|
|
||||||
metrics: makeMetrics({
|
|
||||||
elapsedSeconds: 0.1,
|
|
||||||
normalizedDistance: 0.001,
|
|
||||||
}),
|
|
||||||
}).activity
|
|
||||||
).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('normalizes equal drawing speeds across pointer sample rates', () => {
|
|
||||||
const lowRate = new GardenAudioGestureState(gardenAudioConfig.input);
|
|
||||||
const highRate = new GardenAudioGestureState(gardenAudioConfig.input);
|
|
||||||
|
|
||||||
lowRate.beginGesture();
|
|
||||||
highRate.beginGesture();
|
|
||||||
|
|
||||||
const lowRateFrame = lowRate.recordStroke({
|
|
||||||
metrics: makeMetrics({
|
|
||||||
elapsedSeconds: 0.1,
|
|
||||||
normalizedDistance: 0.07,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
let highRateFrame = { activity: 0, maniaAmount: 0 };
|
|
||||||
for (let index = 0; index < 5; index += 1) {
|
|
||||||
highRateFrame = highRate.recordStroke({
|
|
||||||
metrics: makeMetrics({
|
|
||||||
elapsedSeconds: 0.02,
|
|
||||||
normalizedDistance: 0.014,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(highRateFrame.activity).toBeCloseTo(lowRateFrame.activity, 5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('holds mania with hysteresis before releasing', () => {
|
|
||||||
const state = new GardenAudioGestureState(gardenAudioConfig.input);
|
|
||||||
|
|
||||||
state.beginGesture();
|
|
||||||
|
|
||||||
const manicFrame = state.recordStroke({
|
|
||||||
metrics: makeMetrics({
|
|
||||||
elapsedSeconds: 0.25,
|
|
||||||
normalizedDistance: 0.3,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(manicFrame.maniaAmount).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
const heldFrame = state.recordStroke({
|
|
||||||
metrics: makeMetrics({
|
|
||||||
elapsedSeconds: 0.06,
|
|
||||||
normalizedDistance: 0.04,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(heldFrame.maniaAmount).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
const releasedFrame = state.recordStroke({
|
|
||||||
metrics: makeMetrics({
|
|
||||||
elapsedSeconds: 0.7,
|
|
||||||
normalizedDistance: 0,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(releasedFrame.maniaAmount).toBeLessThan(heldFrame.maniaAmount);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { clamp, clamp01 } from '../utils/clamp';
|
import { approach, clamp, clamp01, smoothstep } from '../utils/math';
|
||||||
import type { GardenAudioConfig } from './garden-audio-config';
|
import type { GardenAudioConfig } from './garden-audio-config';
|
||||||
import type { GardenAudioStrokeMetrics } from './garden-audio-input';
|
import type { GardenAudioStrokeMetrics } from './garden-audio-input';
|
||||||
|
|
||||||
|
|
@ -92,18 +92,3 @@ export class GardenAudioGestureState {
|
||||||
return clamp(activity * distanceAmount, 0, this.inputConfig.activitySoftCeiling);
|
return clamp(activity * distanceAmount, 0, this.inputConfig.activitySoftCeiling);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const approach = (
|
|
||||||
current: number,
|
|
||||||
target: number,
|
|
||||||
elapsedSeconds: number,
|
|
||||||
timeConstantSeconds: number
|
|
||||||
): number => {
|
|
||||||
const amount = 1 - Math.exp(-elapsedSeconds / Math.max(0.001, timeConstantSeconds));
|
|
||||||
return current + (target - current) * amount;
|
|
||||||
};
|
|
||||||
|
|
||||||
const smoothstep = (edge0: number, edge1: number, value: number): number => {
|
|
||||||
const amount = clamp01((value - edge0) / (edge1 - edge0));
|
|
||||||
return amount * amount * (3 - 2 * amount);
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
import { VIBE_PRESETS } from '../vibes';
|
|
||||||
import { gardenAudioConfig } from './garden-audio-config';
|
|
||||||
import { GardenAudioGraph } from './garden-audio-graph';
|
|
||||||
|
|
||||||
class FakeAudioParam {
|
|
||||||
public value = 0;
|
|
||||||
public setTargetAtTime = vi.fn();
|
|
||||||
}
|
|
||||||
|
|
||||||
class FakeAudioNode {
|
|
||||||
public readonly gain = new FakeAudioParam();
|
|
||||||
public readonly frequency = new FakeAudioParam();
|
|
||||||
public readonly threshold = new FakeAudioParam();
|
|
||||||
public readonly knee = new FakeAudioParam();
|
|
||||||
public readonly ratio = new FakeAudioParam();
|
|
||||||
public readonly attack = new FakeAudioParam();
|
|
||||||
public readonly release = new FakeAudioParam();
|
|
||||||
public readonly delayTime = new FakeAudioParam();
|
|
||||||
public readonly connections: Array<unknown> = [];
|
|
||||||
public type = '';
|
|
||||||
|
|
||||||
public connect(target: unknown): unknown {
|
|
||||||
this.connections.push(target);
|
|
||||||
return target;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class FakeAudioBuffer {
|
|
||||||
private readonly data: Float32Array;
|
|
||||||
|
|
||||||
public constructor(length: number) {
|
|
||||||
this.data = new Float32Array(length);
|
|
||||||
}
|
|
||||||
|
|
||||||
public getChannelData(): Float32Array {
|
|
||||||
return this.data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class FakeAudioContext {
|
|
||||||
public readonly currentTime = 1;
|
|
||||||
public readonly destination = new FakeAudioNode() as unknown as AudioDestinationNode;
|
|
||||||
public readonly sampleRate = 16;
|
|
||||||
public readonly state = 'running';
|
|
||||||
public readonly compressors: Array<FakeAudioNode> = [];
|
|
||||||
|
|
||||||
public createGain(): GainNode {
|
|
||||||
return new FakeAudioNode() as unknown as GainNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
public createBiquadFilter(): BiquadFilterNode {
|
|
||||||
return new FakeAudioNode() as unknown as BiquadFilterNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
public createDelay(): DelayNode {
|
|
||||||
return new FakeAudioNode() as unknown as DelayNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
public createDynamicsCompressor(): DynamicsCompressorNode {
|
|
||||||
const node = new FakeAudioNode();
|
|
||||||
this.compressors.push(node);
|
|
||||||
return node as unknown as DynamicsCompressorNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
public createBuffer(_channels: number, length: number): AudioBuffer {
|
|
||||||
return new FakeAudioBuffer(length) as unknown as AudioBuffer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('GardenAudioGraph', () => {
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('builds controlled output, role buses, and delay automation', () => {
|
|
||||||
vi.stubGlobal('AudioContext', FakeAudioContext);
|
|
||||||
const graph = new GardenAudioGraph(gardenAudioConfig);
|
|
||||||
const context = graph.ensureContext(true) as unknown as FakeAudioContext;
|
|
||||||
|
|
||||||
expect(context.compressors).toHaveLength(1);
|
|
||||||
expect(graph.getPianoBus('pad')).not.toBeNull();
|
|
||||||
expect(graph.getPianoBus('pad')).not.toBe(graph.getPianoBus('gesture'));
|
|
||||||
expect(graph.noiseBus).not.toBeNull();
|
|
||||||
|
|
||||||
graph.updateDelay(VIBE_PRESETS[0].audio, 1);
|
|
||||||
|
|
||||||
expect(graph.getPianoBus('pad')?.gain.setTargetAtTime).toHaveBeenCalled();
|
|
||||||
expect(graph.getPianoBus('brush')?.gain.setTargetAtTime).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,7 +1,38 @@
|
||||||
import { clamp } from '../utils/clamp';
|
import { clamp } from '../utils/math';
|
||||||
import { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
|
import { isIosLike } from './audio-platform';
|
||||||
|
import type { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
|
||||||
import type { PianoNoteRole } from './garden-audio-types';
|
import type { PianoNoteRole } from './garden-audio-types';
|
||||||
|
|
||||||
|
const outputHighPassFrequencyHz = 45;
|
||||||
|
const graphTuning = {
|
||||||
|
closeGain: 0.0001,
|
||||||
|
closeRampSeconds: 0.015,
|
||||||
|
delayMaxSeconds: 2,
|
||||||
|
eventBusGain: 1,
|
||||||
|
noiseMax: 1,
|
||||||
|
noiseMin: -1,
|
||||||
|
latencyHint: 'interactive' as AudioContextLatencyCategory,
|
||||||
|
outputFilterType: 'highpass' as BiquadFilterType,
|
||||||
|
noiseBufferChannels: 1,
|
||||||
|
noiseBufferDurationSeconds: 1,
|
||||||
|
unlockTickFrequencyHz: 440,
|
||||||
|
unlockTickGain: 0.0001,
|
||||||
|
unlockTickSeconds: 0.025,
|
||||||
|
unlockTickType: 'sine' as OscillatorType,
|
||||||
|
compressor: {
|
||||||
|
thresholdDb: -18,
|
||||||
|
kneeDb: 18,
|
||||||
|
ratio: 2.1,
|
||||||
|
attackSeconds: 0.018,
|
||||||
|
releaseSeconds: 0.18,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const delayFilterTuning = {
|
||||||
|
feedbackHighPassHz: 180,
|
||||||
|
feedbackLowPassHz: 5200,
|
||||||
|
returnLowPassHz: 6200,
|
||||||
|
};
|
||||||
|
|
||||||
export class GardenAudioGraph {
|
export class GardenAudioGraph {
|
||||||
public context: AudioContext | null = null;
|
public context: AudioContext | null = null;
|
||||||
public eventBus: GainNode | null = null;
|
public eventBus: GainNode | null = null;
|
||||||
|
|
@ -13,6 +44,8 @@ export class GardenAudioGraph {
|
||||||
private delayNode: DelayNode | null = null;
|
private delayNode: DelayNode | null = null;
|
||||||
private delayFeedback: GainNode | null = null;
|
private delayFeedback: GainNode | null = null;
|
||||||
private delayOutput: GainNode | null = null;
|
private delayOutput: GainNode | null = null;
|
||||||
|
private mediaStreamDestination: MediaStreamAudioDestinationNode | null = null;
|
||||||
|
private mediaStreamElement: HTMLAudioElement | null = null;
|
||||||
private readonly pianoBuses = new Map<PianoNoteRole, GainNode>();
|
private readonly pianoBuses = new Map<PianoNoteRole, GainNode>();
|
||||||
|
|
||||||
public constructor(private readonly config: GardenAudioConfig) {}
|
public constructor(private readonly config: GardenAudioConfig) {}
|
||||||
|
|
@ -34,7 +67,7 @@ export class GardenAudioGraph {
|
||||||
let context: AudioContext;
|
let context: AudioContext;
|
||||||
try {
|
try {
|
||||||
context = new AudioContextConstructor({
|
context = new AudioContextConstructor({
|
||||||
latencyHint: this.config.graph.latencyHint,
|
latencyHint: graphTuning.latencyHint,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
context = new AudioContextConstructor();
|
context = new AudioContextConstructor();
|
||||||
|
|
@ -42,22 +75,30 @@ export class GardenAudioGraph {
|
||||||
const masterGain = context.createGain();
|
const masterGain = context.createGain();
|
||||||
const highPass = context.createBiquadFilter();
|
const highPass = context.createBiquadFilter();
|
||||||
const compressor = context.createDynamicsCompressor();
|
const compressor = context.createDynamicsCompressor();
|
||||||
|
const mediaStreamDestination = isIosLike()
|
||||||
|
? context.createMediaStreamDestination()
|
||||||
|
: null;
|
||||||
|
|
||||||
masterGain.gain.value = 0;
|
masterGain.gain.value = 0;
|
||||||
highPass.type = this.config.graph.outputFilterType;
|
highPass.type = graphTuning.outputFilterType;
|
||||||
highPass.frequency.value = this.config.highPassFrequencyHz;
|
highPass.frequency.value = outputHighPassFrequencyHz;
|
||||||
compressor.threshold.value = this.config.graph.compressor.thresholdDb;
|
compressor.threshold.value = graphTuning.compressor.thresholdDb;
|
||||||
compressor.knee.value = this.config.graph.compressor.kneeDb;
|
compressor.knee.value = graphTuning.compressor.kneeDb;
|
||||||
compressor.ratio.value = this.config.graph.compressor.ratio;
|
compressor.ratio.value = graphTuning.compressor.ratio;
|
||||||
compressor.attack.value = this.config.graph.compressor.attackSeconds;
|
compressor.attack.value = graphTuning.compressor.attackSeconds;
|
||||||
compressor.release.value = this.config.graph.compressor.releaseSeconds;
|
compressor.release.value = graphTuning.compressor.releaseSeconds;
|
||||||
|
|
||||||
masterGain.connect(highPass);
|
masterGain.connect(highPass);
|
||||||
highPass.connect(compressor);
|
highPass.connect(compressor);
|
||||||
|
if (mediaStreamDestination) {
|
||||||
|
compressor.connect(mediaStreamDestination);
|
||||||
|
} else {
|
||||||
compressor.connect(context.destination);
|
compressor.connect(context.destination);
|
||||||
|
}
|
||||||
|
|
||||||
this.context = context;
|
this.context = context;
|
||||||
this.masterGain = masterGain;
|
this.masterGain = masterGain;
|
||||||
|
this.mediaStreamDestination = mediaStreamDestination;
|
||||||
this.noiseBuffer = this.createNoiseBuffer(context);
|
this.noiseBuffer = this.createNoiseBuffer(context);
|
||||||
this.createDelay(context, masterGain);
|
this.createDelay(context, masterGain);
|
||||||
this.createBuses(context, masterGain);
|
this.createBuses(context, masterGain);
|
||||||
|
|
@ -77,17 +118,13 @@ export class GardenAudioGraph {
|
||||||
const source = this.context.createOscillator();
|
const source = this.context.createOscillator();
|
||||||
const gain = this.context.createGain();
|
const gain = this.context.createGain();
|
||||||
|
|
||||||
source.type = this.config.graph.unlockTickType;
|
source.type = graphTuning.unlockTickType;
|
||||||
source.frequency.setValueAtTime(this.config.graph.unlockTickFrequencyHz, now);
|
source.frequency.setValueAtTime(graphTuning.unlockTickFrequencyHz, now);
|
||||||
gain.gain.setValueAtTime(this.config.piano.minGain, now);
|
gain.gain.setValueAtTime(graphTuning.unlockTickGain, now);
|
||||||
gain.gain.exponentialRampToValueAtTime(
|
|
||||||
this.config.piano.minGain,
|
|
||||||
now + this.config.graph.unlockTickSeconds
|
|
||||||
);
|
|
||||||
source.connect(gain);
|
source.connect(gain);
|
||||||
gain.connect(this.context.destination);
|
gain.connect(this.context.destination);
|
||||||
source.start(now);
|
source.start(now);
|
||||||
source.stop(now + this.config.graph.unlockTickSeconds);
|
source.stop(now + graphTuning.unlockTickSeconds);
|
||||||
source.addEventListener(
|
source.addEventListener(
|
||||||
'ended',
|
'ended',
|
||||||
() => {
|
() => {
|
||||||
|
|
@ -110,6 +147,38 @@ export class GardenAudioGraph {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public startMediaElementOutput(): void {
|
||||||
|
if (!this.mediaStreamDestination) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaElement = this.ensureMediaStreamElement();
|
||||||
|
const playPromise = mediaElement.play();
|
||||||
|
void playPromise?.catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureMediaStreamElement(): HTMLAudioElement {
|
||||||
|
if (this.mediaStreamElement) {
|
||||||
|
return this.mediaStreamElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaElement = document.createElement('audio');
|
||||||
|
mediaElement.autoplay = true;
|
||||||
|
mediaElement.volume = 1;
|
||||||
|
mediaElement.setAttribute('playsinline', '');
|
||||||
|
mediaElement.setAttribute('aria-hidden', 'true');
|
||||||
|
mediaElement.style.position = 'fixed';
|
||||||
|
mediaElement.style.width = '1px';
|
||||||
|
mediaElement.style.height = '1px';
|
||||||
|
mediaElement.style.opacity = '0';
|
||||||
|
mediaElement.style.pointerEvents = 'none';
|
||||||
|
mediaElement.style.left = '-9999px';
|
||||||
|
mediaElement.srcObject = this.mediaStreamDestination?.stream ?? null;
|
||||||
|
document.body.append(mediaElement);
|
||||||
|
this.mediaStreamElement = mediaElement;
|
||||||
|
return mediaElement;
|
||||||
|
}
|
||||||
|
|
||||||
public applyDelayProfile(profile: GardenAudioVibeProfile): void {
|
public applyDelayProfile(profile: GardenAudioVibeProfile): void {
|
||||||
if (!this.context || !this.delayNode) {
|
if (!this.context || !this.delayNode) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -167,9 +236,9 @@ export class GardenAudioGraph {
|
||||||
|
|
||||||
if (this.masterGain && context.state !== 'closed') {
|
if (this.masterGain && context.state !== 'closed') {
|
||||||
this.masterGain.gain.setTargetAtTime(
|
this.masterGain.gain.setTargetAtTime(
|
||||||
this.config.graph.closeGain,
|
graphTuning.closeGain,
|
||||||
context.currentTime,
|
context.currentTime,
|
||||||
this.config.graph.closeRampSeconds
|
graphTuning.closeRampSeconds
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -182,7 +251,7 @@ export class GardenAudioGraph {
|
||||||
|
|
||||||
private createDelay(context: AudioContext, masterGain: GainNode): void {
|
private createDelay(context: AudioContext, masterGain: GainNode): void {
|
||||||
const delayInput = context.createGain();
|
const delayInput = context.createGain();
|
||||||
const delayNode = context.createDelay(this.config.graph.delayMaxSeconds);
|
const delayNode = context.createDelay(graphTuning.delayMaxSeconds);
|
||||||
const delayFeedback = context.createGain();
|
const delayFeedback = context.createGain();
|
||||||
const delayOutput = context.createGain();
|
const delayOutput = context.createGain();
|
||||||
const feedbackHighPass = context.createBiquadFilter();
|
const feedbackHighPass = context.createBiquadFilter();
|
||||||
|
|
@ -193,11 +262,11 @@ export class GardenAudioGraph {
|
||||||
delayFeedback.gain.value = this.config.delay.feedback;
|
delayFeedback.gain.value = this.config.delay.feedback;
|
||||||
delayOutput.gain.value = this.config.delay.wetGain;
|
delayOutput.gain.value = this.config.delay.wetGain;
|
||||||
feedbackHighPass.type = 'highpass';
|
feedbackHighPass.type = 'highpass';
|
||||||
feedbackHighPass.frequency.value = this.config.delay.feedbackHighPassHz;
|
feedbackHighPass.frequency.value = delayFilterTuning.feedbackHighPassHz;
|
||||||
feedbackLowPass.type = 'lowpass';
|
feedbackLowPass.type = 'lowpass';
|
||||||
feedbackLowPass.frequency.value = this.config.delay.feedbackLowPassHz;
|
feedbackLowPass.frequency.value = delayFilterTuning.feedbackLowPassHz;
|
||||||
returnLowPass.type = 'lowpass';
|
returnLowPass.type = 'lowpass';
|
||||||
returnLowPass.frequency.value = this.config.delay.returnLowPassHz;
|
returnLowPass.frequency.value = delayFilterTuning.returnLowPassHz;
|
||||||
|
|
||||||
delayInput.connect(delayNode);
|
delayInput.connect(delayNode);
|
||||||
delayNode.connect(feedbackHighPass);
|
delayNode.connect(feedbackHighPass);
|
||||||
|
|
@ -216,7 +285,7 @@ export class GardenAudioGraph {
|
||||||
|
|
||||||
private createBuses(context: AudioContext, masterGain: GainNode): void {
|
private createBuses(context: AudioContext, masterGain: GainNode): void {
|
||||||
const eventBus = context.createGain();
|
const eventBus = context.createGain();
|
||||||
eventBus.gain.value = this.config.graph.eventBusGain;
|
eventBus.gain.value = graphTuning.eventBusGain;
|
||||||
eventBus.connect(masterGain);
|
eventBus.connect(masterGain);
|
||||||
this.eventBus = eventBus;
|
this.eventBus = eventBus;
|
||||||
this.pianoBuses.clear();
|
this.pianoBuses.clear();
|
||||||
|
|
@ -249,12 +318,11 @@ export class GardenAudioGraph {
|
||||||
|
|
||||||
private createNoiseBuffer(context: AudioContext): AudioBuffer {
|
private createNoiseBuffer(context: AudioContext): AudioBuffer {
|
||||||
const buffer = context.createBuffer(
|
const buffer = context.createBuffer(
|
||||||
appPositiveInteger(this.config.graph.noiseBufferChannels),
|
appPositiveInteger(graphTuning.noiseBufferChannels),
|
||||||
Math.max(
|
Math.max(
|
||||||
1,
|
1,
|
||||||
Math.floor(
|
Math.floor(
|
||||||
context.sampleRate *
|
context.sampleRate * Math.max(0.001, graphTuning.noiseBufferDurationSeconds)
|
||||||
Math.max(0.001, this.config.graph.noiseBufferDurationSeconds)
|
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
context.sampleRate
|
context.sampleRate
|
||||||
|
|
@ -263,8 +331,8 @@ export class GardenAudioGraph {
|
||||||
|
|
||||||
for (let index = 0; index < data.length; index++) {
|
for (let index = 0; index < data.length; index++) {
|
||||||
data[index] =
|
data[index] =
|
||||||
this.config.graph.noiseMin +
|
graphTuning.noiseMin +
|
||||||
Math.random() * (this.config.graph.noiseMax - this.config.graph.noiseMin);
|
Math.random() * (graphTuning.noiseMax - graphTuning.noiseMin);
|
||||||
}
|
}
|
||||||
|
|
||||||
return buffer;
|
return buffer;
|
||||||
|
|
@ -280,6 +348,13 @@ export class GardenAudioGraph {
|
||||||
this.delayNode = null;
|
this.delayNode = null;
|
||||||
this.delayFeedback = null;
|
this.delayFeedback = null;
|
||||||
this.delayOutput = null;
|
this.delayOutput = null;
|
||||||
|
this.mediaStreamDestination = null;
|
||||||
|
if (this.mediaStreamElement) {
|
||||||
|
this.mediaStreamElement.pause();
|
||||||
|
this.mediaStreamElement.srcObject = null;
|
||||||
|
this.mediaStreamElement.remove();
|
||||||
|
this.mediaStreamElement = null;
|
||||||
|
}
|
||||||
this.pianoBuses.clear();
|
this.pianoBuses.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import { gardenAudioConfig } from './garden-audio-config';
|
|
||||||
import { getStrokeMetrics } from './garden-audio-input';
|
|
||||||
|
|
||||||
describe('getStrokeMetrics', () => {
|
|
||||||
it('normalizes stroke distance against canvas size', () => {
|
|
||||||
const standardDensity = getStrokeMetrics(
|
|
||||||
{
|
|
||||||
vibe: {} as never,
|
|
||||||
from: [0, 0],
|
|
||||||
to: [100, 0],
|
|
||||||
canvasSize: [1000, 500],
|
|
||||||
elapsedSeconds: 0.1,
|
|
||||||
isErasing: false,
|
|
||||||
},
|
|
||||||
gardenAudioConfig.input
|
|
||||||
);
|
|
||||||
const highDensity = getStrokeMetrics(
|
|
||||||
{
|
|
||||||
vibe: {} as never,
|
|
||||||
from: [0, 0],
|
|
||||||
to: [200, 0],
|
|
||||||
canvasSize: [2000, 1000],
|
|
||||||
elapsedSeconds: 0.1,
|
|
||||||
isErasing: false,
|
|
||||||
},
|
|
||||||
gardenAudioConfig.input
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(highDensity.normalizedDistance).toBeCloseTo(
|
|
||||||
standardDensity.normalizedDistance
|
|
||||||
);
|
|
||||||
expect(highDensity.normalizedSpeed).toBeCloseTo(standardDensity.normalizedSpeed);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses configured elapsed-time floors for missing or invalid samples', () => {
|
|
||||||
const metrics = getStrokeMetrics(
|
|
||||||
{
|
|
||||||
vibe: {} as never,
|
|
||||||
from: [0, 0],
|
|
||||||
to: [10, 0],
|
|
||||||
elapsedSeconds: 0,
|
|
||||||
isErasing: false,
|
|
||||||
},
|
|
||||||
gardenAudioConfig.input
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(metrics.elapsedSeconds).toBe(gardenAudioConfig.input.fallbackFrameSeconds);
|
|
||||||
expect(Number.isFinite(metrics.normalizedSpeed)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import type { GardenAudioConfig } from './garden-audio-config';
|
|
||||||
import type { GardenAudioStroke } from './garden-audio-types';
|
import type { GardenAudioStroke } from './garden-audio-types';
|
||||||
|
|
||||||
const fallbackNormalizationPixels = 1000;
|
const fallbackNormalizationPixels = 1000;
|
||||||
|
const fallbackFrameSeconds = 1 / 60;
|
||||||
|
const minElapsedSeconds = 0.001;
|
||||||
|
|
||||||
export interface GardenAudioStrokeMetrics {
|
export interface GardenAudioStrokeMetrics {
|
||||||
distancePixels: number;
|
distancePixels: number;
|
||||||
|
|
@ -10,14 +11,11 @@ export interface GardenAudioStrokeMetrics {
|
||||||
normalizedSpeed: number;
|
normalizedSpeed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getStrokeMetrics = (
|
export const getStrokeMetrics = (stroke: GardenAudioStroke): GardenAudioStrokeMetrics => {
|
||||||
stroke: GardenAudioStroke,
|
|
||||||
inputConfig: GardenAudioConfig['input']
|
|
||||||
): GardenAudioStrokeMetrics => {
|
|
||||||
const dx = stroke.to[0] - stroke.from[0];
|
const dx = stroke.to[0] - stroke.from[0];
|
||||||
const dy = stroke.to[1] - stroke.from[1];
|
const dy = stroke.to[1] - stroke.from[1];
|
||||||
const distancePixels = Math.hypot(dx, dy);
|
const distancePixels = Math.hypot(dx, dy);
|
||||||
const elapsedSeconds = getElapsedSeconds(stroke, inputConfig);
|
const elapsedSeconds = getElapsedSeconds(stroke);
|
||||||
const normalizedDistance = distancePixels / getStrokeNormalizationPixels(stroke);
|
const normalizedDistance = distancePixels / getStrokeNormalizationPixels(stroke);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -28,19 +26,16 @@ export const getStrokeMetrics = (
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getElapsedSeconds = (
|
const getElapsedSeconds = (stroke: GardenAudioStroke): number => {
|
||||||
stroke: GardenAudioStroke,
|
|
||||||
inputConfig: GardenAudioConfig['input']
|
|
||||||
): number => {
|
|
||||||
if (
|
if (
|
||||||
stroke.elapsedSeconds !== undefined &&
|
stroke.elapsedSeconds !== undefined &&
|
||||||
Number.isFinite(stroke.elapsedSeconds) &&
|
Number.isFinite(stroke.elapsedSeconds) &&
|
||||||
stroke.elapsedSeconds > 0
|
stroke.elapsedSeconds > 0
|
||||||
) {
|
) {
|
||||||
return Math.max(inputConfig.minElapsedSeconds, stroke.elapsedSeconds);
|
return Math.max(minElapsedSeconds, stroke.elapsedSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
return inputConfig.fallbackFrameSeconds;
|
return fallbackFrameSeconds;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStrokeNormalizationPixels = (stroke: GardenAudioStroke): number => {
|
const getStrokeNormalizationPixels = (stroke: GardenAudioStroke): number => {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,14 @@
|
||||||
import { VibePreset } from '../vibes';
|
import type { VibePreset } from '../vibes';
|
||||||
import {
|
import type { GardenAudioChord, GardenAudioVibeProfile } from './garden-audio-config';
|
||||||
GardenAudioChord,
|
|
||||||
gardenAudioConfig,
|
export const PITCH_SEMITONES_PER_OCTAVE = 12;
|
||||||
GardenAudioVibeProfile,
|
|
||||||
} from './garden-audio-config';
|
const chordVoicings = {
|
||||||
|
majorOpen: [0, 7, 12, 16],
|
||||||
|
minorOpen: [0, 7, 12, 15],
|
||||||
|
majorClosed: [0, 4, 7, 12, 16],
|
||||||
|
minorClosed: [0, 3, 7, 12, 15],
|
||||||
|
};
|
||||||
|
|
||||||
export const getVibeProfile = (vibe: VibePreset): GardenAudioVibeProfile => vibe.audio;
|
export const getVibeProfile = (vibe: VibePreset): GardenAudioVibeProfile => vibe.audio;
|
||||||
|
|
||||||
|
|
@ -12,14 +17,12 @@ export const getChordIntervals = (
|
||||||
openVoicing: boolean
|
openVoicing: boolean
|
||||||
): Array<number> => {
|
): Array<number> => {
|
||||||
if (openVoicing) {
|
if (openVoicing) {
|
||||||
return chord.quality === 'major'
|
return chord.quality === 'major' ? chordVoicings.majorOpen : chordVoicings.minorOpen;
|
||||||
? gardenAudioConfig.generativePiano.chordVoicings.majorOpen
|
|
||||||
: gardenAudioConfig.generativePiano.chordVoicings.minorOpen;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return chord.quality === 'major'
|
return chord.quality === 'major'
|
||||||
? gardenAudioConfig.generativePiano.chordVoicings.majorClosed
|
? chordVoicings.majorClosed
|
||||||
: gardenAudioConfig.generativePiano.chordVoicings.minorClosed;
|
: chordVoicings.minorClosed;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const degreeToSemitone = (
|
export const degreeToSemitone = (
|
||||||
|
|
@ -29,7 +32,5 @@ export const degreeToSemitone = (
|
||||||
const scaleIndex =
|
const scaleIndex =
|
||||||
((degree % profile.scale.length) + profile.scale.length) % profile.scale.length;
|
((degree % profile.scale.length) + profile.scale.length) % profile.scale.length;
|
||||||
const octave = Math.floor(degree / profile.scale.length);
|
const octave = Math.floor(degree / profile.scale.length);
|
||||||
return (
|
return profile.scale[scaleIndex] + octave * PITCH_SEMITONES_PER_OCTAVE;
|
||||||
profile.scale[scaleIndex] + octave * gardenAudioConfig.piano.pitchSemitonesPerOctave
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,326 +0,0 @@
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
import { appConfig } from '../config';
|
|
||||||
import { VIBE_PRESETS } from '../vibes';
|
|
||||||
import { gardenAudioConfig, GardenAudioConfig } from './garden-audio-config';
|
|
||||||
|
|
||||||
type FakeScheduledSourceNode = {
|
|
||||||
start: ReturnType<typeof vi.fn>;
|
|
||||||
stop: ReturnType<typeof vi.fn>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const calls = {
|
|
||||||
constructed: 0,
|
|
||||||
resumed: 0,
|
|
||||||
sourcesStarted: 0,
|
|
||||||
sources: [] as Array<FakeScheduledSourceNode>,
|
|
||||||
gains: [] as Array<FakeAudioNode>,
|
|
||||||
};
|
|
||||||
|
|
||||||
let contextState: AudioContextState = 'suspended';
|
|
||||||
let resumeError: Error | null = null;
|
|
||||||
let ErrorHandler: typeof import('../utils/error-handler').ErrorHandler;
|
|
||||||
let GardenAudio: typeof import('./garden-audio').GardenAudio;
|
|
||||||
let loadPianoSamples: typeof import('./piano-samples').loadPianoSamples;
|
|
||||||
let Severity: typeof import('../utils/error-handler').Severity;
|
|
||||||
|
|
||||||
class FakeAudioParam {
|
|
||||||
public value = 0;
|
|
||||||
public setTargetAtTime = vi.fn();
|
|
||||||
public setValueAtTime = vi.fn();
|
|
||||||
public exponentialRampToValueAtTime = vi.fn();
|
|
||||||
public cancelScheduledValues = vi.fn();
|
|
||||||
}
|
|
||||||
|
|
||||||
class FakeAudioNode {
|
|
||||||
public readonly gain = new FakeAudioParam();
|
|
||||||
public readonly frequency = new FakeAudioParam();
|
|
||||||
public readonly playbackRate = new FakeAudioParam();
|
|
||||||
public readonly Q = new FakeAudioParam();
|
|
||||||
public readonly threshold = new FakeAudioParam();
|
|
||||||
public readonly knee = new FakeAudioParam();
|
|
||||||
public readonly ratio = new FakeAudioParam();
|
|
||||||
public readonly attack = new FakeAudioParam();
|
|
||||||
public readonly release = new FakeAudioParam();
|
|
||||||
public readonly delayTime = new FakeAudioParam();
|
|
||||||
public readonly pan = new FakeAudioParam();
|
|
||||||
public type = '';
|
|
||||||
public addEventListener = vi.fn();
|
|
||||||
public connect = vi.fn();
|
|
||||||
public disconnect = vi.fn();
|
|
||||||
}
|
|
||||||
|
|
||||||
class FakeAudioBuffer {
|
|
||||||
private readonly data: Float32Array;
|
|
||||||
|
|
||||||
public constructor(length: number) {
|
|
||||||
this.data = new Float32Array(length);
|
|
||||||
}
|
|
||||||
|
|
||||||
public getChannelData(): Float32Array {
|
|
||||||
return this.data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class FakeAudioContext {
|
|
||||||
public readonly currentTime = 1;
|
|
||||||
public readonly sampleRate = 16;
|
|
||||||
public readonly destination = new FakeAudioNode() as unknown as AudioDestinationNode;
|
|
||||||
public readonly decodeAudioData = vi.fn(async () => ({}) as AudioBuffer);
|
|
||||||
|
|
||||||
public constructor() {
|
|
||||||
calls.constructed += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get state(): AudioContextState {
|
|
||||||
return contextState;
|
|
||||||
}
|
|
||||||
|
|
||||||
public set state(state: AudioContextState) {
|
|
||||||
contextState = state;
|
|
||||||
}
|
|
||||||
|
|
||||||
public createGain(): GainNode {
|
|
||||||
const node = new FakeAudioNode();
|
|
||||||
calls.gains.push(node);
|
|
||||||
return node as unknown as GainNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
public createBiquadFilter(): BiquadFilterNode {
|
|
||||||
return new FakeAudioNode() as unknown as BiquadFilterNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
public createDelay(): DelayNode {
|
|
||||||
return new FakeAudioNode() as unknown as DelayNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
public createDynamicsCompressor(): DynamicsCompressorNode {
|
|
||||||
return new FakeAudioNode() as unknown as DynamicsCompressorNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
public createStereoPanner(): StereoPannerNode {
|
|
||||||
return new FakeAudioNode() as unknown as StereoPannerNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
public createBuffer(_channels: number, length: number): AudioBuffer {
|
|
||||||
return new FakeAudioBuffer(length) as unknown as AudioBuffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
public createBufferSource(): AudioBufferSourceNode {
|
|
||||||
const node = new FakeAudioNode() as unknown as AudioBufferSourceNode & {
|
|
||||||
buffer: AudioBuffer | null;
|
|
||||||
start: () => void;
|
|
||||||
stop: () => void;
|
|
||||||
};
|
|
||||||
node.buffer = null;
|
|
||||||
node.start = vi.fn(() => {
|
|
||||||
calls.sourcesStarted += 1;
|
|
||||||
}) as unknown as typeof node.start;
|
|
||||||
node.stop = vi.fn() as unknown as typeof node.stop;
|
|
||||||
calls.sources.push(node as unknown as FakeScheduledSourceNode);
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
public createOscillator(): OscillatorNode {
|
|
||||||
const node = new FakeAudioNode() as unknown as OscillatorNode & {
|
|
||||||
start: () => void;
|
|
||||||
stop: () => void;
|
|
||||||
};
|
|
||||||
node.start = vi.fn(() => {
|
|
||||||
calls.sourcesStarted += 1;
|
|
||||||
}) as unknown as typeof node.start;
|
|
||||||
node.stop = vi.fn() as unknown as typeof node.stop;
|
|
||||||
calls.sources.push(node as unknown as FakeScheduledSourceNode);
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async resume(): Promise<void> {
|
|
||||||
calls.resumed += 1;
|
|
||||||
if (resumeError) {
|
|
||||||
throw resumeError;
|
|
||||||
}
|
|
||||||
contextState = 'running';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const makeConfig = (): GardenAudioConfig => ({
|
|
||||||
...gardenAudioConfig,
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('GardenAudio startup policy', () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
vi.resetModules();
|
|
||||||
({ ErrorHandler, Severity } = await import('../utils/error-handler'));
|
|
||||||
({ GardenAudio } = await import('./garden-audio'));
|
|
||||||
({ loadPianoSamples } = await import('./piano-samples'));
|
|
||||||
|
|
||||||
calls.constructed = 0;
|
|
||||||
calls.resumed = 0;
|
|
||||||
calls.sourcesStarted = 0;
|
|
||||||
calls.sources = [];
|
|
||||||
calls.gains = [];
|
|
||||||
contextState = 'suspended';
|
|
||||||
resumeError = null;
|
|
||||||
vi.stubGlobal('AudioContext', FakeAudioContext);
|
|
||||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('not loaded in tests')));
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not create an AudioContext from passive audio paths', () => {
|
|
||||||
const audio = new GardenAudio(makeConfig());
|
|
||||||
const vibe = VIBE_PRESETS[0];
|
|
||||||
|
|
||||||
audio.start(vibe);
|
|
||||||
audio.stroke({
|
|
||||||
vibe,
|
|
||||||
from: [0, 0],
|
|
||||||
to: [12, 0],
|
|
||||||
isErasing: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(calls.constructed).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('only resumes a suspended context from a user gesture start', () => {
|
|
||||||
const audio = new GardenAudio(makeConfig());
|
|
||||||
const vibe = VIBE_PRESETS[0];
|
|
||||||
|
|
||||||
audio.start(vibe, { userGesture: true });
|
|
||||||
|
|
||||||
expect(calls.constructed).toBe(1);
|
|
||||||
expect(calls.resumed).toBe(1);
|
|
||||||
expect(contextState).toBe('running');
|
|
||||||
|
|
||||||
contextState = 'suspended';
|
|
||||||
audio.start(vibe);
|
|
||||||
audio.setMuted(false);
|
|
||||||
|
|
||||||
expect(calls.resumed).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reports AudioContext resume failures as warnings', async () => {
|
|
||||||
const audio = new GardenAudio(makeConfig());
|
|
||||||
const vibe = VIBE_PRESETS[0];
|
|
||||||
resumeError = new Error('resume rejected');
|
|
||||||
const addException = vi.spyOn(ErrorHandler, 'addException');
|
|
||||||
|
|
||||||
audio.start(vibe, { userGesture: true });
|
|
||||||
await Promise.resolve();
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
expect(addException).toHaveBeenCalledWith(resumeError, {
|
|
||||||
fallbackMessage: 'Could not resume audio playback.',
|
|
||||||
severity: Severity.WARNING,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates live master gain from adjustable volume', () => {
|
|
||||||
const config = makeConfig();
|
|
||||||
const audio = new GardenAudio(config);
|
|
||||||
const vibe = VIBE_PRESETS[0];
|
|
||||||
|
|
||||||
audio.start(vibe, { userGesture: true });
|
|
||||||
const masterGain = calls.gains[0]?.gain;
|
|
||||||
if (!masterGain) {
|
|
||||||
throw new Error('Missing fake master gain');
|
|
||||||
}
|
|
||||||
|
|
||||||
audio.setMasterVolume(0.2);
|
|
||||||
|
|
||||||
expect(masterGain.setTargetAtTime).toHaveBeenLastCalledWith(
|
|
||||||
0.2,
|
|
||||||
1,
|
|
||||||
config.updateRampSeconds
|
|
||||||
);
|
|
||||||
|
|
||||||
audio.setMuted(true);
|
|
||||||
const mutedCallCount = masterGain.setTargetAtTime.mock.calls.length;
|
|
||||||
audio.setMasterVolume(0.8);
|
|
||||||
|
|
||||||
expect(masterGain.setTargetAtTime).toHaveBeenCalledTimes(mutedCallCount);
|
|
||||||
|
|
||||||
audio.setMuted(false);
|
|
||||||
|
|
||||||
expect(masterGain.setTargetAtTime).toHaveBeenLastCalledWith(
|
|
||||||
0.8,
|
|
||||||
1,
|
|
||||||
config.fadeInSeconds
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stays silent without piano samples while preserving eraser noise', () => {
|
|
||||||
const audio = new GardenAudio(makeConfig());
|
|
||||||
const vibe = VIBE_PRESETS[0];
|
|
||||||
|
|
||||||
audio.start(vibe, { userGesture: true });
|
|
||||||
expect(calls.sourcesStarted).toBe(1);
|
|
||||||
|
|
||||||
audio.beginGesture();
|
|
||||||
audio.stroke({
|
|
||||||
vibe,
|
|
||||||
from: [30, 40],
|
|
||||||
to: [60, 60],
|
|
||||||
isErasing: false,
|
|
||||||
elapsedSeconds: 0.05,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(calls.sourcesStarted).toBe(1);
|
|
||||||
|
|
||||||
audio.stroke({
|
|
||||||
vibe,
|
|
||||||
from: [60, 60],
|
|
||||||
to: [75, 80],
|
|
||||||
isErasing: true,
|
|
||||||
elapsedSeconds: 0.05,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(calls.sourcesStarted).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('quickly stops active piano voices when the vibe changes', async () => {
|
|
||||||
vi.stubGlobal(
|
|
||||||
'fetch',
|
|
||||||
vi.fn(async () => ({
|
|
||||||
arrayBuffer: async () => new ArrayBuffer(8),
|
|
||||||
ok: true,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
await loadPianoSamples(new FakeAudioContext() as unknown as AudioContext);
|
|
||||||
|
|
||||||
const audio = new GardenAudio(makeConfig());
|
|
||||||
const vibe = VIBE_PRESETS[0];
|
|
||||||
|
|
||||||
audio.start(vibe, { userGesture: true });
|
|
||||||
audio.beginGesture();
|
|
||||||
audio.stroke({
|
|
||||||
vibe,
|
|
||||||
from: [30, 40],
|
|
||||||
to: [90, 40],
|
|
||||||
elapsedSeconds: 0.05,
|
|
||||||
isErasing: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const activePianoSources = calls.sources.filter(
|
|
||||||
(source) => source.stop.mock.calls.length === 1
|
|
||||||
);
|
|
||||||
expect(activePianoSources.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
const stopCounts = activePianoSources.map((source) => source.stop.mock.calls.length);
|
|
||||||
audio.changeVibe(VIBE_PRESETS[1], { userGesture: true });
|
|
||||||
|
|
||||||
const stoppedVoices = activePianoSources.filter(
|
|
||||||
(source, index) => source.stop.mock.calls.length === stopCounts[index] + 1
|
|
||||||
);
|
|
||||||
expect(stoppedVoices.length).toBeGreaterThan(0);
|
|
||||||
stoppedVoices.forEach((source) => {
|
|
||||||
expect(source.stop.mock.calls.at(-1)?.[0]).toBeCloseTo(
|
|
||||||
1 + appConfig.audio.piano.voiceStealStopSeconds,
|
|
||||||
3
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { clamp01 } from '../utils/clamp';
|
|
||||||
import { ErrorHandler, Severity } from '../utils/error-handler';
|
import { ErrorHandler, Severity } from '../utils/error-handler';
|
||||||
|
import { clamp01 } from '../utils/math';
|
||||||
import type { VibeId, VibePreset } from '../vibes';
|
import type { VibeId, VibePreset } from '../vibes';
|
||||||
import { GardenAudioConfig } from './garden-audio-config';
|
import type { GardenAudioConfig } from './garden-audio-config';
|
||||||
import { GardenAudioEnergy } from './garden-audio-energy';
|
import { GardenAudioEnergy } from './garden-audio-energy';
|
||||||
import { GardenAudioGestureState } from './garden-audio-gesture-state';
|
import { GardenAudioGestureState } from './garden-audio-gesture-state';
|
||||||
import { GardenAudioGraph } from './garden-audio-graph';
|
import { GardenAudioGraph } from './garden-audio-graph';
|
||||||
|
|
@ -24,6 +24,12 @@ export type {
|
||||||
|
|
||||||
type AudioLifecycle = 'idle' | 'started' | 'destroyed';
|
type AudioLifecycle = 'idle' | 'started' | 'destroyed';
|
||||||
|
|
||||||
|
const muteGain = 0.0001;
|
||||||
|
const muteRampSeconds = 0.02;
|
||||||
|
const brushUpPianoFinishSeconds = 1.2;
|
||||||
|
const brushUpPianoFadeSeconds = 1.1;
|
||||||
|
const vibeChangeStingerMinIntervalSeconds = 0.45;
|
||||||
|
|
||||||
export class GardenAudio {
|
export class GardenAudio {
|
||||||
private readonly graph: GardenAudioGraph;
|
private readonly graph: GardenAudioGraph;
|
||||||
private readonly piano: PianoSampler;
|
private readonly piano: PianoSampler;
|
||||||
|
|
@ -36,7 +42,10 @@ export class GardenAudio {
|
||||||
private lifecycle: AudioLifecycle = 'idle';
|
private lifecycle: AudioLifecycle = 'idle';
|
||||||
private isMuted = false;
|
private isMuted = false;
|
||||||
private isGestureActive = false;
|
private isGestureActive = false;
|
||||||
|
private isPianoStoppedAfterGesture = false;
|
||||||
|
private fadePianoAfter: number | null = null;
|
||||||
private masterVolume: number;
|
private masterVolume: number;
|
||||||
|
private stopPianoAfter: number | null = null;
|
||||||
private lastEraserAt = Number.NEGATIVE_INFINITY;
|
private lastEraserAt = Number.NEGATIVE_INFINITY;
|
||||||
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
||||||
|
|
||||||
|
|
@ -44,50 +53,55 @@ export class GardenAudio {
|
||||||
this.masterVolume = clamp01(config.masterVolume);
|
this.masterVolume = clamp01(config.masterVolume);
|
||||||
this.graph = new GardenAudioGraph(config);
|
this.graph = new GardenAudioGraph(config);
|
||||||
this.piano = new PianoSampler(config, this.graph);
|
this.piano = new PianoSampler(config, this.graph);
|
||||||
this.noise = new NoiseBurstPlayer(config, this.graph);
|
this.noise = new NoiseBurstPlayer(this.graph);
|
||||||
this.energy = new GardenAudioEnergy(config);
|
this.energy = new GardenAudioEnergy(config);
|
||||||
this.gestureState = new GardenAudioGestureState(config.input);
|
this.gestureState = new GardenAudioGestureState(config.input);
|
||||||
this.pianoEngine = new GenerativePianoEngine(config, (note) => this.piano.play(note));
|
this.pianoEngine = new GenerativePianoEngine(config, (note) => this.piano.play(note));
|
||||||
}
|
}
|
||||||
|
|
||||||
public start(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
|
public start(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
|
||||||
if (this.lifecycle === 'destroyed' || this.isMuted) {
|
const isUserGesture = options.userGesture === true;
|
||||||
|
|
||||||
|
if (this.lifecycle === 'destroyed') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const context = this.graph.ensureContext(options.userGesture === true);
|
const context = this.graph.ensureContext(isUserGesture);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const startupRampSeconds =
|
const startupRampSeconds = isUserGesture
|
||||||
options.userGesture === true
|
? muteRampSeconds
|
||||||
? this.config.muteRampSeconds
|
|
||||||
: this.config.fadeInSeconds;
|
: this.config.fadeInSeconds;
|
||||||
const needsResume = context.state !== 'running' && context.state !== 'closed';
|
const needsResume = context.state !== 'running' && context.state !== 'closed';
|
||||||
let resumePromise: Promise<void> | null = null;
|
let resumePromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
if (isUserGesture) {
|
||||||
|
this.graph.startMediaElementOutput();
|
||||||
|
this.graph.unlock();
|
||||||
|
}
|
||||||
|
|
||||||
if (needsResume) {
|
if (needsResume) {
|
||||||
if (options.userGesture !== true) {
|
if (!isUserGesture) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
resumePromise = context.resume();
|
resumePromise = context.resume();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.userGesture === true) {
|
if (isUserGesture) {
|
||||||
this.graph.unlock();
|
this.graph.unlock();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resumePromise) {
|
if (resumePromise) {
|
||||||
void resumePromise
|
void resumePromise
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (
|
if (this.graph.context === context && this.lifecycle !== 'destroyed') {
|
||||||
this.graph.context === context &&
|
|
||||||
this.lifecycle !== 'destroyed' &&
|
|
||||||
!this.isMuted
|
|
||||||
) {
|
|
||||||
this.graph.unlock();
|
this.graph.unlock();
|
||||||
this.graph.setMasterGain(this.masterVolume, startupRampSeconds);
|
this.completeStart(vibe, {
|
||||||
|
context,
|
||||||
|
startupRampSeconds,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
|
@ -96,6 +110,32 @@ export class GardenAudio {
|
||||||
severity: Severity.WARNING,
|
severity: Severity.WARNING,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.completeStart(vibe, {
|
||||||
|
context,
|
||||||
|
startupRampSeconds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private completeStart(
|
||||||
|
vibe: VibePreset,
|
||||||
|
{
|
||||||
|
context,
|
||||||
|
startupRampSeconds,
|
||||||
|
}: {
|
||||||
|
context: AudioContext;
|
||||||
|
startupRampSeconds: number;
|
||||||
|
}
|
||||||
|
): void {
|
||||||
|
if (this.graph.context !== context || this.lifecycle === 'destroyed') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isMuted) {
|
||||||
|
this.graph.setMasterGain(muteGain, muteRampSeconds);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.lifecycle = 'started';
|
this.lifecycle = 'started';
|
||||||
|
|
@ -111,7 +151,12 @@ export class GardenAudio {
|
||||||
this.pianoEngine.cue(context.currentTime);
|
this.pianoEngine.cue(context.currentTime);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch((error) => {
|
||||||
|
ErrorHandler.addException(error, {
|
||||||
|
fallbackMessage: 'Could not load piano samples. Using synthesized audio.',
|
||||||
|
severity: Severity.WARNING,
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -143,8 +188,8 @@ export class GardenAudio {
|
||||||
|
|
||||||
this.isMuted = isMuted;
|
this.isMuted = isMuted;
|
||||||
this.graph.setMasterGain(
|
this.graph.setMasterGain(
|
||||||
isMuted ? this.config.muteGain : this.masterVolume,
|
isMuted ? muteGain : this.masterVolume,
|
||||||
isMuted ? this.config.muteRampSeconds : this.config.fadeInSeconds
|
isMuted ? muteRampSeconds : this.config.fadeInSeconds
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -162,6 +207,9 @@ export class GardenAudio {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isGestureActive = true;
|
this.isGestureActive = true;
|
||||||
|
this.isPianoStoppedAfterGesture = false;
|
||||||
|
this.fadePianoAfter = null;
|
||||||
|
this.stopPianoAfter = null;
|
||||||
this.gestureState.beginGesture();
|
this.gestureState.beginGesture();
|
||||||
this.energy.beginGesture(context.currentTime);
|
this.energy.beginGesture(context.currentTime);
|
||||||
this.pianoEngine.beginGesture();
|
this.pianoEngine.beginGesture();
|
||||||
|
|
@ -170,6 +218,12 @@ export class GardenAudio {
|
||||||
public endGesture(): void {
|
public endGesture(): void {
|
||||||
this.gestureState.endGesture();
|
this.gestureState.endGesture();
|
||||||
this.isGestureActive = false;
|
this.isGestureActive = false;
|
||||||
|
const context = this.graph.context;
|
||||||
|
this.isPianoStoppedAfterGesture = true;
|
||||||
|
this.fadePianoAfter = context
|
||||||
|
? context.currentTime + brushUpPianoFinishSeconds
|
||||||
|
: null;
|
||||||
|
this.stopPianoAfter = null;
|
||||||
this.energy.endGesture();
|
this.energy.endGesture();
|
||||||
this.pianoEngine.endGesture();
|
this.pianoEngine.endGesture();
|
||||||
}
|
}
|
||||||
|
|
@ -187,6 +241,21 @@ export class GardenAudio {
|
||||||
this.energy.silence();
|
this.energy.silence();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!this.isGestureActive && this.isPianoStoppedAfterGesture) {
|
||||||
|
if (this.fadePianoAfter !== null && context.currentTime >= this.fadePianoAfter) {
|
||||||
|
this.piano.fadeAll(brushUpPianoFadeSeconds);
|
||||||
|
this.fadePianoAfter = null;
|
||||||
|
this.stopPianoAfter = context.currentTime + brushUpPianoFadeSeconds;
|
||||||
|
}
|
||||||
|
if (this.stopPianoAfter !== null && context.currentTime >= this.stopPianoAfter) {
|
||||||
|
this.piano.stopAll();
|
||||||
|
this.pianoEngine.reset();
|
||||||
|
this.stopPianoAfter = null;
|
||||||
|
}
|
||||||
|
this.updateDelay(snapshot);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.pianoEngine.renderLookahead({
|
this.pianoEngine.renderLookahead({
|
||||||
vibe: snapshot.vibe,
|
vibe: snapshot.vibe,
|
||||||
now: context.currentTime,
|
now: context.currentTime,
|
||||||
|
|
@ -211,7 +280,7 @@ export class GardenAudio {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const metrics = getStrokeMetrics(stroke, this.config.input);
|
const metrics = getStrokeMetrics(stroke);
|
||||||
const now = context.currentTime;
|
const now = context.currentTime;
|
||||||
|
|
||||||
const frame = this.gestureState.recordStroke({ metrics });
|
const frame = this.gestureState.recordStroke({ metrics });
|
||||||
|
|
@ -242,6 +311,9 @@ export class GardenAudio {
|
||||||
this.pianoEngine.reset();
|
this.pianoEngine.reset();
|
||||||
this.currentVibeId = null;
|
this.currentVibeId = null;
|
||||||
this.isGestureActive = false;
|
this.isGestureActive = false;
|
||||||
|
this.isPianoStoppedAfterGesture = false;
|
||||||
|
this.fadePianoAfter = null;
|
||||||
|
this.stopPianoAfter = null;
|
||||||
this.lastEraserAt = Number.NEGATIVE_INFINITY;
|
this.lastEraserAt = Number.NEGATIVE_INFINITY;
|
||||||
this.lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
this.lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
||||||
}
|
}
|
||||||
|
|
@ -253,7 +325,7 @@ export class GardenAudio {
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = context.currentTime;
|
const now = context.currentTime;
|
||||||
if (now - this.lastVibeStingerAt < this.config.vibeChangeStingerMinIntervalSeconds) {
|
if (now - this.lastVibeStingerAt < vibeChangeStingerMinIntervalSeconds) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,249 +0,0 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import { VIBE_PRESETS } from '../vibes';
|
|
||||||
import { gardenAudioConfig } from './garden-audio-config';
|
|
||||||
import { PianoNote } from './garden-audio-types';
|
|
||||||
import { GenerativePianoEngine } from './generative-piano';
|
|
||||||
|
|
||||||
const makeEngine = () => {
|
|
||||||
const notes: Array<PianoNote> = [];
|
|
||||||
const engine = new GenerativePianoEngine(gardenAudioConfig, (note) => {
|
|
||||||
notes.push(note);
|
|
||||||
});
|
|
||||||
|
|
||||||
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,
|
|
||||||
bars = 8,
|
|
||||||
now = 0
|
|
||||||
) => {
|
|
||||||
engine.renderLookahead({
|
|
||||||
vibe: VIBE_PRESETS[0],
|
|
||||||
now,
|
|
||||||
activity,
|
|
||||||
lookaheadSeconds: getBeatSeconds() * getBeatsPerBar() * bars,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const average = (values: Array<number>): number =>
|
|
||||||
values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
||||||
|
|
||||||
const uniqueStartTimes = (notes: Array<PianoNote>): Array<string> =>
|
|
||||||
Array.from(new Set(notes.map((note) => note.startTime.toFixed(3))));
|
|
||||||
|
|
||||||
const countNotesBetween = (
|
|
||||||
notes: Array<PianoNote>,
|
|
||||||
startSeconds: number,
|
|
||||||
endSeconds: number
|
|
||||||
): number =>
|
|
||||||
notes.filter((note) => note.startTime >= startSeconds && note.startTime < endSeconds)
|
|
||||||
.length;
|
|
||||||
|
|
||||||
const getNoteKey = (note: PianoNote): string =>
|
|
||||||
[
|
|
||||||
note.startTime.toFixed(3),
|
|
||||||
note.midi,
|
|
||||||
note.role ?? 'none',
|
|
||||||
note.pan.toFixed(3),
|
|
||||||
].join(':');
|
|
||||||
|
|
||||||
describe('GenerativePianoEngine', () => {
|
|
||||||
it('plays quiet background music even when the garden is idle', () => {
|
|
||||||
const { engine, notes } = makeEngine();
|
|
||||||
|
|
||||||
renderBars(engine, 0);
|
|
||||||
|
|
||||||
expect(notes.length).toBeGreaterThan(0);
|
|
||||||
expect(notes.some((note) => note.durationSeconds > getBeatSeconds() * 6)).toBe(true);
|
|
||||||
expect(Math.max(...notes.map((note) => note.velocity))).toBeLessThan(0.12);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the background sparse instead of filling every beat', () => {
|
|
||||||
const { engine, notes } = makeEngine();
|
|
||||||
|
|
||||||
renderBars(engine, 0, 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, 8);
|
|
||||||
renderBars(active.engine, 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('uses style pools with multiple notes instead of one repeating key', () => {
|
|
||||||
const { engine, notes } = makeEngine();
|
|
||||||
|
|
||||||
renderBars(engine, 1, 16);
|
|
||||||
|
|
||||||
expect(new Set(notes.map((note) => note.midi)).size).toBeGreaterThan(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('changes musical style over time without a color change', () => {
|
|
||||||
const { engine, notes } = makeEngine();
|
|
||||||
|
|
||||||
renderBars(engine, 1, 32);
|
|
||||||
|
|
||||||
const styleWindows = [
|
|
||||||
notes.filter((note) => note.startTime >= 0 && note.startTime < 8),
|
|
||||||
notes.filter((note) => note.startTime >= 8 && note.startTime < 16),
|
|
||||||
notes.filter((note) => note.startTime >= 16 && note.startTime < 24),
|
|
||||||
];
|
|
||||||
const averageMidiByWindow = styleWindows.map((windowNotes) =>
|
|
||||||
Math.round(average(windowNotes.map((note) => note.midi)))
|
|
||||||
);
|
|
||||||
const averagePanByWindow = styleWindows.map((windowNotes) =>
|
|
||||||
Number(average(windowNotes.map((note) => note.pan)).toFixed(2))
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(styleWindows.every((windowNotes) => windowNotes.length > 0)).toBe(true);
|
|
||||||
expect(new Set(averageMidiByWindow).size).toBeGreaterThan(1);
|
|
||||||
expect(new Set(averagePanByWindow).size).toBeGreaterThan(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
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,
|
|
||||||
lookaheadSeconds: 12,
|
|
||||||
});
|
|
||||||
|
|
||||||
layered.engine.beginGesture();
|
|
||||||
layered.engine.recordStroke({
|
|
||||||
vibe: VIBE_PRESETS[0],
|
|
||||||
now,
|
|
||||||
activity: 0.85,
|
|
||||||
});
|
|
||||||
layered.engine.renderLookahead({
|
|
||||||
vibe: VIBE_PRESETS[0],
|
|
||||||
now,
|
|
||||||
activity: 0.35,
|
|
||||||
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('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,
|
|
||||||
});
|
|
||||||
engine.recordStroke({
|
|
||||||
vibe: VIBE_PRESETS[0],
|
|
||||||
now: now + 1,
|
|
||||||
activity: 0.95,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(notes).toHaveLength(1);
|
|
||||||
expect(notes[0].startTime).toBe(now);
|
|
||||||
|
|
||||||
engine.recordStroke({
|
|
||||||
vibe: VIBE_PRESETS[0],
|
|
||||||
now: now + 6,
|
|
||||||
activity: 0.95,
|
|
||||||
});
|
|
||||||
|
|
||||||
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, 16);
|
|
||||||
renderBars(second.engine, 0.78, 16);
|
|
||||||
|
|
||||||
expect(second.notes).toEqual(first.notes);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not duplicate notes across overlapping lookahead windows', () => {
|
|
||||||
const { engine, notes } = makeEngine();
|
|
||||||
|
|
||||||
engine.renderLookahead({
|
|
||||||
vibe: VIBE_PRESETS[0],
|
|
||||||
now: 0,
|
|
||||||
activity: 0.72,
|
|
||||||
lookaheadSeconds: getBeatSeconds() * 2,
|
|
||||||
});
|
|
||||||
engine.renderLookahead({
|
|
||||||
vibe: VIBE_PRESETS[0],
|
|
||||||
now: getBeatSeconds() * 0.5,
|
|
||||||
activity: 0.72,
|
|
||||||
lookaheadSeconds: getBeatSeconds() * 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
const noteKeys = notes.map(getNoteKey);
|
|
||||||
expect(new Set(noteKeys).size).toBe(noteKeys.length);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps generated notes inside the audio contract', () => {
|
|
||||||
const { engine, notes } = makeEngine();
|
|
||||||
|
|
||||||
VIBE_PRESETS.forEach((vibe) => {
|
|
||||||
engine.cue(0);
|
|
||||||
engine.renderLookahead({
|
|
||||||
vibe,
|
|
||||||
now: 0,
|
|
||||||
activity: 1,
|
|
||||||
lookaheadSeconds: getBeatSeconds() * getBeatsPerBar() * 4,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
notes.forEach((note) => {
|
|
||||||
expect(Number.isFinite(note.startTime)).toBe(true);
|
|
||||||
expect(note.midi).toBeGreaterThanOrEqual(21);
|
|
||||||
expect(note.midi).toBeLessThanOrEqual(108);
|
|
||||||
expect(note.velocity).toBeGreaterThan(0);
|
|
||||||
expect(note.velocity).toBeLessThanOrEqual(0.4);
|
|
||||||
expect(note.pan).toBeGreaterThanOrEqual(-1);
|
|
||||||
expect(note.pan).toBeLessThanOrEqual(1);
|
|
||||||
expect(note.durationSeconds).toBeGreaterThan(0);
|
|
||||||
expect(note.lowpassHz ?? gardenAudioConfig.piano.lowpassHz).toBeGreaterThanOrEqual(
|
|
||||||
gardenAudioConfig.piano.lowpassMinHz
|
|
||||||
);
|
|
||||||
expect(note.lowpassHz ?? gardenAudioConfig.piano.lowpassHz).toBeLessThanOrEqual(
|
|
||||||
gardenAudioConfig.piano.lowpassMaxHz
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,18 +1,28 @@
|
||||||
import { clamp, clamp01 } from '../utils/clamp';
|
import { clamp, clamp01 } from '../utils/math';
|
||||||
import { VibePreset } from '../vibes';
|
import type { VibePreset } from '../vibes';
|
||||||
import {
|
import type {
|
||||||
GardenAudioChord,
|
GardenAudioChord,
|
||||||
GardenAudioConfig,
|
GardenAudioConfig,
|
||||||
GardenAudioRegister,
|
|
||||||
GardenAudioStylePool,
|
|
||||||
GardenAudioVibeProfile,
|
GardenAudioVibeProfile,
|
||||||
} from './garden-audio-config';
|
} from './garden-audio-config';
|
||||||
import {
|
import {
|
||||||
degreeToSemitone,
|
degreeToSemitone,
|
||||||
getChordIntervals,
|
getChordIntervals,
|
||||||
getVibeProfile,
|
getVibeProfile,
|
||||||
|
PITCH_SEMITONES_PER_OCTAVE,
|
||||||
} from './garden-audio-music';
|
} from './garden-audio-music';
|
||||||
import { PianoNote } from './garden-audio-types';
|
import {
|
||||||
|
GENERATIVE_LOOKAHEAD_SECONDS,
|
||||||
|
GENERATIVE_START_DELAY_SECONDS,
|
||||||
|
PIANO_SCHEDULE_AHEAD_SECONDS,
|
||||||
|
} from './garden-audio-scheduling';
|
||||||
|
import type { PianoNote } from './garden-audio-types';
|
||||||
|
import {
|
||||||
|
generativePianoTuning,
|
||||||
|
styleVoices,
|
||||||
|
type GardenAudioRegister,
|
||||||
|
type GardenAudioStylePool,
|
||||||
|
} from './generative-piano-tuning';
|
||||||
|
|
||||||
type GardenAudioStyleIndex = 0 | 1 | 2;
|
type GardenAudioStyleIndex = 0 | 1 | 2;
|
||||||
|
|
||||||
|
|
@ -87,8 +97,8 @@ export class GenerativePianoEngine {
|
||||||
private readonly playNote: (note: PianoNote) => void
|
private readonly playNote: (note: PianoNote) => void
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private get generation(): GardenAudioConfig['generativePiano'] {
|
private get generation(): typeof generativePianoTuning {
|
||||||
return this.config.generativePiano;
|
return generativePianoTuning;
|
||||||
}
|
}
|
||||||
|
|
||||||
public prime(now: number): void {
|
public prime(now: number): void {
|
||||||
|
|
@ -190,7 +200,7 @@ export class GenerativePianoEngine {
|
||||||
vibe,
|
vibe,
|
||||||
now,
|
now,
|
||||||
activity,
|
activity,
|
||||||
lookaheadSeconds = this.config.rhythm.lookaheadSeconds,
|
lookaheadSeconds = GENERATIVE_LOOKAHEAD_SECONDS,
|
||||||
}: RenderLookaheadRequest): void {
|
}: RenderLookaheadRequest): void {
|
||||||
this.prime(now);
|
this.prime(now);
|
||||||
this.skipLateBeats(now);
|
this.skipLateBeats(now);
|
||||||
|
|
@ -418,7 +428,7 @@ export class GenerativePianoEngine {
|
||||||
velocity:
|
velocity:
|
||||||
(this.generation.supportNote.velocityBase +
|
(this.generation.supportNote.velocityBase +
|
||||||
expression * this.generation.supportNote.velocityExpressionWeight) *
|
expression * this.generation.supportNote.velocityExpressionWeight) *
|
||||||
this.config.styleVoices[styleIndex].velocityMultiplier,
|
styleVoices[styleIndex].velocityMultiplier,
|
||||||
startTime,
|
startTime,
|
||||||
durationSeconds:
|
durationSeconds:
|
||||||
this.generation.supportNote.durationBaseSeconds +
|
this.generation.supportNote.durationBaseSeconds +
|
||||||
|
|
@ -464,7 +474,7 @@ export class GenerativePianoEngine {
|
||||||
velocity:
|
velocity:
|
||||||
(this.generation.textureNote.velocityBase +
|
(this.generation.textureNote.velocityBase +
|
||||||
expression * this.generation.textureNote.velocityExpressionWeight) *
|
expression * this.generation.textureNote.velocityExpressionWeight) *
|
||||||
this.config.styleVoices[styleIndex].velocityMultiplier,
|
styleVoices[styleIndex].velocityMultiplier,
|
||||||
startTime,
|
startTime,
|
||||||
durationSeconds:
|
durationSeconds:
|
||||||
this.generation.textureNote.durationBaseSeconds +
|
this.generation.textureNote.durationBaseSeconds +
|
||||||
|
|
@ -511,7 +521,7 @@ export class GenerativePianoEngine {
|
||||||
velocity:
|
velocity:
|
||||||
(this.generation.gestureAccent.velocityBase +
|
(this.generation.gestureAccent.velocityBase +
|
||||||
strength * this.generation.gestureAccent.velocityStrengthWeight) *
|
strength * this.generation.gestureAccent.velocityStrengthWeight) *
|
||||||
this.config.styleVoices[styleIndex].velocityMultiplier,
|
styleVoices[styleIndex].velocityMultiplier,
|
||||||
startTime,
|
startTime,
|
||||||
durationSeconds:
|
durationSeconds:
|
||||||
this.generation.gestureAccent.durationBaseSeconds +
|
this.generation.gestureAccent.durationBaseSeconds +
|
||||||
|
|
@ -560,7 +570,7 @@ export class GenerativePianoEngine {
|
||||||
velocity:
|
velocity:
|
||||||
(this.generation.touchNote.velocityBase +
|
(this.generation.touchNote.velocityBase +
|
||||||
strength * this.generation.touchNote.velocityStrengthWeight) *
|
strength * this.generation.touchNote.velocityStrengthWeight) *
|
||||||
this.config.styleVoices[styleIndex].velocityMultiplier,
|
styleVoices[styleIndex].velocityMultiplier,
|
||||||
startTime: now,
|
startTime: now,
|
||||||
durationSeconds:
|
durationSeconds:
|
||||||
this.generation.touchNote.durationBaseSeconds +
|
this.generation.touchNote.durationBaseSeconds +
|
||||||
|
|
@ -661,7 +671,7 @@ export class GenerativePianoEngine {
|
||||||
lookaheadEnd: number;
|
lookaheadEnd: number;
|
||||||
activity: number;
|
activity: number;
|
||||||
}): void {
|
}): void {
|
||||||
const earliestStart = now + this.config.piano.scheduleAheadSeconds;
|
const earliestStart = now + PIANO_SCHEDULE_AHEAD_SECONDS;
|
||||||
this.nextBrushStreamStep ??= 0;
|
this.nextBrushStreamStep ??= 0;
|
||||||
this.pruneBrushStreamNoteCounts(this.getGlobalBarIndex(now) - 1);
|
this.pruneBrushStreamNoteCounts(this.getGlobalBarIndex(now) - 1);
|
||||||
|
|
||||||
|
|
@ -770,7 +780,7 @@ export class GenerativePianoEngine {
|
||||||
velocity:
|
velocity:
|
||||||
(this.generation.brushStream.velocityBase +
|
(this.generation.brushStream.velocityBase +
|
||||||
intensity * this.generation.brushStream.velocityIntensityWeight) *
|
intensity * this.generation.brushStream.velocityIntensityWeight) *
|
||||||
this.config.styleVoices[styleIndex].velocityMultiplier,
|
styleVoices[styleIndex].velocityMultiplier,
|
||||||
startTime,
|
startTime,
|
||||||
durationSeconds,
|
durationSeconds,
|
||||||
pan,
|
pan,
|
||||||
|
|
@ -803,7 +813,7 @@ export class GenerativePianoEngine {
|
||||||
velocity:
|
velocity:
|
||||||
(this.generation.brushStreamEcho.velocityBase +
|
(this.generation.brushStreamEcho.velocityBase +
|
||||||
intensity * this.generation.brushStreamEcho.velocityIntensityWeight) *
|
intensity * this.generation.brushStreamEcho.velocityIntensityWeight) *
|
||||||
this.config.styleVoices[styleIndex].velocityMultiplier,
|
styleVoices[styleIndex].velocityMultiplier,
|
||||||
startTime: startTime + this.generation.brushMotifCanonDelaySeconds,
|
startTime: startTime + this.generation.brushMotifCanonDelaySeconds,
|
||||||
durationSeconds: Math.max(
|
durationSeconds: Math.max(
|
||||||
this.generation.brushStreamEcho.durationMinSeconds,
|
this.generation.brushStreamEcho.durationMinSeconds,
|
||||||
|
|
@ -913,7 +923,7 @@ export class GenerativePianoEngine {
|
||||||
pool: GardenAudioStylePool;
|
pool: GardenAudioStylePool;
|
||||||
styleIndex: GardenAudioStyleIndex;
|
styleIndex: GardenAudioStyleIndex;
|
||||||
}): Array<number> {
|
}): Array<number> {
|
||||||
const styleOffset = this.config.styleVoices[styleIndex].scaleDegreeOffset;
|
const styleOffset = styleVoices[styleIndex].scaleDegreeOffset;
|
||||||
if (!layer || layer.motifOffsets.length === 0) {
|
if (!layer || layer.motifOffsets.length === 0) {
|
||||||
return this.rotate(pool.scaleDegrees, this.brushStreamNoteIndex + styleOffset);
|
return this.rotate(pool.scaleDegrees, this.brushStreamNoteIndex + styleOffset);
|
||||||
}
|
}
|
||||||
|
|
@ -990,10 +1000,7 @@ export class GenerativePianoEngine {
|
||||||
octave <= this.generation.candidateOctaveSearch.max;
|
octave <= this.generation.candidateOctaveSearch.max;
|
||||||
octave += 1
|
octave += 1
|
||||||
) {
|
) {
|
||||||
const midi =
|
const midi = pitchSource.baseMidi + offset + octave * PITCH_SEMITONES_PER_OCTAVE;
|
||||||
pitchSource.baseMidi +
|
|
||||||
offset +
|
|
||||||
octave * this.config.piano.pitchSemitonesPerOctave;
|
|
||||||
if (midi >= register.midiMin && midi <= register.midiMax) {
|
if (midi >= register.midiMin && midi <= register.midiMax) {
|
||||||
const roundedMidi = Math.round(midi);
|
const roundedMidi = Math.round(midi);
|
||||||
candidates.push({
|
candidates.push({
|
||||||
|
|
@ -1080,7 +1087,7 @@ export class GenerativePianoEngine {
|
||||||
|
|
||||||
private getStylePan(styleIndex: GardenAudioStyleIndex): number {
|
private getStylePan(styleIndex: GardenAudioStyleIndex): number {
|
||||||
const pool = this.generation.stylePools[styleIndex];
|
const pool = this.generation.stylePools[styleIndex];
|
||||||
const styleVoice = this.config.styleVoices[styleIndex];
|
const styleVoice = styleVoices[styleIndex];
|
||||||
return clamp(
|
return clamp(
|
||||||
pool.pan + styleVoice.panOffset * this.generation.stylePanOffsetScale,
|
pool.pan + styleVoice.panOffset * this.generation.stylePanOffsetScale,
|
||||||
-1,
|
-1,
|
||||||
|
|
@ -1113,7 +1120,7 @@ export class GenerativePianoEngine {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const earliestStart = now + this.config.piano.scheduleAheadSeconds;
|
const earliestStart = now + PIANO_SCHEDULE_AHEAD_SECONDS;
|
||||||
if (this.getTimeForStep(this.nextBeatStep) >= earliestStart) {
|
if (this.getTimeForStep(this.nextBeatStep) >= earliestStart) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1152,7 +1159,7 @@ export class GenerativePianoEngine {
|
||||||
private getTimeForStep(stepIndex: number): number {
|
private getTimeForStep(stepIndex: number): number {
|
||||||
return (
|
return (
|
||||||
(this.timelineStartedAt ?? 0) +
|
(this.timelineStartedAt ?? 0) +
|
||||||
this.config.startDelaySeconds +
|
GENERATIVE_START_DELAY_SECONDS +
|
||||||
stepIndex * this.getStepDurationSeconds()
|
stepIndex * this.getStepDurationSeconds()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1161,7 +1168,7 @@ export class GenerativePianoEngine {
|
||||||
const timelineStartedAt = this.timelineStartedAt ?? startTime;
|
const timelineStartedAt = this.timelineStartedAt ?? startTime;
|
||||||
const elapsedSeconds = Math.max(
|
const elapsedSeconds = Math.max(
|
||||||
0,
|
0,
|
||||||
startTime - timelineStartedAt - this.config.startDelaySeconds
|
startTime - timelineStartedAt - GENERATIVE_START_DELAY_SECONDS
|
||||||
);
|
);
|
||||||
return Math.floor(elapsedSeconds / this.getStepDurationSeconds());
|
return Math.floor(elapsedSeconds / this.getStepDurationSeconds());
|
||||||
}
|
}
|
||||||
|
|
@ -1170,7 +1177,7 @@ export class GenerativePianoEngine {
|
||||||
const timelineStartedAt = this.timelineStartedAt ?? startTime;
|
const timelineStartedAt = this.timelineStartedAt ?? startTime;
|
||||||
const elapsedSeconds = Math.max(
|
const elapsedSeconds = Math.max(
|
||||||
0,
|
0,
|
||||||
startTime - timelineStartedAt - this.config.startDelaySeconds
|
startTime - timelineStartedAt - GENERATIVE_START_DELAY_SECONDS
|
||||||
);
|
);
|
||||||
return Math.max(
|
return Math.max(
|
||||||
0,
|
0,
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,18 @@
|
||||||
import type { GardenAudioConfig } from './garden-audio-config';
|
import { createAudioPanNode } from './audio-pan-node';
|
||||||
import { GardenAudioGraph } from './garden-audio-graph';
|
import type { GardenAudioGraph } from './garden-audio-graph';
|
||||||
import { NoiseBurst } from './garden-audio-types';
|
import type { NoiseBurst } from './garden-audio-types';
|
||||||
|
|
||||||
|
const noiseBurstTuning = {
|
||||||
|
attackSeconds: 0.004,
|
||||||
|
filterQ: 1.4,
|
||||||
|
offsetRandomSeconds: 0.4,
|
||||||
|
scheduleAheadSeconds: 0.002,
|
||||||
|
silentGain: 0.0001,
|
||||||
|
filterType: 'bandpass' as BiquadFilterType,
|
||||||
|
};
|
||||||
|
|
||||||
export class NoiseBurstPlayer {
|
export class NoiseBurstPlayer {
|
||||||
public constructor(
|
public constructor(private readonly graph: GardenAudioGraph) {}
|
||||||
private readonly config: GardenAudioConfig,
|
|
||||||
private readonly graph: GardenAudioGraph
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public play({ startTime, durationSeconds, gain, filterHz, pan }: NoiseBurst): void {
|
public play({ startTime, durationSeconds, gain, filterHz, pan }: NoiseBurst): void {
|
||||||
const { context, eventBus, noiseBus, noiseBuffer } = this.graph;
|
const { context, eventBus, noiseBus, noiseBuffer } = this.graph;
|
||||||
|
|
@ -16,35 +22,30 @@ export class NoiseBurstPlayer {
|
||||||
}
|
}
|
||||||
|
|
||||||
const scheduledStart = Math.max(
|
const scheduledStart = Math.max(
|
||||||
context.currentTime + this.config.noiseBurst.scheduleAheadSeconds,
|
context.currentTime + noiseBurstTuning.scheduleAheadSeconds,
|
||||||
startTime
|
startTime
|
||||||
);
|
);
|
||||||
const source = context.createBufferSource();
|
const source = context.createBufferSource();
|
||||||
const filter = context.createBiquadFilter();
|
const filter = context.createBiquadFilter();
|
||||||
const envelope = context.createGain();
|
const envelope = context.createGain();
|
||||||
const panner = context.createStereoPanner();
|
const panNode = createAudioPanNode(context, pan, scheduledStart);
|
||||||
const stopAt = scheduledStart + durationSeconds;
|
const stopAt = scheduledStart + durationSeconds;
|
||||||
|
|
||||||
source.buffer = noiseBuffer;
|
source.buffer = noiseBuffer;
|
||||||
filter.type = this.config.noiseBurst.filterType;
|
filter.type = noiseBurstTuning.filterType;
|
||||||
filter.frequency.setValueAtTime(filterHz, scheduledStart);
|
filter.frequency.setValueAtTime(filterHz, scheduledStart);
|
||||||
filter.Q.value = this.config.noiseBurst.filterQ;
|
filter.Q.value = noiseBurstTuning.filterQ;
|
||||||
envelope.gain.setValueAtTime(this.config.noiseBurst.silentGain, scheduledStart);
|
envelope.gain.setValueAtTime(noiseBurstTuning.silentGain, scheduledStart);
|
||||||
envelope.gain.exponentialRampToValueAtTime(
|
envelope.gain.exponentialRampToValueAtTime(
|
||||||
Math.max(this.config.noiseBurst.silentGain, gain),
|
Math.max(noiseBurstTuning.silentGain, gain),
|
||||||
scheduledStart + this.config.noiseBurst.attackSeconds
|
scheduledStart + noiseBurstTuning.attackSeconds
|
||||||
);
|
);
|
||||||
envelope.gain.exponentialRampToValueAtTime(this.config.noiseBurst.silentGain, stopAt);
|
envelope.gain.exponentialRampToValueAtTime(noiseBurstTuning.silentGain, stopAt);
|
||||||
panner.pan.setValueAtTime(pan, scheduledStart);
|
|
||||||
|
|
||||||
source.connect(filter);
|
source.connect(filter);
|
||||||
filter.connect(envelope);
|
filter.connect(envelope);
|
||||||
envelope.connect(panner);
|
envelope.connect(panNode.input);
|
||||||
panner.connect(outputBus);
|
panNode.output.connect(outputBus);
|
||||||
source.start(
|
source.start(scheduledStart, Math.random() * noiseBurstTuning.offsetRandomSeconds);
|
||||||
scheduledStart,
|
|
||||||
Math.random() * this.config.noiseBurst.offsetRandomSeconds
|
|
||||||
);
|
|
||||||
source.stop(stopAt);
|
source.stop(stopAt);
|
||||||
source.addEventListener(
|
source.addEventListener(
|
||||||
'ended',
|
'ended',
|
||||||
|
|
@ -52,7 +53,7 @@ export class NoiseBurstPlayer {
|
||||||
source.disconnect();
|
source.disconnect();
|
||||||
filter.disconnect();
|
filter.disconnect();
|
||||||
envelope.disconnect();
|
envelope.disconnect();
|
||||||
panner.disconnect();
|
panNode.disconnect();
|
||||||
},
|
},
|
||||||
{ once: true }
|
{ once: true }
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,167 +0,0 @@
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
import { gardenAudioConfig } from './garden-audio-config';
|
|
||||||
import type { GardenAudioGraph } from './garden-audio-graph';
|
|
||||||
import type { PianoSampler } from './piano-sampler';
|
|
||||||
|
|
||||||
const calls = {
|
|
||||||
bufferSourcesStarted: 0,
|
|
||||||
};
|
|
||||||
const sampleCount = 30;
|
|
||||||
|
|
||||||
class FakeAudioParam {
|
|
||||||
public value = 0;
|
|
||||||
public setTargetAtTime = vi.fn();
|
|
||||||
public setValueAtTime = vi.fn();
|
|
||||||
public exponentialRampToValueAtTime = vi.fn();
|
|
||||||
public cancelScheduledValues = vi.fn();
|
|
||||||
}
|
|
||||||
|
|
||||||
class FakeAudioNode {
|
|
||||||
public readonly gain = new FakeAudioParam();
|
|
||||||
public readonly frequency = new FakeAudioParam();
|
|
||||||
public readonly playbackRate = new FakeAudioParam();
|
|
||||||
public readonly Q = new FakeAudioParam();
|
|
||||||
public readonly pan = new FakeAudioParam();
|
|
||||||
public buffer: AudioBuffer | null = null;
|
|
||||||
public type = '';
|
|
||||||
public addEventListener = vi.fn();
|
|
||||||
public connect = vi.fn();
|
|
||||||
public disconnect = vi.fn();
|
|
||||||
public start = vi.fn();
|
|
||||||
public stop = vi.fn();
|
|
||||||
}
|
|
||||||
|
|
||||||
class FakeAudioContext {
|
|
||||||
public readonly currentTime = 1;
|
|
||||||
public readonly decodeAudioData = vi.fn(async () => ({}) as AudioBuffer);
|
|
||||||
|
|
||||||
public createGain(): GainNode {
|
|
||||||
return new FakeAudioNode() as unknown as GainNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
public createBiquadFilter(): BiquadFilterNode {
|
|
||||||
return new FakeAudioNode() as unknown as BiquadFilterNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
public createStereoPanner(): StereoPannerNode {
|
|
||||||
return new FakeAudioNode() as unknown as StereoPannerNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
public createBufferSource(): AudioBufferSourceNode {
|
|
||||||
const node = new FakeAudioNode() as unknown as AudioBufferSourceNode & {
|
|
||||||
start: () => void;
|
|
||||||
stop: () => void;
|
|
||||||
};
|
|
||||||
node.start = vi.fn(() => {
|
|
||||||
calls.bufferSourcesStarted += 1;
|
|
||||||
});
|
|
||||||
node.stop = vi.fn();
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const makeSampler = async (context: AudioContext): Promise<PianoSampler> => {
|
|
||||||
const { PianoSampler } = await import('./piano-sampler');
|
|
||||||
const eventBus = new FakeAudioNode() as unknown as GainNode;
|
|
||||||
const graph = {
|
|
||||||
context,
|
|
||||||
delayInput: null,
|
|
||||||
eventBus,
|
|
||||||
getPianoBus: vi.fn(() => eventBus),
|
|
||||||
} as unknown as GardenAudioGraph;
|
|
||||||
|
|
||||||
return new PianoSampler(gardenAudioConfig, graph);
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('PianoSampler', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
calls.bufferSourcesStarted = 0;
|
|
||||||
vi.resetModules();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('loads every piano sample before playback', async () => {
|
|
||||||
const context = new FakeAudioContext() as unknown as AudioContext;
|
|
||||||
const sampler = await makeSampler(context);
|
|
||||||
const fetch = vi.fn(async () => {
|
|
||||||
return {
|
|
||||||
arrayBuffer: async () => new ArrayBuffer(8),
|
|
||||||
ok: true,
|
|
||||||
} as Response;
|
|
||||||
});
|
|
||||||
vi.stubGlobal('fetch', fetch);
|
|
||||||
|
|
||||||
await sampler.load(context);
|
|
||||||
sampler.play({
|
|
||||||
durationSeconds: 0.2,
|
|
||||||
midi: 60,
|
|
||||||
pan: 0,
|
|
||||||
startTime: context.currentTime,
|
|
||||||
velocity: 0.5,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(fetch).toHaveBeenCalledTimes(sampleCount);
|
|
||||||
expect(context.decodeAudioData).toHaveBeenCalledTimes(sampleCount);
|
|
||||||
expect(calls.bufferSourcesStarted).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('only queues a piano load when the sampler is idle', async () => {
|
|
||||||
const context = new FakeAudioContext() as unknown as AudioContext;
|
|
||||||
const sampler = await makeSampler(context);
|
|
||||||
const fetch = vi.fn(async () => {
|
|
||||||
return {
|
|
||||||
arrayBuffer: async () => new ArrayBuffer(8),
|
|
||||||
ok: true,
|
|
||||||
} as Response;
|
|
||||||
});
|
|
||||||
vi.stubGlobal('fetch', fetch);
|
|
||||||
|
|
||||||
const firstLoad = sampler.loadIfIdle(context);
|
|
||||||
const secondLoad = sampler.loadIfIdle(context);
|
|
||||||
|
|
||||||
expect(firstLoad).toBeInstanceOf(Promise);
|
|
||||||
expect(secondLoad).toBeNull();
|
|
||||||
|
|
||||||
await firstLoad;
|
|
||||||
|
|
||||||
expect(sampler.loadIfIdle(context)).toBeNull();
|
|
||||||
expect(fetch).toHaveBeenCalledTimes(sampleCount);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('allows loading to be retried after a load failure', async () => {
|
|
||||||
const context = new FakeAudioContext() as unknown as AudioContext;
|
|
||||||
const sampler = await makeSampler(context);
|
|
||||||
const fetch = vi
|
|
||||||
.fn()
|
|
||||||
.mockRejectedValueOnce(new Error('load failed'))
|
|
||||||
.mockResolvedValue({
|
|
||||||
arrayBuffer: async () => new ArrayBuffer(8),
|
|
||||||
ok: true,
|
|
||||||
} as Response);
|
|
||||||
vi.stubGlobal('fetch', fetch);
|
|
||||||
|
|
||||||
await expect(sampler.loadIfIdle(context)).rejects.toThrow('load failed');
|
|
||||||
await expect(sampler.loadIfIdle(context)).resolves.toBeUndefined();
|
|
||||||
|
|
||||||
expect(fetch).toHaveBeenCalledTimes(sampleCount * 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stays silent when no decoded sample is available', () => {
|
|
||||||
const context = new FakeAudioContext() as unknown as AudioContext;
|
|
||||||
return makeSampler(context).then((sampler) => {
|
|
||||||
sampler.play({
|
|
||||||
durationSeconds: 0.2,
|
|
||||||
midi: 60,
|
|
||||||
pan: 0,
|
|
||||||
startTime: context.currentTime,
|
|
||||||
velocity: 0.5,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(calls.bufferSourcesStarted).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,11 +1,32 @@
|
||||||
import { clamp, clamp01 } from '../utils/clamp';
|
import { clamp, clamp01 } from '../utils/math';
|
||||||
import { GardenAudioConfig } from './garden-audio-config';
|
import { createAudioPanNode } from './audio-pan-node';
|
||||||
import { GardenAudioGraph } from './garden-audio-graph';
|
import type { GardenAudioConfig } from './garden-audio-config';
|
||||||
import { ActivePianoVoice, LoadedPianoSample, PianoNote } from './garden-audio-types';
|
import type { GardenAudioGraph } from './garden-audio-graph';
|
||||||
|
import { PITCH_SEMITONES_PER_OCTAVE } from './garden-audio-music';
|
||||||
|
import { PIANO_SCHEDULE_AHEAD_SECONDS } from './garden-audio-scheduling';
|
||||||
|
import type {
|
||||||
|
ActivePianoVoice,
|
||||||
|
LoadedPianoSample,
|
||||||
|
PianoNote,
|
||||||
|
} from './garden-audio-types';
|
||||||
import { getLoadedPianoSamples, loadPianoSamples } from './piano-samples';
|
import { getLoadedPianoSamples, loadPianoSamples } from './piano-samples';
|
||||||
|
|
||||||
type PianoLoadState = 'idle' | 'loading' | 'loaded';
|
type PianoLoadState = 'idle' | 'loading' | 'loaded';
|
||||||
|
|
||||||
|
const pianoSamplerTuning = {
|
||||||
|
filterType: 'lowpass' as BiquadFilterType,
|
||||||
|
filterQ: 0.7,
|
||||||
|
minDurationSeconds: 0.08,
|
||||||
|
minFadeSeconds: 0.08,
|
||||||
|
minGain: 0.0001,
|
||||||
|
synthGainScale: 0.34,
|
||||||
|
synthMaxDurationSeconds: 1.8,
|
||||||
|
synthOscillatorType: 'triangle' as OscillatorType,
|
||||||
|
tailStopExtraSeconds: 0.05,
|
||||||
|
voiceStealFadeSeconds: 0.025,
|
||||||
|
voiceStealStopSeconds: 0.05,
|
||||||
|
};
|
||||||
|
|
||||||
export class PianoSampler {
|
export class PianoSampler {
|
||||||
private loadState: PianoLoadState = 'idle';
|
private loadState: PianoLoadState = 'idle';
|
||||||
private sampleLoadPromise: Promise<void> | null = null;
|
private sampleLoadPromise: Promise<void> | null = null;
|
||||||
|
|
@ -34,7 +55,9 @@ export class PianoSampler {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.loadState = 'loading';
|
this.loadState = 'loading';
|
||||||
this.sampleLoadPromise = loadPianoSamples(context)
|
this.sampleLoadPromise = loadPianoSamples(context, undefined, {
|
||||||
|
forceReload: true,
|
||||||
|
})
|
||||||
.then((samples) => {
|
.then((samples) => {
|
||||||
this.setSamples(samples);
|
this.setSamples(samples);
|
||||||
this.loadState = 'loaded';
|
this.loadState = 'loaded';
|
||||||
|
|
@ -74,16 +97,26 @@ export class PianoSampler {
|
||||||
|
|
||||||
const sample = this.findNearestSample(midi);
|
const sample = this.findNearestSample(midi);
|
||||||
if (!sample) {
|
if (!sample) {
|
||||||
|
this.playSynthFallback({
|
||||||
|
midi,
|
||||||
|
velocity,
|
||||||
|
startTime,
|
||||||
|
durationSeconds,
|
||||||
|
pan,
|
||||||
|
role,
|
||||||
|
delaySend,
|
||||||
|
lowpassHz,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const scheduledStart = Math.max(
|
const scheduledStart = Math.max(
|
||||||
context.currentTime + this.config.piano.scheduleAheadSeconds,
|
context.currentTime + PIANO_SCHEDULE_AHEAD_SECONDS,
|
||||||
startTime
|
startTime
|
||||||
);
|
);
|
||||||
const noteVelocity = clamp01(velocity);
|
const noteVelocity = clamp01(velocity);
|
||||||
const noteGainValue = Math.max(
|
const noteGainValue = Math.max(
|
||||||
this.config.piano.minGain,
|
pianoSamplerTuning.minGain,
|
||||||
this.config.piano.gain * noteVelocity
|
this.config.piano.gain * noteVelocity
|
||||||
);
|
);
|
||||||
const sustainSeconds =
|
const sustainSeconds =
|
||||||
|
|
@ -91,14 +124,14 @@ export class PianoSampler {
|
||||||
(this.config.piano.sustainBase +
|
(this.config.piano.sustainBase +
|
||||||
noteVelocity * this.config.piano.sustainVelocityRange);
|
noteVelocity * this.config.piano.sustainVelocityRange);
|
||||||
const sustainAt =
|
const sustainAt =
|
||||||
scheduledStart + Math.max(this.config.piano.minDurationSeconds, durationSeconds);
|
scheduledStart + Math.max(pianoSamplerTuning.minDurationSeconds, durationSeconds);
|
||||||
const releaseAt = sustainAt + sustainSeconds;
|
const releaseAt = sustainAt + sustainSeconds;
|
||||||
const releaseSeconds = this.config.piano.releaseSeconds;
|
const releaseSeconds = this.config.piano.releaseSeconds;
|
||||||
const stopAt = releaseAt + releaseSeconds;
|
const stopAt = releaseAt + releaseSeconds;
|
||||||
const source = context.createBufferSource();
|
const source = context.createBufferSource();
|
||||||
const filter = context.createBiquadFilter();
|
const filter = context.createBiquadFilter();
|
||||||
const gain = context.createGain();
|
const gain = context.createGain();
|
||||||
const panner = context.createStereoPanner();
|
const panNode = createAudioPanNode(context, pan, scheduledStart);
|
||||||
let sendGain: GainNode | null = null;
|
let sendGain: GainNode | null = null;
|
||||||
|
|
||||||
this.trimActiveVoices(scheduledStart);
|
this.trimActiveVoices(scheduledStart);
|
||||||
|
|
@ -111,45 +144,46 @@ export class PianoSampler {
|
||||||
|
|
||||||
source.buffer = sample.buffer;
|
source.buffer = sample.buffer;
|
||||||
source.playbackRate.setValueAtTime(
|
source.playbackRate.setValueAtTime(
|
||||||
Math.pow(2, (midi - sample.midi) / this.config.piano.pitchSemitonesPerOctave),
|
Math.pow(2, (midi - sample.midi) / PITCH_SEMITONES_PER_OCTAVE),
|
||||||
scheduledStart
|
scheduledStart
|
||||||
);
|
);
|
||||||
filter.type = this.config.piano.filterType;
|
filter.type = pianoSamplerTuning.filterType;
|
||||||
filter.frequency.setValueAtTime(
|
filter.frequency.setValueAtTime(
|
||||||
clamp(lowpassHz, this.config.piano.lowpassMinHz, this.config.piano.lowpassMaxHz),
|
clamp(lowpassHz, this.config.piano.lowpassMinHz, this.config.piano.lowpassMaxHz),
|
||||||
scheduledStart
|
scheduledStart
|
||||||
);
|
);
|
||||||
filter.Q.value = this.config.piano.filterQ;
|
filter.Q.value = pianoSamplerTuning.filterQ;
|
||||||
gain.gain.setValueAtTime(this.config.piano.minGain, scheduledStart);
|
gain.gain.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart);
|
||||||
gain.gain.exponentialRampToValueAtTime(
|
gain.gain.exponentialRampToValueAtTime(
|
||||||
noteGainValue,
|
noteGainValue,
|
||||||
scheduledStart + this.config.piano.gainAttackSeconds
|
scheduledStart + this.config.piano.gainAttackSeconds
|
||||||
);
|
);
|
||||||
gain.gain.setTargetAtTime(
|
gain.gain.setTargetAtTime(
|
||||||
Math.max(this.config.piano.minGain, noteGainValue * this.config.piano.sustainLevel),
|
Math.max(
|
||||||
|
pianoSamplerTuning.minGain,
|
||||||
|
noteGainValue * this.config.piano.sustainLevel
|
||||||
|
),
|
||||||
sustainAt,
|
sustainAt,
|
||||||
Math.max(
|
Math.max(
|
||||||
this.config.piano.minFadeSeconds,
|
pianoSamplerTuning.minFadeSeconds,
|
||||||
sustainSeconds * this.config.piano.sustainBase
|
sustainSeconds * this.config.piano.sustainBase
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
gain.gain.setTargetAtTime(this.config.piano.minGain, releaseAt, releaseSeconds);
|
gain.gain.setTargetAtTime(pianoSamplerTuning.minGain, releaseAt, releaseSeconds);
|
||||||
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
|
|
||||||
|
|
||||||
source.connect(filter);
|
source.connect(filter);
|
||||||
filter.connect(gain);
|
filter.connect(gain);
|
||||||
gain.connect(panner);
|
gain.connect(panNode.input);
|
||||||
panner.connect(eventBus);
|
panNode.output.connect(eventBus);
|
||||||
|
|
||||||
if (delayInput && delaySend > 0) {
|
if (delayInput && delaySend > 0) {
|
||||||
sendGain = context.createGain();
|
sendGain = context.createGain();
|
||||||
sendGain.gain.value = delaySend;
|
sendGain.gain.value = delaySend;
|
||||||
panner.connect(sendGain);
|
panNode.output.connect(sendGain);
|
||||||
sendGain.connect(delayInput);
|
sendGain.connect(delayInput);
|
||||||
}
|
}
|
||||||
|
|
||||||
source.start(scheduledStart);
|
source.start(scheduledStart);
|
||||||
source.stop(stopAt + this.config.piano.tailStopExtraSeconds);
|
source.stop(stopAt + pianoSamplerTuning.tailStopExtraSeconds);
|
||||||
this.activeVoices.push({ gain, source, stopAt });
|
this.activeVoices.push({ gain, source, stopAt });
|
||||||
|
|
||||||
source.addEventListener(
|
source.addEventListener(
|
||||||
|
|
@ -158,7 +192,7 @@ export class PianoSampler {
|
||||||
source.disconnect();
|
source.disconnect();
|
||||||
filter.disconnect();
|
filter.disconnect();
|
||||||
gain.disconnect();
|
gain.disconnect();
|
||||||
panner.disconnect();
|
panNode.disconnect();
|
||||||
sendGain?.disconnect();
|
sendGain?.disconnect();
|
||||||
this.activeVoices = this.activeVoices.filter((voice) => voice.gain !== gain);
|
this.activeVoices = this.activeVoices.filter((voice) => voice.gain !== gain);
|
||||||
},
|
},
|
||||||
|
|
@ -181,6 +215,22 @@ export class PianoSampler {
|
||||||
this.activeVoices = [];
|
this.activeVoices = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public fadeAll(fadeSeconds: number): void {
|
||||||
|
const context = this.graph.context;
|
||||||
|
if (!context) {
|
||||||
|
this.activeVoices = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = context.currentTime;
|
||||||
|
const fadeDurationSeconds = Math.max(pianoSamplerTuning.minFadeSeconds, fadeSeconds);
|
||||||
|
|
||||||
|
this.trimActiveVoices(now);
|
||||||
|
this.activeVoices.forEach((voice) => {
|
||||||
|
this.fadeVoice(voice, now, fadeDurationSeconds);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public reset(): void {
|
public reset(): void {
|
||||||
this.loadState = 'idle';
|
this.loadState = 'idle';
|
||||||
this.sampleLoadPromise = null;
|
this.sampleLoadPromise = null;
|
||||||
|
|
@ -198,18 +248,114 @@ export class PianoSampler {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private playSynthFallback({
|
||||||
|
midi,
|
||||||
|
velocity,
|
||||||
|
startTime,
|
||||||
|
durationSeconds,
|
||||||
|
pan,
|
||||||
|
role,
|
||||||
|
delaySend = 0,
|
||||||
|
lowpassHz = this.config.piano.lowpassHz,
|
||||||
|
}: PianoNote): void {
|
||||||
|
const { context, delayInput } = this.graph;
|
||||||
|
const eventBus = this.graph.getPianoBus(role);
|
||||||
|
if (!context || !eventBus) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduledStart = Math.max(
|
||||||
|
context.currentTime + PIANO_SCHEDULE_AHEAD_SECONDS,
|
||||||
|
startTime
|
||||||
|
);
|
||||||
|
const noteVelocity = clamp01(velocity);
|
||||||
|
const noteGainValue = Math.max(
|
||||||
|
pianoSamplerTuning.minGain,
|
||||||
|
this.config.piano.gain * noteVelocity * pianoSamplerTuning.synthGainScale
|
||||||
|
);
|
||||||
|
const releaseAt =
|
||||||
|
scheduledStart +
|
||||||
|
clamp(
|
||||||
|
durationSeconds + this.config.piano.sustainSeconds * 0.5,
|
||||||
|
pianoSamplerTuning.minDurationSeconds,
|
||||||
|
pianoSamplerTuning.synthMaxDurationSeconds
|
||||||
|
);
|
||||||
|
const stopAt = releaseAt + this.config.piano.releaseSeconds;
|
||||||
|
const source = context.createOscillator();
|
||||||
|
const filter = context.createBiquadFilter();
|
||||||
|
const gain = context.createGain();
|
||||||
|
const panNode = createAudioPanNode(context, pan, scheduledStart);
|
||||||
|
let sendGain: GainNode | null = null;
|
||||||
|
|
||||||
|
this.trimActiveVoices(scheduledStart);
|
||||||
|
while (this.activeVoices.length >= this.config.piano.maxVoices) {
|
||||||
|
const oldest = this.activeVoices.shift();
|
||||||
|
if (oldest) {
|
||||||
|
this.stopVoice(oldest, scheduledStart);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
source.type = pianoSamplerTuning.synthOscillatorType;
|
||||||
|
source.frequency.setValueAtTime(getMidiFrequency(midi), scheduledStart);
|
||||||
|
filter.type = pianoSamplerTuning.filterType;
|
||||||
|
filter.frequency.setValueAtTime(
|
||||||
|
clamp(lowpassHz, this.config.piano.lowpassMinHz, this.config.piano.lowpassMaxHz),
|
||||||
|
scheduledStart
|
||||||
|
);
|
||||||
|
filter.Q.value = pianoSamplerTuning.filterQ;
|
||||||
|
gain.gain.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(
|
||||||
|
noteGainValue,
|
||||||
|
scheduledStart + this.config.piano.gainAttackSeconds
|
||||||
|
);
|
||||||
|
gain.gain.setTargetAtTime(
|
||||||
|
pianoSamplerTuning.minGain,
|
||||||
|
releaseAt,
|
||||||
|
this.config.piano.releaseSeconds
|
||||||
|
);
|
||||||
|
|
||||||
|
source.connect(filter);
|
||||||
|
filter.connect(gain);
|
||||||
|
gain.connect(panNode.input);
|
||||||
|
panNode.output.connect(eventBus);
|
||||||
|
|
||||||
|
if (delayInput && delaySend > 0) {
|
||||||
|
sendGain = context.createGain();
|
||||||
|
sendGain.gain.value = delaySend;
|
||||||
|
panNode.output.connect(sendGain);
|
||||||
|
sendGain.connect(delayInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
source.start(scheduledStart);
|
||||||
|
source.stop(stopAt + pianoSamplerTuning.tailStopExtraSeconds);
|
||||||
|
this.activeVoices.push({ gain, source, stopAt });
|
||||||
|
|
||||||
|
source.addEventListener(
|
||||||
|
'ended',
|
||||||
|
() => {
|
||||||
|
source.disconnect();
|
||||||
|
filter.disconnect();
|
||||||
|
gain.disconnect();
|
||||||
|
panNode.disconnect();
|
||||||
|
sendGain?.disconnect();
|
||||||
|
this.activeVoices = this.activeVoices.filter((voice) => voice.gain !== gain);
|
||||||
|
},
|
||||||
|
{ once: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private trimActiveVoices(now: number): void {
|
private trimActiveVoices(now: number): void {
|
||||||
this.activeVoices = this.activeVoices.filter((voice) => voice.stopAt > now);
|
this.activeVoices = this.activeVoices.filter((voice) => voice.stopAt > now);
|
||||||
}
|
}
|
||||||
|
|
||||||
private stopVoice(voice: ActivePianoVoice, now: number): void {
|
private stopVoice(voice: ActivePianoVoice, now: number): void {
|
||||||
const stopAt = now + this.config.piano.voiceStealStopSeconds;
|
const stopAt = now + pianoSamplerTuning.voiceStealStopSeconds;
|
||||||
|
|
||||||
voice.gain.gain.cancelScheduledValues(now);
|
voice.gain.gain.cancelScheduledValues(now);
|
||||||
voice.gain.gain.setTargetAtTime(
|
voice.gain.gain.setTargetAtTime(
|
||||||
this.config.piano.minGain,
|
pianoSamplerTuning.minGain,
|
||||||
now,
|
now,
|
||||||
this.config.piano.voiceStealFadeSeconds
|
pianoSamplerTuning.voiceStealFadeSeconds
|
||||||
);
|
);
|
||||||
voice.stopAt = stopAt;
|
voice.stopAt = stopAt;
|
||||||
try {
|
try {
|
||||||
|
|
@ -219,7 +365,31 @@ export class PianoSampler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fadeVoice(
|
||||||
|
voice: ActivePianoVoice,
|
||||||
|
now: number,
|
||||||
|
fadeDurationSeconds: number
|
||||||
|
): void {
|
||||||
|
const stopAt = Math.min(voice.stopAt, now + fadeDurationSeconds);
|
||||||
|
|
||||||
|
voice.gain.gain.cancelScheduledValues(now);
|
||||||
|
voice.gain.gain.setTargetAtTime(
|
||||||
|
pianoSamplerTuning.minGain,
|
||||||
|
now,
|
||||||
|
Math.max(0.001, fadeDurationSeconds / 4)
|
||||||
|
);
|
||||||
|
voice.stopAt = stopAt;
|
||||||
|
try {
|
||||||
|
voice.source.stop(stopAt + pianoSamplerTuning.tailStopExtraSeconds);
|
||||||
|
} catch {
|
||||||
|
// The voice may already have ended; either way it is fading out of the mix.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private setSamples(samples: Array<LoadedPianoSample>): void {
|
private setSamples(samples: Array<LoadedPianoSample>): void {
|
||||||
this.samples = samples.slice().sort((a, b) => a.midi - b.midi);
|
this.samples = samples.slice().sort((a, b) => a.midi - b.midi);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getMidiFrequency = (midi: number): number =>
|
||||||
|
440 * Math.pow(2, (midi - 69) / PITCH_SEMITONES_PER_OCTAVE);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { gardenAudioConfig } from './garden-audio-config';
|
|
||||||
import type { LoadedPianoSample } from './garden-audio-types';
|
import type { LoadedPianoSample } from './garden-audio-types';
|
||||||
|
|
||||||
interface PianoSampleDefinition {
|
interface PianoSampleDefinition {
|
||||||
|
|
@ -45,21 +44,36 @@ const sampleFiles: Array<[fileName: string, midi: number]> = [
|
||||||
['C8v12.m4a', 108],
|
['C8v12.m4a', 108],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const sampleBaseUrl = `${import.meta.env.BASE_URL}audio/`;
|
||||||
|
const preloadDecode = {
|
||||||
|
channels: 2,
|
||||||
|
frames: 128,
|
||||||
|
sampleRateHz: 48_000,
|
||||||
|
};
|
||||||
|
|
||||||
const pianoSampleDefinitions: Array<PianoSampleDefinition> = sampleFiles
|
const pianoSampleDefinitions: Array<PianoSampleDefinition> = sampleFiles
|
||||||
.map(([fileName, midi]) => ({
|
.map(([fileName, midi]) => ({
|
||||||
midi,
|
midi,
|
||||||
url: `${gardenAudioConfig.piano.sampleBaseUrl}${fileName}`,
|
url: `${sampleBaseUrl}${fileName}`,
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => a.midi - b.midi);
|
.sort((a, b) => a.midi - b.midi);
|
||||||
|
|
||||||
let loadedPianoSamples: Array<LoadedPianoSample> | null = null;
|
let loadedPianoSamples: Array<LoadedPianoSample> | null = null;
|
||||||
let pianoSampleLoadPromise: Promise<Array<LoadedPianoSample>> | null = null;
|
let pianoSampleLoadPromise: Promise<Array<LoadedPianoSample>> | null = null;
|
||||||
|
|
||||||
|
interface PianoSampleLoadOptions {
|
||||||
|
forceReload?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampleLoadTuning = {
|
||||||
|
concurrency: 4,
|
||||||
|
sampleTimeoutMs: 15_000,
|
||||||
|
};
|
||||||
|
|
||||||
export const preloadPianoSamples = (
|
export const preloadPianoSamples = (
|
||||||
onProgress?: (progress: PianoSampleLoadProgress) => void
|
onProgress?: (progress: PianoSampleLoadProgress) => void
|
||||||
): Promise<Array<LoadedPianoSample>> => {
|
): Promise<Array<LoadedPianoSample>> => {
|
||||||
const OfflineAudioContextConstructor =
|
const OfflineAudioContextConstructor = globalThis.OfflineAudioContext;
|
||||||
globalThis.OfflineAudioContext ?? globalThis.webkitOfflineAudioContext;
|
|
||||||
|
|
||||||
if (!OfflineAudioContextConstructor) {
|
if (!OfflineAudioContextConstructor) {
|
||||||
return Promise.reject(
|
return Promise.reject(
|
||||||
|
|
@ -68,16 +82,17 @@ export const preloadPianoSamples = (
|
||||||
}
|
}
|
||||||
|
|
||||||
const decodeContext = new OfflineAudioContextConstructor(
|
const decodeContext = new OfflineAudioContextConstructor(
|
||||||
gardenAudioConfig.piano.preloadDecode.channels,
|
preloadDecode.channels,
|
||||||
gardenAudioConfig.piano.preloadDecode.frames,
|
preloadDecode.frames,
|
||||||
gardenAudioConfig.piano.preloadDecode.sampleRateHz
|
preloadDecode.sampleRateHz
|
||||||
);
|
);
|
||||||
return loadPianoSamples(decodeContext, onProgress);
|
return loadPianoSamples(decodeContext, onProgress);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadPianoSamples = (
|
export const loadPianoSamples = (
|
||||||
decodeContext: BaseAudioContext,
|
decodeContext: BaseAudioContext,
|
||||||
onProgress?: (progress: PianoSampleLoadProgress) => void
|
onProgress?: (progress: PianoSampleLoadProgress) => void,
|
||||||
|
options: PianoSampleLoadOptions = {}
|
||||||
): Promise<Array<LoadedPianoSample>> => {
|
): Promise<Array<LoadedPianoSample>> => {
|
||||||
if (loadedPianoSamples) {
|
if (loadedPianoSamples) {
|
||||||
onProgress?.({
|
onProgress?.({
|
||||||
|
|
@ -87,7 +102,7 @@ export const loadPianoSamples = (
|
||||||
return Promise.resolve([...loadedPianoSamples]);
|
return Promise.resolve([...loadedPianoSamples]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pianoSampleLoadPromise) {
|
if (pianoSampleLoadPromise && options.forceReload !== true) {
|
||||||
return pianoSampleLoadPromise;
|
return pianoSampleLoadPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -95,16 +110,27 @@ export const loadPianoSamples = (
|
||||||
const totalCount = pianoSampleDefinitions.length;
|
const totalCount = pianoSampleDefinitions.length;
|
||||||
onProgress?.({ loadedCount, totalCount });
|
onProgress?.({ loadedCount, totalCount });
|
||||||
|
|
||||||
pianoSampleLoadPromise = Promise.all(
|
pianoSampleLoadPromise = loadPianoSampleBatch(
|
||||||
pianoSampleDefinitions.map(async (sample) => {
|
pianoSampleDefinitions,
|
||||||
const loadedSample = await loadPianoSample(decodeContext, sample);
|
async (sample) => {
|
||||||
|
try {
|
||||||
|
return await withTimeout(
|
||||||
|
loadPianoSample(decodeContext, sample),
|
||||||
|
sampleLoadTuning.sampleTimeoutMs
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
loadedCount += 1;
|
loadedCount += 1;
|
||||||
onProgress?.({ loadedCount, totalCount, sample });
|
onProgress?.({ loadedCount, totalCount, sample });
|
||||||
return loadedSample;
|
}
|
||||||
})
|
}
|
||||||
).then(
|
).then(
|
||||||
(samples) => {
|
(samples) => {
|
||||||
loadedPianoSamples = samples.slice().sort((a, b) => a.midi - b.midi);
|
loadedPianoSamples = samples
|
||||||
|
.filter((sample): sample is LoadedPianoSample => sample !== null)
|
||||||
|
.sort((a, b) => a.midi - b.midi);
|
||||||
|
if (loadedPianoSamples.length === 0) {
|
||||||
|
throw new Error('Unable to load any piano samples.');
|
||||||
|
}
|
||||||
return [...loadedPianoSamples];
|
return [...loadedPianoSamples];
|
||||||
},
|
},
|
||||||
(error: unknown) => {
|
(error: unknown) => {
|
||||||
|
|
@ -133,6 +159,43 @@ const loadPianoSample = async (
|
||||||
return { midi: sample.midi, buffer };
|
return { midi: sample.midi, buffer };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadPianoSampleBatch = async (
|
||||||
|
samples: Array<PianoSampleDefinition>,
|
||||||
|
loadSample: (
|
||||||
|
sample: PianoSampleDefinition
|
||||||
|
) => Promise<LoadedPianoSample | null>
|
||||||
|
): Promise<Array<LoadedPianoSample | null>> => {
|
||||||
|
const results: Array<LoadedPianoSample | null> = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < samples.length; index += sampleLoadTuning.concurrency) {
|
||||||
|
const batch = samples.slice(index, index + sampleLoadTuning.concurrency);
|
||||||
|
const batchResults = await Promise.all(
|
||||||
|
batch.map((sample) => loadSample(sample).catch(() => null))
|
||||||
|
);
|
||||||
|
results.push(...batchResults);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
};
|
||||||
|
|
||||||
|
const withTimeout = <T>(promise: Promise<T>, timeoutMs: number): Promise<T> =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const timeout = globalThis.setTimeout(() => {
|
||||||
|
reject(new Error('Timed out while loading a piano sample.'));
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
promise.then(
|
||||||
|
(value) => {
|
||||||
|
globalThis.clearTimeout(timeout);
|
||||||
|
resolve(value);
|
||||||
|
},
|
||||||
|
(error: unknown) => {
|
||||||
|
globalThis.clearTimeout(timeout);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const decodeAudioData = (
|
const decodeAudioData = (
|
||||||
decodeContext: BaseAudioContext,
|
decodeContext: BaseAudioContext,
|
||||||
audioData: ArrayBuffer
|
audioData: ArrayBuffer
|
||||||
|
|
|
||||||
313
src/config.ts
313
src/config.ts
|
|
@ -1,10 +1,9 @@
|
||||||
|
import { APP_STORAGE_KEYS, DEFAULT_AUDIO_VOLUME } from './app-constants';
|
||||||
import { defaultSettings } from './config/default-settings';
|
import { defaultSettings } from './config/default-settings';
|
||||||
import { runtimeControls } from './config/runtime-controls';
|
import { runtimeControls } from './config/runtime-controls';
|
||||||
import type { GardenAppConfig } from './config/types';
|
import type { GardenAppConfig } from './config/types';
|
||||||
import { defaultVibeId, vibePresets } from './config/vibe-presets';
|
import { defaultVibeId, vibePresets } from './config/vibe-presets';
|
||||||
|
|
||||||
const defaultAudioMasterVolume = 0.42;
|
|
||||||
|
|
||||||
export { VibeId } from './config/types';
|
export { VibeId } from './config/types';
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
|
|
@ -16,10 +15,9 @@ export type {
|
||||||
|
|
||||||
export const appConfig = {
|
export const appConfig = {
|
||||||
audio: {
|
audio: {
|
||||||
masterVolume: defaultAudioMasterVolume,
|
masterVolume: DEFAULT_AUDIO_VOLUME,
|
||||||
fadeInSeconds: 0.45,
|
fadeInSeconds: 0.45,
|
||||||
updateRampSeconds: 0.08,
|
updateRampSeconds: 0.08,
|
||||||
highPassFrequencyHz: 45,
|
|
||||||
delay: {
|
delay: {
|
||||||
timeSeconds: 0.46,
|
timeSeconds: 0.46,
|
||||||
feedback: 0.12,
|
feedback: 0.12,
|
||||||
|
|
@ -32,44 +30,24 @@ export const appConfig = {
|
||||||
outputBase: 0.65,
|
outputBase: 0.65,
|
||||||
outputActivityDuck: 0.28,
|
outputActivityDuck: 0.28,
|
||||||
timeRampSeconds: 0.12,
|
timeRampSeconds: 0.12,
|
||||||
feedbackHighPassHz: 180,
|
|
||||||
feedbackLowPassHz: 5200,
|
|
||||||
returnLowPassHz: 6200,
|
|
||||||
},
|
},
|
||||||
piano: {
|
piano: {
|
||||||
maxVoices: 24,
|
maxVoices: 24,
|
||||||
filterType: 'lowpass',
|
|
||||||
gain: 0.48,
|
gain: 0.48,
|
||||||
sustainSeconds: 0.42,
|
sustainSeconds: 0.42,
|
||||||
sustainLevel: 0.32,
|
sustainLevel: 0.32,
|
||||||
releaseSeconds: 0.24,
|
releaseSeconds: 0.24,
|
||||||
lowpassHz: 7600,
|
lowpassHz: 7600,
|
||||||
filterQ: 0.7,
|
|
||||||
gainAttackSeconds: 0.006,
|
gainAttackSeconds: 0.006,
|
||||||
lowpassMaxHz: 12000,
|
lowpassMaxHz: 12000,
|
||||||
lowpassMinHz: 1400,
|
lowpassMinHz: 1400,
|
||||||
minDurationSeconds: 0.08,
|
|
||||||
minFadeSeconds: 0.08,
|
|
||||||
minGain: 0.0001,
|
|
||||||
pitchSemitonesPerOctave: 12,
|
|
||||||
scheduleAheadSeconds: 0.002,
|
|
||||||
sustainBase: 0.45,
|
sustainBase: 0.45,
|
||||||
sustainVelocityRange: 0.55,
|
sustainVelocityRange: 0.55,
|
||||||
tailStopExtraSeconds: 0.05,
|
|
||||||
voiceStealFadeSeconds: 0.025,
|
|
||||||
voiceStealStopSeconds: 0.05,
|
|
||||||
sampleBaseUrl: `${import.meta.env.BASE_URL}audio/`,
|
|
||||||
preloadDecode: {
|
|
||||||
channels: 1,
|
|
||||||
frames: 1,
|
|
||||||
sampleRateHz: 44_100,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
rhythm: {
|
rhythm: {
|
||||||
bpm: 74,
|
bpm: 74,
|
||||||
stepsPerBeat: 4,
|
stepsPerBeat: 4,
|
||||||
stepsPerBar: 16,
|
stepsPerBar: 16,
|
||||||
lookaheadSeconds: 0.3,
|
|
||||||
sparseActivity: 0.055,
|
sparseActivity: 0.055,
|
||||||
},
|
},
|
||||||
eraser: {
|
eraser: {
|
||||||
|
|
@ -89,19 +67,6 @@ export const appConfig = {
|
||||||
strokeDecaySeconds: 0.32,
|
strokeDecaySeconds: 0.32,
|
||||||
},
|
},
|
||||||
graph: {
|
graph: {
|
||||||
closeGain: 0.0001,
|
|
||||||
closeRampSeconds: 0.015,
|
|
||||||
delayMaxSeconds: 2,
|
|
||||||
eventBusGain: 1,
|
|
||||||
noiseMax: 1,
|
|
||||||
noiseMin: -1,
|
|
||||||
unlockTickFrequencyHz: 440,
|
|
||||||
unlockTickSeconds: 0.035,
|
|
||||||
unlockTickType: 'sine',
|
|
||||||
latencyHint: 'interactive',
|
|
||||||
outputFilterType: 'highpass',
|
|
||||||
noiseBufferChannels: 1,
|
|
||||||
noiseBufferDurationSeconds: 1,
|
|
||||||
pianoBusGains: {
|
pianoBusGains: {
|
||||||
pad: 0.86,
|
pad: 0.86,
|
||||||
support: 0.94,
|
support: 0.94,
|
||||||
|
|
@ -119,16 +84,8 @@ export const appConfig = {
|
||||||
stinger: 0,
|
stinger: 0,
|
||||||
},
|
},
|
||||||
noiseBusGain: 0.72,
|
noiseBusGain: 0.72,
|
||||||
compressor: {
|
|
||||||
thresholdDb: -18,
|
|
||||||
kneeDb: 18,
|
|
||||||
ratio: 2.1,
|
|
||||||
attackSeconds: 0.018,
|
|
||||||
releaseSeconds: 0.18,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
input: {
|
input: {
|
||||||
fallbackFrameSeconds: 1 / 60,
|
|
||||||
fullActivitySpeed: 0.86,
|
fullActivitySpeed: 0.86,
|
||||||
activityNoiseFloorSpeed: 0.025,
|
activityNoiseFloorSpeed: 0.025,
|
||||||
activityCurve: 0.74,
|
activityCurve: 0.74,
|
||||||
|
|
@ -139,259 +96,7 @@ export const appConfig = {
|
||||||
manicActivityThreshold: 0.9,
|
manicActivityThreshold: 0.9,
|
||||||
manicReleaseThreshold: 0.76,
|
manicReleaseThreshold: 0.76,
|
||||||
maniaSmoothingSeconds: 0.12,
|
maniaSmoothingSeconds: 0.12,
|
||||||
minElapsedSeconds: 0.001,
|
|
||||||
},
|
},
|
||||||
muteGain: 0.0001,
|
|
||||||
muteRampSeconds: 0.02,
|
|
||||||
noiseBurst: {
|
|
||||||
attackSeconds: 0.004,
|
|
||||||
filterQ: 1.4,
|
|
||||||
offsetRandomSeconds: 0.4,
|
|
||||||
scheduleAheadSeconds: 0.002,
|
|
||||||
silentGain: 0.0001,
|
|
||||||
filterType: 'bandpass',
|
|
||||||
},
|
|
||||||
startDelaySeconds: 0.02,
|
|
||||||
vibeChangeStingerMinIntervalSeconds: 0.45,
|
|
||||||
generativePiano: {
|
|
||||||
stylePools: [
|
|
||||||
{
|
|
||||||
midiMin: 48,
|
|
||||||
midiMax: 67,
|
|
||||||
preferredMidi: 55,
|
|
||||||
pan: -0.18,
|
|
||||||
scaleDegrees: [0, 1, 2, 4],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
midiMin: 55,
|
|
||||||
midiMax: 74,
|
|
||||||
preferredMidi: 63,
|
|
||||||
pan: 0,
|
|
||||||
scaleDegrees: [1, 2, 3, 5],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
midiMin: 62,
|
|
||||||
midiMax: 81,
|
|
||||||
preferredMidi: 72,
|
|
||||||
pan: 0.18,
|
|
||||||
scaleDegrees: [2, 3, 4, 6],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
padRegisters: [
|
|
||||||
{
|
|
||||||
midiMin: 40,
|
|
||||||
midiMax: 55,
|
|
||||||
preferredMidi: 48,
|
|
||||||
pan: -0.12,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
midiMin: 48,
|
|
||||||
midiMax: 64,
|
|
||||||
preferredMidi: 55,
|
|
||||||
pan: 0.08,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
midiMin: 58,
|
|
||||||
midiMax: 76,
|
|
||||||
preferredMidi: 67,
|
|
||||||
pan: 0.2,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
chordVoicings: {
|
|
||||||
majorOpen: [0, 7, 12, 16],
|
|
||||||
minorOpen: [0, 7, 12, 15],
|
|
||||||
majorClosed: [0, 4, 7, 12, 16],
|
|
||||||
minorClosed: [0, 3, 7, 12, 15],
|
|
||||||
},
|
|
||||||
vibeChangeStinger: {
|
|
||||||
velocities: [0.1, 0.085, 0.07],
|
|
||||||
pans: [-0.16, 0, 0.16],
|
|
||||||
delaySends: [0.012, 0.014, 0.016],
|
|
||||||
lowpassExpression: 0.35,
|
|
||||||
},
|
|
||||||
highActivityExtra: {
|
|
||||||
barOffset: 1,
|
|
||||||
expressionMultiplier: 0.9,
|
|
||||||
},
|
|
||||||
padChord: {
|
|
||||||
velocities: [0.052, 0.041, 0.033],
|
|
||||||
expressionVelocityWeight: 0.02,
|
|
||||||
delaySend: 0.008,
|
|
||||||
lowpassExpressionWeight: 0.28,
|
|
||||||
},
|
|
||||||
supportNote: {
|
|
||||||
velocityBase: 0.105,
|
|
||||||
velocityExpressionWeight: 0.07,
|
|
||||||
durationBaseSeconds: 1.35,
|
|
||||||
durationExpressionSeconds: 0.4,
|
|
||||||
delaySendBase: 0.016,
|
|
||||||
delaySendExpressionWeight: 0.006,
|
|
||||||
lowpassExpressionWeight: 0.7,
|
|
||||||
expressionThreshold: 0.55,
|
|
||||||
offsetsByStyle: [
|
|
||||||
[0, 2, 12],
|
|
||||||
[1, 2, 0, 12],
|
|
||||||
[2, 12, 3, 13],
|
|
||||||
],
|
|
||||||
},
|
|
||||||
textureNote: {
|
|
||||||
velocityBase: 0.09,
|
|
||||||
velocityExpressionWeight: 0.08,
|
|
||||||
durationBaseSeconds: 0.62,
|
|
||||||
durationExpressionSeconds: 0.24,
|
|
||||||
delaySendBase: 0.016,
|
|
||||||
delaySendExpressionWeight: 0.006,
|
|
||||||
idleExpressionThreshold: 0.35,
|
|
||||||
mediumExpressionThreshold: 0.7,
|
|
||||||
intenseSpacing: 1,
|
|
||||||
idlePhase: 1,
|
|
||||||
},
|
|
||||||
gestureAccent: {
|
|
||||||
rotationStrengthMultiplier: 3,
|
|
||||||
quantizeStepLookahead: 1,
|
|
||||||
velocityBase: 0.12,
|
|
||||||
velocityStrengthWeight: 0.09,
|
|
||||||
durationBaseSeconds: 0.48,
|
|
||||||
durationStrengthSeconds: 0.22,
|
|
||||||
delaySend: 0.012,
|
|
||||||
},
|
|
||||||
touchNote: {
|
|
||||||
registerBiasManiaAmount: 0,
|
|
||||||
velocityBase: 0.14,
|
|
||||||
velocityStrengthWeight: 0.11,
|
|
||||||
durationBaseSeconds: 0.55,
|
|
||||||
durationStrengthSeconds: 0.18,
|
|
||||||
delaySend: 0.006,
|
|
||||||
lowpassBaseExpression: 0.55,
|
|
||||||
lowpassStrengthWeight: 0.35,
|
|
||||||
},
|
|
||||||
brushPhrase: {
|
|
||||||
initialMotifOffset: -1,
|
|
||||||
energyDecaySeconds: 0.72,
|
|
||||||
maniaDecaySeconds: 0.54,
|
|
||||||
fadeMinimumLifetimeSeconds: 0.001,
|
|
||||||
layerIntensityBase: 0.8,
|
|
||||||
layerIntensityManiaWeight: 0.42,
|
|
||||||
frameActivityWeight: 0.42,
|
|
||||||
frameManiaWeight: 0.18,
|
|
||||||
},
|
|
||||||
brushStream: {
|
|
||||||
inferredManiaThreshold: 0.82,
|
|
||||||
inferredManiaRange: 0.18,
|
|
||||||
registerManiaShift: 0.45,
|
|
||||||
chordToneEverySteps: 4,
|
|
||||||
durationBaseSeconds: 0.48,
|
|
||||||
durationIntensitySeconds: 0.08,
|
|
||||||
durationManiaSeconds: 0.34,
|
|
||||||
durationMinSeconds: 0.14,
|
|
||||||
durationMaxSeconds: 0.62,
|
|
||||||
delaySendBase: 0.012,
|
|
||||||
delaySendIntensityWeight: 0.011,
|
|
||||||
delaySendManiaWeight: 0.006,
|
|
||||||
delaySendMin: 0.006,
|
|
||||||
delaySendMax: 0.032,
|
|
||||||
velocityBase: 0.1,
|
|
||||||
velocityIntensityWeight: 0.13,
|
|
||||||
lowpassBaseExpression: 0.39,
|
|
||||||
lowpassIntensityWeight: 0.48,
|
|
||||||
lowpassManiaWeight: 0.18,
|
|
||||||
manicThreshold: 0.85,
|
|
||||||
intenseThreshold: 0.62,
|
|
||||||
activeThreshold: 0.34,
|
|
||||||
},
|
|
||||||
brushStreamEcho: {
|
|
||||||
maniaThreshold: 0.86,
|
|
||||||
stepModulo: 2,
|
|
||||||
stepRemainder: 1,
|
|
||||||
intensityThreshold: 0.95,
|
|
||||||
octaveSemitones: 12,
|
|
||||||
maxMidi: 88,
|
|
||||||
velocityBase: 0.045,
|
|
||||||
velocityIntensityWeight: 0.05,
|
|
||||||
durationMinSeconds: 0.11,
|
|
||||||
durationScale: 0.68,
|
|
||||||
panScale: -0.75,
|
|
||||||
delaySendMin: 0.006,
|
|
||||||
delaySendScale: 0.72,
|
|
||||||
lowpassBaseExpression: 0.62,
|
|
||||||
lowpassManiaWeight: 0.24,
|
|
||||||
},
|
|
||||||
brushMotif: {
|
|
||||||
highThreshold: 0.82,
|
|
||||||
mediumThreshold: 0.55,
|
|
||||||
highOffset: 1,
|
|
||||||
mediumOffset: 0,
|
|
||||||
lowOffset: -1,
|
|
||||||
minOffset: -3,
|
|
||||||
maxOffset: 4,
|
|
||||||
},
|
|
||||||
registerBias: {
|
|
||||||
maniaShiftSemitones: 4,
|
|
||||||
midiMin: 36,
|
|
||||||
midiMaxForMin: 86,
|
|
||||||
minimumSpan: 4,
|
|
||||||
midiMax: 91,
|
|
||||||
},
|
|
||||||
candidateOctaveSearch: {
|
|
||||||
min: -3,
|
|
||||||
max: 3,
|
|
||||||
},
|
|
||||||
stylePanOffsetScale: 0.35,
|
|
||||||
lowpass: {
|
|
||||||
midiBase: 48,
|
|
||||||
midiRange: 33,
|
|
||||||
midiLiftHz: 720,
|
|
||||||
expressionBase: 0.58,
|
|
||||||
expressionWeight: 0.32,
|
|
||||||
},
|
|
||||||
styleRotationBars: 2,
|
|
||||||
chordBars: 4,
|
|
||||||
supportBarSpacing: 2,
|
|
||||||
supportBarOffset: 1,
|
|
||||||
idleTextureBarSpacing: 2,
|
|
||||||
mediumTextureBarSpacing: 1,
|
|
||||||
textureBeat: 2,
|
|
||||||
highActivityExtraBeat: 3,
|
|
||||||
highActivityExtraThreshold: 0.45,
|
|
||||||
noteScorePreferenceWeight: 1.8,
|
|
||||||
noteScoreRegisterWeight: 0.28,
|
|
||||||
noteScoreChordToneWeight: 0.75,
|
|
||||||
noteScoreRepeatPenalty: 3.2,
|
|
||||||
gestureAccentMinIntervalSeconds: 2.5,
|
|
||||||
strokeAccentMinSteps: 12,
|
|
||||||
strokeAccentThreshold: 0.58,
|
|
||||||
stingerSpacingSeconds: 0.08,
|
|
||||||
stingerDurationSeconds: 1.1,
|
|
||||||
maxBrushPhraseLayers: 3,
|
|
||||||
maxBrushStreamNotesPerBar: 9,
|
|
||||||
brushLayerBaseSeconds: 5.5,
|
|
||||||
brushLayerEnergySeconds: 2.5,
|
|
||||||
brushLayerMinIntensity: 0.12,
|
|
||||||
brushStreamIdleIntervalBeats: 2,
|
|
||||||
brushStreamActiveIntervalBeats: 1,
|
|
||||||
brushStreamIntenseIntervalBeats: 0.5,
|
|
||||||
brushStreamManicIntervalBeats: 0.5,
|
|
||||||
brushMotifMaxSteps: 8,
|
|
||||||
brushMotifCanonDelaySeconds: 0.055,
|
|
||||||
padDurationBarScale: 0.46,
|
|
||||||
},
|
|
||||||
styleVoices: [
|
|
||||||
{
|
|
||||||
scaleDegreeOffset: 0,
|
|
||||||
velocityMultiplier: 0.92,
|
|
||||||
panOffset: -0.14,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
scaleDegreeOffset: 1,
|
|
||||||
velocityMultiplier: 1,
|
|
||||||
panOffset: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
scaleDegreeOffset: 2,
|
|
||||||
velocityMultiplier: 0.86,
|
|
||||||
panOffset: 0.14,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
deltaTime: {
|
deltaTime: {
|
||||||
maxDeltaTimeSeconds: 1 / 30,
|
maxDeltaTimeSeconds: 1 / 30,
|
||||||
|
|
@ -444,10 +149,9 @@ export const appConfig = {
|
||||||
},
|
},
|
||||||
simulation: {
|
simulation: {
|
||||||
budget: {
|
budget: {
|
||||||
adaptiveCapDecreaseAgentsPerSecond: 50_000,
|
adaptiveCapDecreaseAgentsPerSecond: 200_000,
|
||||||
adaptiveCapInitial: 1_000_000,
|
adaptiveCapInitial: 1_000_000,
|
||||||
adaptiveCapMax: 2_000_000,
|
adaptiveCapMin: 50_000,
|
||||||
adaptiveCapMin: 500_000,
|
|
||||||
adaptiveRefreshTargetFps: 60,
|
adaptiveRefreshTargetFps: 60,
|
||||||
frameGapResetSeconds: 1,
|
frameGapResetSeconds: 1,
|
||||||
fpsHeadroom: 0.95,
|
fpsHeadroom: 0.95,
|
||||||
|
|
@ -458,6 +162,7 @@ export const appConfig = {
|
||||||
brushEffectFramesPerSecond: 60,
|
brushEffectFramesPerSecond: 60,
|
||||||
clearColor: { r: 0, g: 0, b: 0, a: 0 },
|
clearColor: { r: 0, g: 0, b: 0, a: 0 },
|
||||||
initialAgentCount: 180_000,
|
initialAgentCount: 180_000,
|
||||||
|
maxDevicePixelRatio: 2,
|
||||||
intro: {
|
intro: {
|
||||||
angleJitterRadians: Math.PI * 0.08,
|
angleJitterRadians: Math.PI * 0.08,
|
||||||
angleEaseEnd: 1,
|
angleEaseEnd: 1,
|
||||||
|
|
@ -508,9 +213,9 @@ export const appConfig = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
storage: {
|
storage: {
|
||||||
audioMutedKey: 'fleeting-garden:audio-muted',
|
audioMutedKey: APP_STORAGE_KEYS.audioMuted,
|
||||||
audioVolumeKey: 'fleeting-garden:audio-volume',
|
audioVolumeKey: APP_STORAGE_KEYS.audioVolume,
|
||||||
vibeKey: 'fleeting-garden:vibe',
|
vibeKey: APP_STORAGE_KEYS.vibe,
|
||||||
},
|
},
|
||||||
toolbar: {
|
toolbar: {
|
||||||
eraser: {
|
eraser: {
|
||||||
|
|
@ -566,7 +271,7 @@ export const appConfig = {
|
||||||
whiteContrastNumerator: 1.05,
|
whiteContrastNumerator: 1.05,
|
||||||
},
|
},
|
||||||
volume: {
|
volume: {
|
||||||
default: defaultAudioMasterVolume,
|
default: DEFAULT_AUDIO_VOLUME,
|
||||||
max: 1,
|
max: 1,
|
||||||
min: 0,
|
min: 0,
|
||||||
step: 0.01,
|
step: 0.01,
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,9 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = {
|
||||||
eraserLineDistanceEpsilon: 0.0001,
|
eraserLineDistanceEpsilon: 0.0001,
|
||||||
eraserMaskAlphaThreshold: 0.5,
|
eraserMaskAlphaThreshold: 0.5,
|
||||||
|
|
||||||
|
internalRenderAreaMegapixels: 8.3,
|
||||||
strokeSpawnSpreadBrushSizeMultiplier: 1,
|
strokeSpawnSpreadBrushSizeMultiplier: 1,
|
||||||
|
maxAgentCount: 700_000,
|
||||||
|
|
||||||
renderTraceNormalizationFloor: 1,
|
renderTraceNormalizationFloor: 1,
|
||||||
renderBrushColorBase: 1.2,
|
renderBrushColorBase: 1.2,
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,13 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
||||||
max: 0.12,
|
max: 0.12,
|
||||||
step: 0.001,
|
step: 0.001,
|
||||||
},
|
},
|
||||||
|
internalRenderAreaMegapixels: {
|
||||||
|
folder: 'Render',
|
||||||
|
label: 'internal area (MP)',
|
||||||
|
min: 0.5,
|
||||||
|
max: 16.6,
|
||||||
|
step: 0.1,
|
||||||
|
},
|
||||||
decayRateBrush: {
|
decayRateBrush: {
|
||||||
folder: 'Diffusion',
|
folder: 'Diffusion',
|
||||||
min: 0.1,
|
min: 0.1,
|
||||||
|
|
@ -248,6 +255,12 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
||||||
max: 1,
|
max: 1,
|
||||||
step: 0.001,
|
step: 0.001,
|
||||||
},
|
},
|
||||||
|
maxAgentCount: {
|
||||||
|
folder: 'Agent',
|
||||||
|
integer: true,
|
||||||
|
label: 'max agent count',
|
||||||
|
step: 10_000,
|
||||||
|
},
|
||||||
mirrorSegmentCount: {
|
mirrorSegmentCount: {
|
||||||
folder: 'Brush',
|
folder: 'Brush',
|
||||||
integer: true,
|
integer: true,
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@ export interface NumberControlConfig {
|
||||||
folder: string;
|
folder: string;
|
||||||
integer?: boolean;
|
integer?: boolean;
|
||||||
label?: string;
|
label?: string;
|
||||||
max: number;
|
max?: number;
|
||||||
min: number;
|
min?: number;
|
||||||
options?: Record<string, number>;
|
options?: Record<string, number>;
|
||||||
step?: number;
|
step?: number;
|
||||||
}
|
}
|
||||||
|
|
@ -32,7 +32,9 @@ export type GardenRuntimeSettings = {
|
||||||
eraserLineDistanceEpsilon: number;
|
eraserLineDistanceEpsilon: number;
|
||||||
eraserMaskAlphaThreshold: number;
|
eraserMaskAlphaThreshold: number;
|
||||||
eraserSize: number;
|
eraserSize: number;
|
||||||
|
internalRenderAreaMegapixels: number;
|
||||||
mirrorSegmentCount: number;
|
mirrorSegmentCount: number;
|
||||||
|
maxAgentCount: number;
|
||||||
selectedColorIndex: number;
|
selectedColorIndex: number;
|
||||||
spawnPerPixel: number;
|
spawnPerPixel: number;
|
||||||
strokeSpawnSpreadBrushSizeMultiplier: number;
|
strokeSpawnSpreadBrushSizeMultiplier: number;
|
||||||
|
|
@ -137,7 +139,6 @@ export interface GardenAppConfig {
|
||||||
budget: {
|
budget: {
|
||||||
adaptiveCapDecreaseAgentsPerSecond: number;
|
adaptiveCapDecreaseAgentsPerSecond: number;
|
||||||
adaptiveCapInitial: number;
|
adaptiveCapInitial: number;
|
||||||
adaptiveCapMax: number;
|
|
||||||
adaptiveCapMin: number;
|
adaptiveCapMin: number;
|
||||||
adaptiveRefreshTargetFps: number;
|
adaptiveRefreshTargetFps: number;
|
||||||
frameGapResetSeconds: number;
|
frameGapResetSeconds: number;
|
||||||
|
|
@ -149,6 +150,7 @@ export interface GardenAppConfig {
|
||||||
brushEffectFramesPerSecond: number;
|
brushEffectFramesPerSecond: number;
|
||||||
clearColor: GPUColor;
|
clearColor: GPUColor;
|
||||||
initialAgentCount: number;
|
initialAgentCount: number;
|
||||||
|
maxDevicePixelRatio: number;
|
||||||
intro: {
|
intro: {
|
||||||
angleJitterRadians: number;
|
angleJitterRadians: number;
|
||||||
angleEaseEnd: number;
|
angleEaseEnd: number;
|
||||||
|
|
|
||||||
|
|
@ -1,141 +0,0 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
import { appConfig } from '../config';
|
|
||||||
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
|
|
||||||
import { settings } from '../settings';
|
|
||||||
import { AgentPopulation } from './agent-population';
|
|
||||||
|
|
||||||
vi.hoisted(() => {
|
|
||||||
Object.defineProperty(globalThis, 'localStorage', {
|
|
||||||
configurable: true,
|
|
||||||
value: {
|
|
||||||
getItem: vi.fn(() => null),
|
|
||||||
setItem: vi.fn(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const originalBrushSize = settings.brushSize;
|
|
||||||
const originalSelectedColorIndex = settings.selectedColorIndex;
|
|
||||||
const originalSpawnPerPixel = settings.spawnPerPixel;
|
|
||||||
const originalStrokeSpawnSpreadBrushSizeMultiplier =
|
|
||||||
settings.strokeSpawnSpreadBrushSizeMultiplier;
|
|
||||||
|
|
||||||
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 setPopulationActiveCount = (population: AgentPopulation, activeCount: number) => {
|
|
||||||
Object.assign(population as unknown as Record<string, number>, {
|
|
||||||
activeCount,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const setPopulationAdaptiveCap = (population: AgentPopulation, adaptiveCap: number) => {
|
|
||||||
Object.assign(population as unknown as Record<string, number>, {
|
|
||||||
adaptiveCap,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPopulationAdaptiveCap = (population: AgentPopulation): number =>
|
|
||||||
(population as unknown as { adaptiveCap: number }).adaptiveCap;
|
|
||||||
|
|
||||||
describe('AgentPopulation adaptive budget', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
settings.brushSize = 1;
|
|
||||||
settings.selectedColorIndex = 0;
|
|
||||||
settings.spawnPerPixel = 1;
|
|
||||||
settings.strokeSpawnSpreadBrushSizeMultiplier = 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
settings.brushSize = originalBrushSize;
|
|
||||||
settings.selectedColorIndex = originalSelectedColorIndex;
|
|
||||||
settings.spawnPerPixel = originalSpawnPerPixel;
|
|
||||||
settings.strokeSpawnSpreadBrushSizeMultiplier =
|
|
||||||
originalStrokeSpawnSpreadBrushSizeMultiplier;
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('expands beyond the 1M start cap only when new agents arrive under healthy FPS', () => {
|
|
||||||
const population = createPopulation();
|
|
||||||
setPopulationActiveCount(population, 1_000_000);
|
|
||||||
|
|
||||||
population.growBudget(1 / 60, 60);
|
|
||||||
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(16, 0));
|
|
||||||
|
|
||||||
expect(getPopulationAdaptiveCap(population)).toBeGreaterThan(
|
|
||||||
appConfig.simulation.budget.adaptiveCapInitial
|
|
||||||
);
|
|
||||||
expect(population.activeAgentCount).toBeGreaterThan(
|
|
||||||
appConfig.simulation.budget.adaptiveCapInitial
|
|
||||||
);
|
|
||||||
expect(getPopulationAdaptiveCap(population)).toBeLessThanOrEqual(
|
|
||||||
appConfig.simulation.budget.adaptiveCapMax
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not grow the cap above the adaptive max agent count', () => {
|
|
||||||
const population = createPopulation();
|
|
||||||
const maxAgentCount = appConfig.simulation.budget.adaptiveCapMax;
|
|
||||||
setPopulationAdaptiveCap(population, maxAgentCount - 1);
|
|
||||||
setPopulationActiveCount(population, maxAgentCount - 1);
|
|
||||||
|
|
||||||
population.growBudget(1 / 60, 60);
|
|
||||||
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(16, 0));
|
|
||||||
|
|
||||||
expect(getPopulationAdaptiveCap(population)).toBe(maxAgentCount);
|
|
||||||
expect(population.activeAgentCount).toBe(maxAgentCount);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clamps a stale cap before adding agents', () => {
|
|
||||||
const population = createPopulation();
|
|
||||||
const maxAgentCount = appConfig.simulation.budget.adaptiveCapMax;
|
|
||||||
setPopulationAdaptiveCap(population, maxAgentCount + 1_000);
|
|
||||||
setPopulationActiveCount(population, maxAgentCount);
|
|
||||||
|
|
||||||
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(16, 0));
|
|
||||||
|
|
||||||
expect(getPopulationAdaptiveCap(population)).toBe(maxAgentCount);
|
|
||||||
expect(population.activeAgentCount).toBe(maxAgentCount);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('scales stroke spawn spread by device pixel ratio', () => {
|
|
||||||
settings.brushSize = 10;
|
|
||||||
const writeAgents = vi.fn();
|
|
||||||
const pipeline = {
|
|
||||||
maxAgentCount: 10_000_000,
|
|
||||||
writeAgents,
|
|
||||||
resizeAgents: vi.fn(),
|
|
||||||
compactAgents: vi.fn(),
|
|
||||||
} as unknown as AgentGenerationPipeline;
|
|
||||||
const population = new AgentPopulation(pipeline, 0, () => 2);
|
|
||||||
vi.spyOn(Math, 'random').mockReturnValue(1);
|
|
||||||
|
|
||||||
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(0, 0));
|
|
||||||
|
|
||||||
const firstBatch = writeAgents.mock.calls[0][1] as Float32Array;
|
|
||||||
expect(firstBatch[0]).toBe(10);
|
|
||||||
expect(firstBatch[1]).toBe(10);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('decreases the cap and active count slowly when FPS falls below the threshold', () => {
|
|
||||||
const population = createPopulation();
|
|
||||||
setPopulationActiveCount(population, 1_000_000);
|
|
||||||
|
|
||||||
population.growBudget(10, 50);
|
|
||||||
|
|
||||||
expect(getPopulationAdaptiveCap(population)).toBe(
|
|
||||||
appConfig.simulation.budget.adaptiveCapMin
|
|
||||||
);
|
|
||||||
expect(population.activeAgentCount).toBe(appConfig.simulation.budget.adaptiveCapMin);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
import { appConfig } from '../config';
|
import { appConfig } from '../config';
|
||||||
import { AGENT_FLOAT_COUNT } from '../pipelines/agents/agent-generation/agent';
|
import {
|
||||||
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
|
AGENT_FLOAT_COUNT,
|
||||||
import { getSafeDevicePixelRatio } from '../pipelines/brush/brush-pipeline';
|
AgentGenerationPipeline,
|
||||||
|
} from '../pipelines/agents/agent-generation/agent-generation-pipeline';
|
||||||
|
import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline';
|
||||||
import { settings } from '../settings';
|
import { settings } from '../settings';
|
||||||
import { createIntroTitleAgents } from './intro-title-agents';
|
import { createIntroTitleAgents } from './intro-title-agents';
|
||||||
|
|
||||||
|
|
@ -14,6 +16,8 @@ export class AgentPopulation {
|
||||||
private canExpandAdaptiveCap = true;
|
private canExpandAdaptiveCap = true;
|
||||||
private shouldCompactAfterErase = false;
|
private shouldCompactAfterErase = false;
|
||||||
private isCompacting = false;
|
private isCompacting = false;
|
||||||
|
private pendingCompaction: Promise<void> | null = null;
|
||||||
|
private postCompactionWriteEnd = 0;
|
||||||
private readonly strokeAgentData = new Float32Array(
|
private readonly strokeAgentData = new Float32Array(
|
||||||
appConfig.simulation.stroke.maxAgentCount * AGENT_FLOAT_COUNT
|
appConfig.simulation.stroke.maxAgentCount * AGENT_FLOAT_COUNT
|
||||||
);
|
);
|
||||||
|
|
@ -21,7 +25,7 @@ export class AgentPopulation {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly pipeline: AgentGenerationPipeline,
|
private readonly pipeline: AgentGenerationPipeline,
|
||||||
private readonly introSeed = Math.floor(Math.random() * 0xffffffff),
|
private readonly introSeed = Math.floor(Math.random() * 0xffffffff),
|
||||||
private readonly getDevicePixelRatio = () => 1
|
private readonly getCanvasPixelRatio = () => 1
|
||||||
) {
|
) {
|
||||||
this.adaptiveCap = this.clampAdaptiveCap(
|
this.adaptiveCap = this.clampAdaptiveCap(
|
||||||
appConfig.simulation.budget.adaptiveCapInitial
|
appConfig.simulation.budget.adaptiveCapInitial
|
||||||
|
|
@ -55,6 +59,7 @@ export class AgentPopulation {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.pipeline.writeAgents(0, data);
|
this.pipeline.writeAgents(0, data);
|
||||||
|
this.markPostCompactionWrite(0, data.length / AGENT_FLOAT_COUNT);
|
||||||
this.activeCount = data.length / AGENT_FLOAT_COUNT;
|
this.activeCount = data.length / AGENT_FLOAT_COUNT;
|
||||||
this.replacementCursor = 0;
|
this.replacementCursor = 0;
|
||||||
}
|
}
|
||||||
|
|
@ -76,7 +81,7 @@ export class AgentPopulation {
|
||||||
this.shouldCompactAfterErase = true;
|
this.shouldCompactAfterErase = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async compactAfterErase(isSwipeActive: boolean): Promise<void> {
|
public compactAfterErase(isSwipeActive: boolean): void {
|
||||||
if (!this.shouldCompactAfterErase || this.isCompacting || isSwipeActive) {
|
if (!this.shouldCompactAfterErase || this.isCompacting || isSwipeActive) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -87,14 +92,33 @@ export class AgentPopulation {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isCompacting = true;
|
this.isCompacting = true;
|
||||||
try {
|
this.postCompactionWriteEnd = 0;
|
||||||
const compactedAgentCount = await this.pipeline.compactAgents(this.activeCount);
|
this.pendingCompaction = this.pipeline
|
||||||
this.activeCount = compactedAgentCount;
|
.compactAgents(this.activeCount)
|
||||||
|
.then((compactedAgentCount) => {
|
||||||
|
const finiteCompactedAgentCount = Number.isFinite(compactedAgentCount)
|
||||||
|
? Math.max(0, Math.floor(compactedAgentCount))
|
||||||
|
: 0;
|
||||||
|
this.activeCount = Math.min(
|
||||||
|
this.activeCount,
|
||||||
|
Math.max(finiteCompactedAgentCount, this.postCompactionWriteEnd)
|
||||||
|
);
|
||||||
this.replacementCursor =
|
this.replacementCursor =
|
||||||
compactedAgentCount === 0 ? 0 : this.replacementCursor % compactedAgentCount;
|
this.activeCount === 0 ? 0 : this.replacementCursor % this.activeCount;
|
||||||
} finally {
|
this.trimActiveCountToBudget();
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
console.warn('Could not compact agents after erase.', error);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
this.isCompacting = false;
|
this.isCompacting = false;
|
||||||
|
this.pendingCompaction = null;
|
||||||
|
this.postCompactionWriteEnd = 0;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async waitForCompaction(): Promise<void> {
|
||||||
|
await this.pendingCompaction;
|
||||||
}
|
}
|
||||||
|
|
||||||
public spawnStrokeAgents(from: vec2, to: vec2): void {
|
public spawnStrokeAgents(from: vec2, to: vec2): void {
|
||||||
|
|
@ -125,7 +149,7 @@ export class AgentPopulation {
|
||||||
const base = i * AGENT_FLOAT_COUNT;
|
const base = i * AGENT_FLOAT_COUNT;
|
||||||
const spread =
|
const spread =
|
||||||
settings.brushSize *
|
settings.brushSize *
|
||||||
getSafeDevicePixelRatio(this.getDevicePixelRatio()) *
|
getSafePixelRatio(this.getCanvasPixelRatio()) *
|
||||||
settings.strokeSpawnSpreadBrushSizeMultiplier;
|
settings.strokeSpawnSpreadBrushSizeMultiplier;
|
||||||
this.strokeAgentData[base] = x + (Math.random() - 0.5) * spread;
|
this.strokeAgentData[base] = x + (Math.random() - 0.5) * spread;
|
||||||
this.strokeAgentData[base + 1] = y + (Math.random() - 0.5) * spread;
|
this.strokeAgentData[base + 1] = y + (Math.random() - 0.5) * spread;
|
||||||
|
|
@ -157,6 +181,7 @@ export class AgentPopulation {
|
||||||
this.activeCount,
|
this.activeCount,
|
||||||
data.subarray(0, appendCount * AGENT_FLOAT_COUNT)
|
data.subarray(0, appendCount * AGENT_FLOAT_COUNT)
|
||||||
);
|
);
|
||||||
|
this.markPostCompactionWrite(this.activeCount, appendCount);
|
||||||
this.activeCount += appendCount;
|
this.activeCount += appendCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -175,12 +200,24 @@ export class AgentPopulation {
|
||||||
(sourceAgentOffset + chunkAgentCount) * AGENT_FLOAT_COUNT
|
(sourceAgentOffset + chunkAgentCount) * AGENT_FLOAT_COUNT
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
this.markPostCompactionWrite(targetAgentOffset, chunkAgentCount);
|
||||||
|
|
||||||
sourceAgentOffset += chunkAgentCount;
|
sourceAgentOffset += chunkAgentCount;
|
||||||
this.replacementCursor = (targetAgentOffset + chunkAgentCount) % this.activeCount;
|
this.replacementCursor = (targetAgentOffset + chunkAgentCount) % this.activeCount;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private markPostCompactionWrite(agentOffset: number, agentCount: number): void {
|
||||||
|
if (!this.isCompacting || agentCount <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.postCompactionWriteEnd = Math.max(
|
||||||
|
this.postCompactionWriteEnd,
|
||||||
|
Math.ceil(agentOffset + agentCount)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private updateAdaptiveCap(deltaTime: number, smoothedFps: number): void {
|
private updateAdaptiveCap(deltaTime: number, smoothedFps: number): void {
|
||||||
const previousCap = this.clampAdaptiveCap(this.adaptiveCap);
|
const previousCap = this.clampAdaptiveCap(this.adaptiveCap);
|
||||||
this.canExpandAdaptiveCap =
|
this.canExpandAdaptiveCap =
|
||||||
|
|
@ -200,7 +237,8 @@ export class AgentPopulation {
|
||||||
appConfig.simulation.budget.adaptiveCapDecreaseAgentsPerSecond * deltaTime
|
appConfig.simulation.budget.adaptiveCapDecreaseAgentsPerSecond * deltaTime
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const nextCap = this.clampAdaptiveCap(previousCap - decrease);
|
const responsiveCap = Math.min(previousCap, this.clampAdaptiveCap(this.activeCount));
|
||||||
|
const nextCap = this.clampAdaptiveCap(responsiveCap - decrease);
|
||||||
this.adaptiveCap = nextCap;
|
this.adaptiveCap = nextCap;
|
||||||
this.trimActiveCountToBudget(decrease);
|
this.trimActiveCountToBudget(decrease);
|
||||||
}
|
}
|
||||||
|
|
@ -230,10 +268,19 @@ export class AgentPopulation {
|
||||||
}
|
}
|
||||||
|
|
||||||
private clampAdaptiveCap(value: number): number {
|
private clampAdaptiveCap(value: number): number {
|
||||||
const pipelineCap = Math.max(0, Math.floor(this.pipeline.maxAgentCount));
|
const runtimeMaxCap =
|
||||||
const maxCap = Math.min(appConfig.simulation.budget.adaptiveCapMax, pipelineCap);
|
settings.maxAgentCount === Number.POSITIVE_INFINITY
|
||||||
|
? Number.POSITIVE_INFINITY
|
||||||
|
: Number.isFinite(settings.maxAgentCount)
|
||||||
|
? Math.max(0, Math.floor(settings.maxAgentCount))
|
||||||
|
: Math.max(0, Math.floor(this.pipeline.maxAgentCount));
|
||||||
|
const maxCap = Math.min(this.pipeline.maxSupportedAgentCount, runtimeMaxCap);
|
||||||
const minCap = Math.min(appConfig.simulation.budget.adaptiveCapMin, maxCap);
|
const minCap = Math.min(appConfig.simulation.budget.adaptiveCapMin, maxCap);
|
||||||
const finiteValue = Number.isFinite(value) ? value : minCap;
|
const finiteValue = Number.isFinite(value) ? value : minCap;
|
||||||
return Math.min(maxCap, Math.max(minCap, Math.round(finiteValue)));
|
const nextCap = Math.min(maxCap, Math.max(minCap, Math.round(finiteValue)));
|
||||||
|
return Math.min(
|
||||||
|
nextCap,
|
||||||
|
this.pipeline.ensureMaxAgentCount(nextCap, this.activeCount)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import {
|
|
||||||
estimateExport4KMemory,
|
|
||||||
getAspectFitExport4KDimensions,
|
|
||||||
getExport4KPreflightError,
|
|
||||||
} from './export-4k';
|
|
||||||
|
|
||||||
const generousLimits = {
|
|
||||||
maxBufferSize: Number.MAX_SAFE_INTEGER,
|
|
||||||
maxTextureDimension2D: Number.MAX_SAFE_INTEGER,
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('4K export preflight', () => {
|
|
||||||
it('fits export dimensions inside 4K while preserving source aspect ratio', () => {
|
|
||||||
expect(getAspectFitExport4KDimensions(3840, 2160)).toEqual({
|
|
||||||
width: 3840,
|
|
||||||
height: 2160,
|
|
||||||
});
|
|
||||||
expect(getAspectFitExport4KDimensions(800, 600)).toEqual({
|
|
||||||
width: 2880,
|
|
||||||
height: 2160,
|
|
||||||
});
|
|
||||||
expect(getAspectFitExport4KDimensions(600, 800)).toEqual({
|
|
||||||
width: 1620,
|
|
||||||
height: 2160,
|
|
||||||
});
|
|
||||||
expect(getAspectFitExport4KDimensions(1000, 1000)).toEqual({
|
|
||||||
width: 2160,
|
|
||||||
height: 2160,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('estimates padded readback and temporary memory for the export', () => {
|
|
||||||
const estimate = estimateExport4KMemory();
|
|
||||||
|
|
||||||
expect(estimate.width).toBe(3840);
|
|
||||||
expect(estimate.height).toBe(2160);
|
|
||||||
expect(estimate.bytesPerRow % 256).toBe(0);
|
|
||||||
expect(estimate.estimatedPeakBytes).toBeGreaterThan(estimate.textureBytes);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects GPUs that cannot allocate the export texture', () => {
|
|
||||||
const error = getExport4KPreflightError({
|
|
||||||
limits: {
|
|
||||||
maxBufferSize: Number.MAX_SAFE_INTEGER,
|
|
||||||
maxTextureDimension2D: 2048,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(error?.code).toBe('export-4k-texture-too-large');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects GPUs that cannot allocate the readback buffer', () => {
|
|
||||||
const estimate = estimateExport4KMemory();
|
|
||||||
const error = getExport4KPreflightError({
|
|
||||||
limits: {
|
|
||||||
maxBufferSize: estimate.readbackBufferBytes - 1,
|
|
||||||
maxTextureDimension2D: Number.MAX_SAFE_INTEGER,
|
|
||||||
},
|
|
||||||
estimate,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(error?.code).toBe('export-4k-readback-too-large');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects browser-reported low-memory devices', () => {
|
|
||||||
const error = getExport4KPreflightError({
|
|
||||||
limits: generousLimits,
|
|
||||||
memoryInfo: {
|
|
||||||
deviceMemoryBytes: 2 * 1024 ** 3,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(error?.code).toBe('export-4k-low-device-memory');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('allows export when memory hints are unavailable', () => {
|
|
||||||
expect(
|
|
||||||
getExport4KPreflightError({
|
|
||||||
limits: generousLimits,
|
|
||||||
})
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import { FramePerformance } from './frame-performance';
|
|
||||||
|
|
||||||
const INITIAL_FPS = 60;
|
|
||||||
|
|
||||||
function createScenario() {
|
|
||||||
const performance = new FramePerformance();
|
|
||||||
let time = 0;
|
|
||||||
performance.update(time);
|
|
||||||
const advance = (fps: number): void => {
|
|
||||||
time += 1000 / fps;
|
|
||||||
performance.update(time);
|
|
||||||
};
|
|
||||||
return { performance, advance };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('FramePerformance', () => {
|
|
||||||
it('starts at the adaptive budget target', () => {
|
|
||||||
const { performance } = createScenario();
|
|
||||||
|
|
||||||
expect(performance.smoothedFps).toBe(INITIAL_FPS);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('smooths measured frame rates', () => {
|
|
||||||
const { performance, advance } = createScenario();
|
|
||||||
|
|
||||||
advance(120);
|
|
||||||
|
|
||||||
expect(performance.smoothedFps).toBeGreaterThan(INITIAL_FPS);
|
|
||||||
expect(performance.smoothedFps).toBeLessThan(120);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores long gaps before smoothing resumes', () => {
|
|
||||||
const performance = new FramePerformance();
|
|
||||||
performance.update(0);
|
|
||||||
performance.update(2_000);
|
|
||||||
|
|
||||||
expect(performance.smoothedFps).toBe(INITIAL_FPS);
|
|
||||||
|
|
||||||
performance.update(2_000 + 1000 / 30);
|
|
||||||
|
|
||||||
expect(performance.smoothedFps).toBeLessThan(INITIAL_FPS);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -2,6 +2,9 @@ import { appConfig } from '../config';
|
||||||
|
|
||||||
export class FramePerformance {
|
export class FramePerformance {
|
||||||
public smoothedFps = appConfig.simulation.budget.initialFps;
|
public smoothedFps = appConfig.simulation.budget.initialFps;
|
||||||
|
public measuredFps = 0;
|
||||||
|
public frameDeltaSeconds = 0;
|
||||||
|
public measuredFrameTimeMs = 0;
|
||||||
|
|
||||||
private previousFrameTime: DOMHighResTimeStamp | null = null;
|
private previousFrameTime: DOMHighResTimeStamp | null = null;
|
||||||
|
|
||||||
|
|
@ -13,14 +16,18 @@ export class FramePerformance {
|
||||||
}
|
}
|
||||||
|
|
||||||
const deltaSeconds = (time - previous) / 1000;
|
const deltaSeconds = (time - previous) / 1000;
|
||||||
if (
|
if (deltaSeconds <= 0) {
|
||||||
deltaSeconds <= 0 ||
|
|
||||||
deltaSeconds > appConfig.simulation.budget.frameGapResetSeconds
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fps = 1 / deltaSeconds;
|
const fps = 1 / deltaSeconds;
|
||||||
|
this.frameDeltaSeconds = deltaSeconds;
|
||||||
|
this.measuredFrameTimeMs = deltaSeconds * 1000;
|
||||||
|
this.measuredFps = fps;
|
||||||
|
if (deltaSeconds > appConfig.simulation.budget.frameGapResetSeconds) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.smoothedFps =
|
this.smoothedFps =
|
||||||
this.smoothedFps * appConfig.simulation.budget.fpsSmoothingRetain +
|
this.smoothedFps * appConfig.simulation.budget.fpsSmoothingRetain +
|
||||||
fps * appConfig.simulation.budget.fpsSmoothingNew;
|
fps * appConfig.simulation.budget.fpsSmoothingNew;
|
||||||
|
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
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(');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
import { readFileSync } from 'node:fs';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
const simulationFrameSource = readFileSync(
|
|
||||||
join(process.cwd(), 'src/game-loop/simulation-frame.ts'),
|
|
||||||
'utf8'
|
|
||||||
);
|
|
||||||
const simulationTexturesSource = readFileSync(
|
|
||||||
join(process.cwd(), 'src/game-loop/simulation-textures.ts'),
|
|
||||||
'utf8'
|
|
||||||
);
|
|
||||||
const resizableTextureSource = readFileSync(
|
|
||||||
join(process.cwd(), 'src/utils/graphics/resizable-texture.ts'),
|
|
||||||
'utf8'
|
|
||||||
);
|
|
||||||
|
|
||||||
const getRenderStepSource = () => {
|
|
||||||
const start = simulationFrameSource.indexOf(
|
|
||||||
'const commandEncoder = this.device.createCommandEncoder();'
|
|
||||||
);
|
|
||||||
const swapCall = ' this.textures.swapBrushEffectMaps();';
|
|
||||||
const end = simulationFrameSource.indexOf(swapCall, start) + swapCall.length;
|
|
||||||
|
|
||||||
if (start < 0 || end < 0) {
|
|
||||||
throw new Error('Could not find the simulation frame execution body');
|
|
||||||
}
|
|
||||||
|
|
||||||
return simulationFrameSource.slice(start, end);
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('GameLoop ping-pong texture flow', () => {
|
|
||||||
it('copies only the trail map with a GPU texture copy and swaps source/influence references after diffusion', () => {
|
|
||||||
const renderStepSource = getRenderStepSource();
|
|
||||||
|
|
||||||
expect(renderStepSource).not.toContain('copyPipeline.execute');
|
|
||||||
expect(renderStepSource).toContain('this.textures.copyTrailMapAToB(commandEncoder);');
|
|
||||||
expect(simulationTexturesSource).toMatch(
|
|
||||||
/commandEncoder\.copyTextureToTexture\([\s\S]*this\.trailMapA\.getTexture\(\)[\s\S]*this\.trailMapB\.getTexture\(\)[\s\S]*width: size\[0\][\s\S]*height: size\[1\][\s\S]*\);/
|
|
||||||
);
|
|
||||||
expect(renderStepSource).toMatch(
|
|
||||||
/this\.pipelines\.diffusionPipeline\.execute\([\s\S]*this\.textures\.sourceMapA\.getTextureView\(\)[\s\S]*this\.textures\.sourceMapB\.getTextureView\(\)[\s\S]*\);[\s\S]*this\.pipelines\.brushEffectDiffusionPipeline\.execute\([\s\S]*this\.textures\.influenceMapA\.getTextureView\(\)[\s\S]*this\.textures\.influenceMapB\.getTextureView\(\)[\s\S]*\);[\s\S]*this\.device\.queue\.submit\(\[commandEncoder\.finish\(\)\]\);[\s\S]*this\.textures\.swapBrushEffectMaps\(\);/
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps resizable textures usable for render, shader, and GPU copy paths', () => {
|
|
||||||
expect(resizableTextureSource).toContain('public getTexture(): GPUTexture');
|
|
||||||
expect(resizableTextureSource).toContain('GPUTextureUsage.COPY_SRC');
|
|
||||||
expect(resizableTextureSource).toContain('GPUTextureUsage.COPY_DST');
|
|
||||||
expect(resizableTextureSource).toContain('commandEncoder.copyTextureToTexture(');
|
|
||||||
expect(simulationTexturesSource).not.toContain('private readonly copyPipeline');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps ping-pong texture references mutable and swaps A/B identities', () => {
|
|
||||||
expect(simulationTexturesSource).toContain('public sourceMapA: ResizableTexture;');
|
|
||||||
expect(simulationTexturesSource).toContain('public sourceMapB: ResizableTexture;');
|
|
||||||
expect(simulationTexturesSource).toContain('public influenceMapA: ResizableTexture;');
|
|
||||||
expect(simulationTexturesSource).toContain('public influenceMapB: ResizableTexture;');
|
|
||||||
expect(simulationTexturesSource).toContain('public swapBrushEffectMaps(): void');
|
|
||||||
expect(simulationTexturesSource).toContain(
|
|
||||||
'[this.sourceMapA, this.sourceMapB] = [this.sourceMapB, this.sourceMapA];'
|
|
||||||
);
|
|
||||||
expect(simulationTexturesSource).toContain(
|
|
||||||
'[this.influenceMapA, this.influenceMapB] = [this.influenceMapB, this.influenceMapA];'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -20,7 +20,7 @@ interface FrameParameters extends RenderInputs {
|
||||||
deltaTime: number;
|
deltaTime: number;
|
||||||
canvasSize: vec2;
|
canvasSize: vec2;
|
||||||
activeAgentCount: number;
|
activeAgentCount: number;
|
||||||
devicePixelRatio: number;
|
canvasPixelRatio: number;
|
||||||
introProgress: number;
|
introProgress: number;
|
||||||
selectedColorIndex: number;
|
selectedColorIndex: number;
|
||||||
isErasing: boolean;
|
isErasing: boolean;
|
||||||
|
|
@ -58,7 +58,7 @@ export class GameLoopResources {
|
||||||
|
|
||||||
this.agentGenerationPipeline = new AgentGenerationPipeline(
|
this.agentGenerationPipeline = new AgentGenerationPipeline(
|
||||||
this.device,
|
this.device,
|
||||||
appConfig.simulation.budget.adaptiveCapMax
|
Math.min(settings.maxAgentCount, appConfig.simulation.budget.adaptiveCapInitial)
|
||||||
);
|
);
|
||||||
|
|
||||||
this.agentPipeline = new AgentPipeline(
|
this.agentPipeline = new AgentPipeline(
|
||||||
|
|
@ -100,7 +100,7 @@ export class GameLoopResources {
|
||||||
deltaTime,
|
deltaTime,
|
||||||
canvasSize,
|
canvasSize,
|
||||||
activeAgentCount,
|
activeAgentCount,
|
||||||
devicePixelRatio,
|
canvasPixelRatio,
|
||||||
introProgress,
|
introProgress,
|
||||||
selectedColorIndex,
|
selectedColorIndex,
|
||||||
channelColors,
|
channelColors,
|
||||||
|
|
@ -125,7 +125,7 @@ export class GameLoopResources {
|
||||||
});
|
});
|
||||||
this.brushPipeline.setParameters({
|
this.brushPipeline.setParameters({
|
||||||
...settings,
|
...settings,
|
||||||
devicePixelRatio,
|
pixelRatio: canvasPixelRatio,
|
||||||
selectedColorIndex,
|
selectedColorIndex,
|
||||||
});
|
});
|
||||||
this.diffusionPipeline.setParameters(settings);
|
this.diffusionPipeline.setParameters(settings);
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,13 @@ import { appConfig } from '../config';
|
||||||
import { activeVibe, settings } from '../settings';
|
import { activeVibe, settings } from '../settings';
|
||||||
import { DeltaTimeCalculator } from '../utils/delta-time-calculator';
|
import { DeltaTimeCalculator } from '../utils/delta-time-calculator';
|
||||||
import { AgentPopulation } from './agent-population';
|
import { AgentPopulation } from './agent-population';
|
||||||
|
import { DevStatsOverlay } from './dev-stats-overlay';
|
||||||
import { EraserPreview } from './eraser-preview';
|
import { EraserPreview } from './eraser-preview';
|
||||||
import { Export4KRenderer } from './export-4k-renderer';
|
import { Export4KRenderer } from './export-4k-renderer';
|
||||||
import { FramePerformance } from './frame-performance';
|
import { FramePerformance } from './frame-performance';
|
||||||
import { GameLoopResources } from './game-loop-resources';
|
import { GameLoopResources } from './game-loop-resources';
|
||||||
import { GardenUi } from './game-loop-types';
|
import { GardenUi } from './game-loop-types';
|
||||||
|
import { getInternalRenderSize } from './internal-render-size';
|
||||||
import { IntroPrompt } from './intro-prompt';
|
import { IntroPrompt } from './intro-prompt';
|
||||||
import { GardenPointerInput } from './pointer-input';
|
import { GardenPointerInput } from './pointer-input';
|
||||||
import { RenderInputCache } from './render-input-cache';
|
import { RenderInputCache } from './render-input-cache';
|
||||||
|
|
@ -26,11 +28,11 @@ export default class GameLoop {
|
||||||
private readonly agentPopulation: AgentPopulation;
|
private readonly agentPopulation: AgentPopulation;
|
||||||
private readonly export4KRenderer: Export4KRenderer;
|
private readonly export4KRenderer: Export4KRenderer;
|
||||||
private readonly framePerformance = new FramePerformance();
|
private readonly framePerformance = new FramePerformance();
|
||||||
|
private readonly devStatsOverlay: DevStatsOverlay | null;
|
||||||
private readonly toolbarContrastMonitor: ToolbarContrastMonitor;
|
private readonly toolbarContrastMonitor: ToolbarContrastMonitor;
|
||||||
private readonly seedValue = Math.floor(Math.random() * 0xffffffff);
|
private readonly seedValue = Math.floor(Math.random() * 0xffffffff);
|
||||||
private readonly seed = this.seedValue.toString(16);
|
private readonly seed = this.seedValue.toString(16);
|
||||||
private readonly resizeListener = this.resize.bind(this);
|
private readonly resizeListener = this.resize.bind(this);
|
||||||
private readonly keydownListener: (event: KeyboardEvent) => void;
|
|
||||||
|
|
||||||
private pendingIntroResizeAt: DOMHighResTimeStamp | null = null;
|
private pendingIntroResizeAt: DOMHighResTimeStamp | null = null;
|
||||||
private hasFinished = false;
|
private hasFinished = false;
|
||||||
|
|
@ -38,11 +40,14 @@ export default class GameLoop {
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly canvas: HTMLCanvasElement,
|
private readonly canvas: HTMLCanvasElement,
|
||||||
device: GPUDevice,
|
private readonly device: GPUDevice,
|
||||||
private readonly deltaTimeCalculator: DeltaTimeCalculator,
|
private readonly deltaTimeCalculator: DeltaTimeCalculator,
|
||||||
ui: GardenUi
|
ui: GardenUi
|
||||||
) {
|
) {
|
||||||
this.resize();
|
this.resize();
|
||||||
|
this.devStatsOverlay = import.meta.env.DEV
|
||||||
|
? new DevStatsOverlay(canvas.parentElement ?? document.body)
|
||||||
|
: null;
|
||||||
this.resources = new GameLoopResources(canvas, device, this.canvasSize);
|
this.resources = new GameLoopResources(canvas, device, this.canvasSize);
|
||||||
this.introPrompt = new IntroPrompt(ui.prompt);
|
this.introPrompt = new IntroPrompt(ui.prompt);
|
||||||
this.eraserPreview = new EraserPreview(canvas, ui.eraserPreview);
|
this.eraserPreview = new EraserPreview(canvas, ui.eraserPreview);
|
||||||
|
|
@ -50,7 +55,7 @@ export default class GameLoop {
|
||||||
this.agentPopulation = new AgentPopulation(
|
this.agentPopulation = new AgentPopulation(
|
||||||
this.resources.agentGenerationPipeline,
|
this.resources.agentGenerationPipeline,
|
||||||
this.seedValue,
|
this.seedValue,
|
||||||
() => this.devicePixelRatio
|
() => this.canvasPixelRatio
|
||||||
);
|
);
|
||||||
this.agentPopulation.initializeIntroAgents(this.canvasSize);
|
this.agentPopulation.initializeIntroAgents(this.canvasSize);
|
||||||
this.pointerInput = new GardenPointerInput({
|
this.pointerInput = new GardenPointerInput({
|
||||||
|
|
@ -60,7 +65,7 @@ export default class GameLoop {
|
||||||
eraserAgentPipeline: this.resources.eraserAgentPipeline,
|
eraserAgentPipeline: this.resources.eraserAgentPipeline,
|
||||||
eraserTexturePipeline: this.resources.eraserTexturePipeline,
|
eraserTexturePipeline: this.resources.eraserTexturePipeline,
|
||||||
eraserPreview: this.eraserPreview,
|
eraserPreview: this.eraserPreview,
|
||||||
getDevicePixelRatio: () => this.devicePixelRatio,
|
getCanvasPixelRatio: () => this.canvasPixelRatio,
|
||||||
getMirrorSegmentCount: () => this.mirrorSegmentCount,
|
getMirrorSegmentCount: () => this.mirrorSegmentCount,
|
||||||
onStartDrawing: () => this.introPrompt.markStartedDrawing(),
|
onStartDrawing: () => this.introPrompt.markStartedDrawing(),
|
||||||
onEraseGestureEnded: () => this.agentPopulation.requestCompactionAfterErase(),
|
onEraseGestureEnded: () => this.agentPopulation.requestCompactionAfterErase(),
|
||||||
|
|
@ -79,13 +84,8 @@ export default class GameLoop {
|
||||||
getSourceTextureView: () => this.resources.textures.sourceMapA.getTextureView(),
|
getSourceTextureView: () => this.resources.textures.sourceMapA.getTextureView(),
|
||||||
getVibeId: () => activeVibe.id,
|
getVibeId: () => activeVibe.id,
|
||||||
});
|
});
|
||||||
this.keydownListener = () => {
|
|
||||||
this.audio.start(activeVibe, { userGesture: true });
|
|
||||||
this.introPrompt.complete();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('resize', this.resizeListener);
|
window.addEventListener('resize', this.resizeListener);
|
||||||
window.addEventListener('keydown', this.keydownListener, { once: true });
|
|
||||||
this.pointerInput.attach();
|
this.pointerInput.attach();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -132,15 +132,16 @@ export default class GameLoop {
|
||||||
await this.finished.promise;
|
await this.finished.promise;
|
||||||
|
|
||||||
window.removeEventListener('resize', this.resizeListener);
|
window.removeEventListener('resize', this.resizeListener);
|
||||||
window.removeEventListener('keydown', this.keydownListener);
|
|
||||||
this.pointerInput.detach();
|
this.pointerInput.detach();
|
||||||
|
this.devStatsOverlay?.destroy();
|
||||||
this.toolbarContrastMonitor.destroy();
|
this.toolbarContrastMonitor.destroy();
|
||||||
this.introPrompt.destroy();
|
this.introPrompt.destroy();
|
||||||
|
await this.agentPopulation.waitForCompaction();
|
||||||
this.resources.destroy();
|
this.resources.destroy();
|
||||||
await this.audio.destroy();
|
await this.audio.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly render = async (time: DOMHighResTimeStamp) => {
|
private readonly render = (time: DOMHighResTimeStamp) => {
|
||||||
if (this.hasFinished) {
|
if (this.hasFinished) {
|
||||||
this.finished.resolve();
|
this.finished.resolve();
|
||||||
return;
|
return;
|
||||||
|
|
@ -148,7 +149,10 @@ export default class GameLoop {
|
||||||
|
|
||||||
const deltaTime = this.deltaTimeCalculator.calculateDeltaTimeInSeconds(time);
|
const deltaTime = this.deltaTimeCalculator.calculateDeltaTimeInSeconds(time);
|
||||||
this.framePerformance.update(time);
|
this.framePerformance.update(time);
|
||||||
this.agentPopulation.growBudget(deltaTime, this.framePerformance.smoothedFps);
|
this.agentPopulation.growBudget(
|
||||||
|
this.framePerformance.frameDeltaSeconds,
|
||||||
|
this.framePerformance.smoothedFps
|
||||||
|
);
|
||||||
this.introPrompt.update(this.pendingIntroResizeAt === null ? deltaTime : 0);
|
this.introPrompt.update(this.pendingIntroResizeAt === null ? deltaTime : 0);
|
||||||
this.resize();
|
this.resize();
|
||||||
this.resizeSimulationToCanvas(time);
|
this.resizeSimulationToCanvas(time);
|
||||||
|
|
@ -156,8 +160,8 @@ export default class GameLoop {
|
||||||
|
|
||||||
const { channelColors, backgroundColor } = this.renderInputs.get();
|
const { channelColors, backgroundColor } = this.renderInputs.get();
|
||||||
const introProgress = this.introPrompt.progress;
|
const introProgress = this.introPrompt.progress;
|
||||||
const devicePixelRatio = this.devicePixelRatio;
|
const canvasPixelRatio = this.canvasPixelRatio;
|
||||||
const eraserPixelSize = settings.eraserSize * devicePixelRatio;
|
const eraserPixelSize = settings.eraserSize * canvasPixelRatio;
|
||||||
const isErasing = this.pointerInput.isEraseMode;
|
const isErasing = this.pointerInput.isEraseMode;
|
||||||
const accentColor = channelColors[settings.selectedColorIndex] ?? channelColors[0];
|
const accentColor = channelColors[settings.selectedColorIndex] ?? channelColors[0];
|
||||||
this.renderInputs.updateAccentColor(accentColor);
|
this.renderInputs.updateAccentColor(accentColor);
|
||||||
|
|
@ -171,7 +175,7 @@ export default class GameLoop {
|
||||||
deltaTime,
|
deltaTime,
|
||||||
canvasSize: this.canvasSize,
|
canvasSize: this.canvasSize,
|
||||||
activeAgentCount: this.agentPopulation.activeAgentCount,
|
activeAgentCount: this.agentPopulation.activeAgentCount,
|
||||||
devicePixelRatio,
|
canvasPixelRatio,
|
||||||
introProgress,
|
introProgress,
|
||||||
selectedColorIndex: settings.selectedColorIndex,
|
selectedColorIndex: settings.selectedColorIndex,
|
||||||
isErasing,
|
isErasing,
|
||||||
|
|
@ -186,20 +190,28 @@ export default class GameLoop {
|
||||||
);
|
);
|
||||||
|
|
||||||
this.pointerInput.clearSwipesIfIdle();
|
this.pointerInput.clearSwipesIfIdle();
|
||||||
await this.agentPopulation.compactAfterErase(this.pointerInput.isSwipeActive);
|
this.agentPopulation.compactAfterErase(this.pointerInput.isSwipeActive);
|
||||||
|
this.devStatsOverlay?.update({
|
||||||
|
time,
|
||||||
|
fps: this.framePerformance.measuredFps,
|
||||||
|
agentCount: this.agentPopulation.activeAgentCount,
|
||||||
|
frameTimeMs: this.framePerformance.measuredFrameTimeMs,
|
||||||
|
renderWidth: this.canvas.width,
|
||||||
|
renderHeight: this.canvas.height,
|
||||||
|
});
|
||||||
|
|
||||||
requestAnimationFrame(this.render);
|
requestAnimationFrame(this.render);
|
||||||
};
|
};
|
||||||
|
|
||||||
private resize(): void {
|
private resize(): void {
|
||||||
const width = Math.max(
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
1,
|
const { width, height } = getInternalRenderSize({
|
||||||
Math.floor(this.canvas.clientWidth * this.devicePixelRatio)
|
clientHeight: rect.height || this.canvas.clientHeight,
|
||||||
);
|
clientWidth: rect.width || this.canvas.clientWidth,
|
||||||
const height = Math.max(
|
maxPixelScale: appConfig.simulation.maxDevicePixelRatio,
|
||||||
1,
|
maxTextureDimension: this.device.limits.maxTextureDimension2D,
|
||||||
Math.floor(this.canvas.clientHeight * this.devicePixelRatio)
|
targetAreaMegapixels: settings.internalRenderAreaMegapixels,
|
||||||
);
|
});
|
||||||
|
|
||||||
if (this.canvas.width === width && this.canvas.height === height) {
|
if (this.canvas.width === width && this.canvas.height === height) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -249,8 +261,11 @@ export default class GameLoop {
|
||||||
return vec2.fromValues(this.canvas.width, this.canvas.height);
|
return vec2.fromValues(this.canvas.width, this.canvas.height);
|
||||||
}
|
}
|
||||||
|
|
||||||
private get devicePixelRatio(): number {
|
private get canvasPixelRatio(): number {
|
||||||
const ratio = window.devicePixelRatio;
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
|
const xScale = rect.width > 0 ? this.canvas.width / rect.width : 1;
|
||||||
|
const yScale = rect.height > 0 ? this.canvas.height / rect.height : xScale;
|
||||||
|
const ratio = (xScale + yScale) / 2;
|
||||||
return Number.isFinite(ratio) && ratio > 0 ? ratio : 1;
|
return Number.isFinite(ratio) && ratio > 0 ? ratio : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
import { appConfig } from '../config';
|
|
||||||
import { IntroPrompt } from './intro-prompt';
|
|
||||||
|
|
||||||
const createPromptElement = (): HTMLElement =>
|
|
||||||
({
|
|
||||||
classList: {
|
|
||||||
add: vi.fn(),
|
|
||||||
contains: vi.fn(() => false),
|
|
||||||
remove: vi.fn(),
|
|
||||||
},
|
|
||||||
innerHTML: '',
|
|
||||||
replaceChildren: vi.fn(),
|
|
||||||
}) as unknown as HTMLElement;
|
|
||||||
|
|
||||||
describe('IntroPrompt', () => {
|
|
||||||
it('advances progress from simulation delta time', () => {
|
|
||||||
const prompt = new IntroPrompt(createPromptElement());
|
|
||||||
|
|
||||||
prompt.update(appConfig.simulation.intro.durationSeconds / 2);
|
|
||||||
|
|
||||||
expect(prompt.progress).toBeCloseTo(0.5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('caps progress when the intro completes', () => {
|
|
||||||
const prompt = new IntroPrompt(createPromptElement());
|
|
||||||
|
|
||||||
prompt.update(appConfig.simulation.intro.durationSeconds * 2);
|
|
||||||
prompt.update(appConfig.simulation.intro.durationSeconds);
|
|
||||||
|
|
||||||
expect(prompt.progress).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('can rewind an active intro to leave a minimum resize paint window', () => {
|
|
||||||
const prompt = new IntroPrompt(createPromptElement());
|
|
||||||
|
|
||||||
prompt.update(appConfig.simulation.intro.durationSeconds * 0.95);
|
|
||||||
prompt.rewindToLeaveRemainingTime(appConfig.simulation.intro.durationSeconds * 0.25);
|
|
||||||
|
|
||||||
expect(prompt.progress).toBeCloseTo(0.75);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('allows title regeneration only before drawing or completion', () => {
|
|
||||||
const prompt = new IntroPrompt(createPromptElement());
|
|
||||||
|
|
||||||
expect(prompt.shouldRegenerateTitleOnResize).toBe(true);
|
|
||||||
|
|
||||||
prompt.markStartedDrawing();
|
|
||||||
|
|
||||||
expect(prompt.shouldRegenerateTitleOnResize).toBe(false);
|
|
||||||
|
|
||||||
const completedPrompt = new IntroPrompt(createPromptElement());
|
|
||||||
completedPrompt.complete();
|
|
||||||
|
|
||||||
expect(completedPrompt.shouldRegenerateTitleOnResize).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
import { AGENT_FLOAT_COUNT } from '../pipelines/agents/agent-generation/agent';
|
|
||||||
import { createIntroTitleAgents } from './intro-title-agents';
|
|
||||||
|
|
||||||
const installCanvasMask = () => {
|
|
||||||
Object.defineProperty(globalThis, 'document', {
|
|
||||||
configurable: true,
|
|
||||||
value: {
|
|
||||||
createElement: vi.fn(() => {
|
|
||||||
const canvas = {
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
getContext: vi.fn(() => ({
|
|
||||||
clearRect: vi.fn(),
|
|
||||||
fillStyle: '',
|
|
||||||
fillText: vi.fn(),
|
|
||||||
font: '',
|
|
||||||
getImageData: vi.fn(() => {
|
|
||||||
const data = new Uint8ClampedArray(canvas.width * canvas.height * 4);
|
|
||||||
for (let i = 3; i < data.length; i += 4) {
|
|
||||||
data[i] = 255;
|
|
||||||
}
|
|
||||||
return { data };
|
|
||||||
}),
|
|
||||||
lineJoin: '',
|
|
||||||
lineWidth: 0,
|
|
||||||
measureText: vi.fn((text: string) => ({
|
|
||||||
actualBoundingBoxAscent: 10,
|
|
||||||
actualBoundingBoxDescent: 4,
|
|
||||||
width: text.length * 10,
|
|
||||||
})),
|
|
||||||
strokeStyle: '',
|
|
||||||
strokeText: vi.fn(),
|
|
||||||
textAlign: '',
|
|
||||||
textBaseline: '',
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
|
|
||||||
return canvas;
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('createIntroTitleAgents', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
installCanvasMask();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
Reflect.deleteProperty(globalThis, 'document');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates deterministic agents for the same seed and progress', () => {
|
|
||||||
const first = createIntroTitleAgents({
|
|
||||||
count: 4,
|
|
||||||
height: 32,
|
|
||||||
progress: 0.4,
|
|
||||||
seed: 123,
|
|
||||||
width: 64,
|
|
||||||
});
|
|
||||||
const second = createIntroTitleAgents({
|
|
||||||
count: 4,
|
|
||||||
height: 32,
|
|
||||||
progress: 0.4,
|
|
||||||
seed: 123,
|
|
||||||
width: 64,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(Array.from(first)).toEqual(Array.from(second));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('preserves targets while advancing positions for a later intro progress', () => {
|
|
||||||
const initial = createIntroTitleAgents({
|
|
||||||
count: 1,
|
|
||||||
height: 32,
|
|
||||||
progress: 0,
|
|
||||||
seed: 456,
|
|
||||||
width: 64,
|
|
||||||
});
|
|
||||||
const later = createIntroTitleAgents({
|
|
||||||
count: 1,
|
|
||||||
height: 32,
|
|
||||||
progress: 0.8,
|
|
||||||
seed: 456,
|
|
||||||
width: 64,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(later[0]).not.toBe(initial[0]);
|
|
||||||
expect(later[1]).not.toBe(initial[1]);
|
|
||||||
expect(later[4]).toBe(initial[4]);
|
|
||||||
expect(later[5]).toBe(initial[5]);
|
|
||||||
expect(later[7]).toBe(initial[7]);
|
|
||||||
expect(later.length).toBe(AGENT_FLOAT_COUNT);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { appConfig } from '../config';
|
import { appConfig } from '../config';
|
||||||
import { AGENT_FLOAT_COUNT } from '../pipelines/agents/agent-generation/agent';
|
import { AGENT_FLOAT_COUNT } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
|
||||||
|
import { clamp, easeOutQuad, mix, mixAngle, smoothstep } from '../utils/math';
|
||||||
|
|
||||||
interface IntroTitlePoint {
|
interface IntroTitlePoint {
|
||||||
x: number;
|
x: number;
|
||||||
|
|
@ -398,19 +399,6 @@ const createSeededRandom = (seed: number): RandomSource => {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const mix = (from: number, to: number, amount: number): number =>
|
|
||||||
from + (to - from) * amount;
|
|
||||||
|
|
||||||
const mixAngle = (from: number, to: number, amount: number): number => {
|
|
||||||
const delta = Math.atan2(Math.sin(to - from), Math.cos(to - from));
|
|
||||||
return from + delta * amount;
|
|
||||||
};
|
|
||||||
|
|
||||||
const smoothstep = (edge0: number, edge1: number, value: number): number => {
|
|
||||||
const t = clamp((value - edge0) / (edge1 - edge0), 0, 1);
|
|
||||||
return t * t * (3 - 2 * t);
|
|
||||||
};
|
|
||||||
|
|
||||||
const easePathProgress = (amount: number): number => {
|
const easePathProgress = (amount: number): number => {
|
||||||
if (appConfig.simulation.intro.pathEasing === 'linear') {
|
if (appConfig.simulation.intro.pathEasing === 'linear') {
|
||||||
return amount;
|
return amount;
|
||||||
|
|
@ -418,10 +406,3 @@ const easePathProgress = (amount: number): number => {
|
||||||
|
|
||||||
return easeOutQuad(amount);
|
return easeOutQuad(amount);
|
||||||
};
|
};
|
||||||
|
|
||||||
const easeOutQuad = (value: number): number => value * (2 - value);
|
|
||||||
|
|
||||||
const clamp = (value: number, min: number, max: number): number => {
|
|
||||||
const safeValue = Number.isFinite(value) ? value : min;
|
|
||||||
return Math.min(max, Math.max(min, safeValue));
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,327 +0,0 @@
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
type PointerListener = (event: PointerEvent) => void;
|
|
||||||
|
|
||||||
const makePointerEvent = (
|
|
||||||
type: string,
|
|
||||||
event: Partial<PointerEvent> = {}
|
|
||||||
): 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<number>): Array<number> => Array.from(point);
|
|
||||||
|
|
||||||
class FakeCanvas {
|
|
||||||
public readonly capturedPointerIds: Array<number> = [];
|
|
||||||
public readonly releasedPointerIds: Array<number> = [];
|
|
||||||
public width = 300;
|
|
||||||
public height = 200;
|
|
||||||
|
|
||||||
private readonly listeners = new Map<string, Set<PointerListener>>();
|
|
||||||
|
|
||||||
public addEventListener(
|
|
||||||
type: string,
|
|
||||||
listener: EventListenerOrEventListenerObject
|
|
||||||
): void {
|
|
||||||
const listeners = this.listeners.get(type) ?? new Set<PointerListener>();
|
|
||||||
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<PointerEvent> = {}): 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 ({
|
|
||||||
devicePixelRatio = 1,
|
|
||||||
}: { devicePixelRatio?: number } = {}) => {
|
|
||||||
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(),
|
|
||||||
};
|
|
||||||
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,
|
|
||||||
getDevicePixelRatio: () => devicePixelRatio,
|
|
||||||
getMirrorSegmentCount: () => 1,
|
|
||||||
onEraseGestureEnded,
|
|
||||||
onStartDrawing,
|
|
||||||
spawnStrokeAgents,
|
|
||||||
} as unknown as ConstructorParameters<typeof GardenPointerInput>[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(audio.stroke).not.toHaveBeenCalled();
|
|
||||||
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('passes normalized audio geometry context with stroke events', async () => {
|
|
||||||
const { audio, canvas } = await createPointerInput();
|
|
||||||
|
|
||||||
canvas.dispatchPointerEvent('pointerdown', { pointerId: 9, timeStamp: 100 });
|
|
||||||
canvas.dispatchPointerEvent('pointermove', {
|
|
||||||
clientX: 40,
|
|
||||||
clientY: 50,
|
|
||||||
pointerId: 9,
|
|
||||||
timeStamp: 150,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(audio.stroke).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
canvasSize: [300, 200],
|
|
||||||
elapsedSeconds: 0.05,
|
|
||||||
from: expect.anything(),
|
|
||||||
isErasing: false,
|
|
||||||
to: expect.anything(),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const stroke = audio.stroke.mock.calls[0][0];
|
|
||||||
expect(toPoint(stroke.from)).toEqual([10, 20]);
|
|
||||||
expect(toPoint(stroke.to)).toEqual([40, 50]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps pointer geometry in backing pixels on high-DPR canvases', async () => {
|
|
||||||
const { audio, brushPipeline, canvas } = await createPointerInput({
|
|
||||||
devicePixelRatio: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
canvas.dispatchPointerEvent('pointerdown', {
|
|
||||||
clientX: 10,
|
|
||||||
clientY: 20,
|
|
||||||
pointerId: 9,
|
|
||||||
timeStamp: 100,
|
|
||||||
});
|
|
||||||
canvas.dispatchPointerEvent('pointermove', {
|
|
||||||
clientX: 40,
|
|
||||||
clientY: 50,
|
|
||||||
pointerId: 9,
|
|
||||||
timeStamp: 150,
|
|
||||||
});
|
|
||||||
|
|
||||||
const firstStroke = audio.stroke.mock.calls[0][0];
|
|
||||||
expect(toPoint(firstStroke.from)).toEqual([20, 40]);
|
|
||||||
expect(toPoint(firstStroke.to)).toEqual([80, 100]);
|
|
||||||
expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[0][0])).toEqual([20, 40]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('caps curve tessellation with the brush curve resolution setting', async () => {
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { GardenAudio } from '../audio/garden-audio';
|
||||||
import { appConfig } from '../config';
|
import { appConfig } from '../config';
|
||||||
import {
|
import {
|
||||||
BrushPipeline,
|
BrushPipeline,
|
||||||
getSafeDevicePixelRatio,
|
getSafePixelRatio,
|
||||||
} from '../pipelines/brush/brush-pipeline';
|
} from '../pipelines/brush/brush-pipeline';
|
||||||
import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline';
|
import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline';
|
||||||
import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline';
|
import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline';
|
||||||
|
|
@ -19,7 +19,7 @@ interface GardenPointerInputOptions {
|
||||||
eraserAgentPipeline: EraserAgentPipeline;
|
eraserAgentPipeline: EraserAgentPipeline;
|
||||||
eraserTexturePipeline: EraserTexturePipeline;
|
eraserTexturePipeline: EraserTexturePipeline;
|
||||||
eraserPreview: EraserPreview;
|
eraserPreview: EraserPreview;
|
||||||
getDevicePixelRatio: () => number;
|
getCanvasPixelRatio: () => number;
|
||||||
getMirrorSegmentCount: () => number;
|
getMirrorSegmentCount: () => number;
|
||||||
onStartDrawing: () => void;
|
onStartDrawing: () => void;
|
||||||
onEraseGestureEnded: () => void;
|
onEraseGestureEnded: () => void;
|
||||||
|
|
@ -106,9 +106,7 @@ export class GardenPointerInput {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.pointerType !== 'touch') {
|
|
||||||
this.options.audio.start(activeVibe, { userGesture: true });
|
this.options.audio.start(activeVibe, { userGesture: true });
|
||||||
}
|
|
||||||
this.options.audio.beginGesture();
|
this.options.audio.beginGesture();
|
||||||
this.options.onStartDrawing();
|
this.options.onStartDrawing();
|
||||||
this.activePointerId = event.pointerId;
|
this.activePointerId = event.pointerId;
|
||||||
|
|
@ -204,12 +202,11 @@ export class GardenPointerInput {
|
||||||
|
|
||||||
private getCanvasPointerPosition(event: PointerEvent): vec2 {
|
private getCanvasPointerPosition(event: PointerEvent): vec2 {
|
||||||
const rect = this.canvas.getBoundingClientRect();
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
const devicePixelRatio = getSafeDevicePixelRatio(
|
const xScale = getSafePixelRatio(this.canvas.width / rect.width);
|
||||||
this.options.getDevicePixelRatio()
|
const yScale = getSafePixelRatio(this.canvas.height / rect.height);
|
||||||
);
|
|
||||||
return vec2.fromValues(
|
return vec2.fromValues(
|
||||||
(event.clientX - rect.left) * devicePixelRatio,
|
(event.clientX - rect.left) * xScale,
|
||||||
(event.clientY - rect.top) * devicePixelRatio
|
(event.clientY - rect.top) * yScale
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -219,7 +216,7 @@ export class GardenPointerInput {
|
||||||
if (
|
if (
|
||||||
previousSample !== undefined &&
|
previousSample !== undefined &&
|
||||||
vec2.squaredDistance(previousSample, position) <=
|
vec2.squaredDistance(previousSample, position) <=
|
||||||
getBrushSmoothingDistanceSquared(this.options.getDevicePixelRatio())
|
getBrushSmoothingDistanceSquared(this.options.getCanvasPixelRatio())
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -253,13 +250,15 @@ export class GardenPointerInput {
|
||||||
|
|
||||||
private addQuadraticBrushSegments(start: vec2, control: vec2, end: vec2): void {
|
private addQuadraticBrushSegments(start: vec2, control: vec2, end: vec2): void {
|
||||||
const curveLength = vec2.distance(start, control) + vec2.distance(control, end);
|
const curveLength = vec2.distance(start, control) + vec2.distance(control, end);
|
||||||
const devicePixelRatio = getSafeDevicePixelRatio(this.options.getDevicePixelRatio());
|
const canvasPixelRatio = getSafePixelRatio(
|
||||||
|
this.options.getCanvasPixelRatio()
|
||||||
|
);
|
||||||
const brushRadius = Math.max(
|
const brushRadius = Math.max(
|
||||||
settings.brushCurveMinBrushRadius * devicePixelRatio,
|
settings.brushCurveMinBrushRadius * canvasPixelRatio,
|
||||||
(settings.brushSize * devicePixelRatio) / 2
|
(settings.brushSize * canvasPixelRatio) / 2
|
||||||
);
|
);
|
||||||
const segmentSpacing = Math.max(
|
const segmentSpacing = Math.max(
|
||||||
settings.brushCurveMinSegmentSpacing * devicePixelRatio,
|
settings.brushCurveMinSegmentSpacing * canvasPixelRatio,
|
||||||
brushRadius * settings.brushCurveSegmentBrushRadiusRatio
|
brushRadius * settings.brushCurveSegmentBrushRadiusRatio
|
||||||
);
|
);
|
||||||
const mirrorSegmentCount = Math.max(1, this.options.getMirrorSegmentCount());
|
const mirrorSegmentCount = Math.max(1, this.options.getMirrorSegmentCount());
|
||||||
|
|
@ -299,7 +298,7 @@ export class GardenPointerInput {
|
||||||
if (
|
if (
|
||||||
this.lastSmoothedBrushPosition !== null &&
|
this.lastSmoothedBrushPosition !== null &&
|
||||||
vec2.squaredDistance(this.lastSmoothedBrushPosition, finalSample) >
|
vec2.squaredDistance(this.lastSmoothedBrushPosition, finalSample) >
|
||||||
getBrushSmoothingDistanceSquared(this.options.getDevicePixelRatio())
|
getBrushSmoothingDistanceSquared(this.options.getCanvasPixelRatio())
|
||||||
) {
|
) {
|
||||||
this.addMirroredBrushSegment(this.lastSmoothedBrushPosition, finalSample);
|
this.addMirroredBrushSegment(this.lastSmoothedBrushPosition, finalSample);
|
||||||
}
|
}
|
||||||
|
|
@ -381,11 +380,11 @@ const getBrushCurveResolution = (): number => {
|
||||||
return Math.max(1, Math.floor(resolution));
|
return Math.max(1, Math.floor(resolution));
|
||||||
};
|
};
|
||||||
|
|
||||||
const getBrushSmoothingDistanceSquared = (devicePixelRatio?: number): number => {
|
const getBrushSmoothingDistanceSquared = (pixelRatio?: number): number => {
|
||||||
const distance = Number.isFinite(settings.brushSmoothingMinSampleDistance)
|
const distance = Number.isFinite(settings.brushSmoothingMinSampleDistance)
|
||||||
? settings.brushSmoothingMinSampleDistance
|
? settings.brushSmoothingMinSampleDistance
|
||||||
: appConfig.defaultSettings.brushSmoothingMinSampleDistance;
|
: appConfig.defaultSettings.brushSmoothingMinSampleDistance;
|
||||||
return Math.max(0, distance * getSafeDevicePixelRatio(devicePixelRatio)) ** 2;
|
return Math.max(0, distance * getSafePixelRatio(pixelRatio)) ** 2;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isSamePointerSample = (left: PointerEvent, right: PointerEvent): boolean =>
|
const isSamePointerSample = (left: PointerEvent, right: PointerEvent): boolean =>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { activeVibe } from '../settings';
|
import { activeVibe } from '../settings';
|
||||||
import { hexToRgb, type VibeId } from '../vibes';
|
import { hexToRgb } from '../utils/hex-to-rgb';
|
||||||
|
import { type VibeId } from '../vibes';
|
||||||
import { RenderInputs } from './game-loop-types';
|
import { RenderInputs } from './game-loop-types';
|
||||||
|
|
||||||
export class RenderInputCache {
|
export class RenderInputCache {
|
||||||
|
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import { getToolbarContrastMetrics } from './toolbar-contrast-monitor';
|
|
||||||
|
|
||||||
const makePixels = (
|
|
||||||
samples: ReadonlyArray<readonly [number, number, number]>
|
|
||||||
): Uint8Array => {
|
|
||||||
const pixels = new Uint8Array(samples.length * 4);
|
|
||||||
samples.forEach(([red, green, blue], index) => {
|
|
||||||
const offset = index * 4;
|
|
||||||
pixels[offset] = red;
|
|
||||||
pixels[offset + 1] = green;
|
|
||||||
pixels[offset + 2] = blue;
|
|
||||||
pixels[offset + 3] = 255;
|
|
||||||
});
|
|
||||||
return pixels;
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('toolbar contrast monitoring', () => {
|
|
||||||
it('leaves the toolbar transparent over dark canvas samples', () => {
|
|
||||||
const metrics = getToolbarContrastMetrics(
|
|
||||||
makePixels(Array.from({ length: 91 }, () => [8, 12, 18])),
|
|
||||||
91,
|
|
||||||
false
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(metrics.backgroundOpacity).toBe(0);
|
|
||||||
expect(metrics.lowContrastRatio).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ramps background opacity as canvas samples get lighter', () => {
|
|
||||||
const dimMetrics = getToolbarContrastMetrics(
|
|
||||||
makePixels(Array.from({ length: 91 }, () => [130, 130, 130])),
|
|
||||||
91,
|
|
||||||
false
|
|
||||||
);
|
|
||||||
const brightMetrics = getToolbarContrastMetrics(
|
|
||||||
makePixels(Array.from({ length: 91 }, () => [210, 210, 210])),
|
|
||||||
91,
|
|
||||||
false
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(dimMetrics.backgroundOpacity).toBeGreaterThan(0);
|
|
||||||
expect(brightMetrics.backgroundOpacity).toBeGreaterThan(dimMetrics.backgroundOpacity);
|
|
||||||
expect(brightMetrics.backgroundOpacity).toBeLessThanOrEqual(0.82);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('raises background opacity when enough samples have poor contrast with white controls', () => {
|
|
||||||
const darkSamples = Array.from({ length: 82 }, () => [8, 12, 18] as const);
|
|
||||||
const brightSamples = Array.from({ length: 9 }, () => [245, 240, 218] as const);
|
|
||||||
const metrics = getToolbarContrastMetrics(
|
|
||||||
makePixels([...darkSamples, ...brightSamples]),
|
|
||||||
91,
|
|
||||||
false
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(metrics.lowContrastRatio).toBeGreaterThanOrEqual(0.08);
|
|
||||||
expect(metrics.backgroundOpacity).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reads bgra canvas samples in the correct channel order', () => {
|
|
||||||
const bgraPixels = new Uint8Array([0, 0, 255, 255]);
|
|
||||||
const metrics = getToolbarContrastMetrics(bgraPixels, 1, true);
|
|
||||||
|
|
||||||
expect(metrics.averageLuminance).toBeCloseTo(0.2126);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { appConfig } from '../config';
|
import { appConfig } from '../config';
|
||||||
|
import { clamp01 } from '../utils/math';
|
||||||
import type { CanvasReadbackRequest } from './game-loop-types';
|
import type { CanvasReadbackRequest } from './game-loop-types';
|
||||||
|
|
||||||
interface CanvasSamplePoint {
|
interface CanvasSamplePoint {
|
||||||
|
|
@ -16,8 +17,6 @@ interface ToolbarContrastMetrics {
|
||||||
const TOOLBAR_BACKGROUND_OPACITY_PROPERTY = '--toolbar-background-opacity';
|
const TOOLBAR_BACKGROUND_OPACITY_PROPERTY = '--toolbar-background-opacity';
|
||||||
const TOOLBAR_BACKGROUND_STRENGTH_PROPERTY = '--toolbar-background-strength';
|
const TOOLBAR_BACKGROUND_STRENGTH_PROPERTY = '--toolbar-background-strength';
|
||||||
|
|
||||||
const clamp01 = (value: number): number => Math.min(1, Math.max(0, value));
|
|
||||||
|
|
||||||
const getLinearChannel = (channel: number): number => {
|
const getLinearChannel = (channel: number): number => {
|
||||||
const normalized = channel / 255;
|
const normalized = channel / 255;
|
||||||
return normalized <= appConfig.toolbar.contrast.linearChannelBreakpoint
|
return normalized <= appConfig.toolbar.contrast.linearChannelBreakpoint
|
||||||
|
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
import { readFileSync } from 'node:fs';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
const projectRoot = process.cwd();
|
|
||||||
const indexSource = readFileSync(join(projectRoot, 'src/index.ts'), 'utf8');
|
|
||||||
const html = readFileSync(join(projectRoot, 'index.html'), 'utf8');
|
|
||||||
|
|
||||||
const escapeRegex = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
||||||
|
|
||||||
const hasClass = (className: string, tagName?: string) => {
|
|
||||||
const tagPattern = tagName ? `<${tagName}\\b[^>]*` : '<[a-z][^>]*';
|
|
||||||
return new RegExp(
|
|
||||||
`${tagPattern}class="[^"]*\\b${escapeRegex(className)}\\b[^"]*"`,
|
|
||||||
'i'
|
|
||||||
).test(html);
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasId = (id: string) => new RegExp(`\\bid="${escapeRegex(id)}"`, 'i').test(html);
|
|
||||||
|
|
||||||
const hasTag = (tagName: string) =>
|
|
||||||
new RegExp(`<${escapeRegex(tagName)}(?:\\s|>|/)`, 'i').test(html);
|
|
||||||
|
|
||||||
const selectorExists = (selector: string) => {
|
|
||||||
const idSelector = /^#(?<id>[\w-]+)$/.exec(selector);
|
|
||||||
if (idSelector?.groups?.id) {
|
|
||||||
return hasId(idSelector.groups.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const classSelector = /^\.([\w-]+)$/.exec(selector);
|
|
||||||
if (classSelector?.[1]) {
|
|
||||||
return hasClass(classSelector[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tagClassSelector = /^(?<tagName>[a-z]+)\.(?<className>[\w-]+)$/.exec(selector);
|
|
||||||
if (tagClassSelector?.groups) {
|
|
||||||
return hasClass(tagClassSelector.groups.className, tagClassSelector.groups.tagName);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (/^[a-z]+$/.test(selector)) {
|
|
||||||
return hasTag(selector);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Unsupported selector contract syntax: ${selector}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('index DOM selector contract', () => {
|
|
||||||
it('keeps every boot-time required selector target present in index.html', () => {
|
|
||||||
const selectors = Array.from(
|
|
||||||
indexSource.matchAll(/queryRequiredElements?\(\s*'([^']+)'\s*,/g),
|
|
||||||
(match) => match[1]
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(selectors.length).toBeGreaterThan(0);
|
|
||||||
expect(selectors.filter((selector) => !selectorExists(selector))).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the three color swatches expected by the palette UI', () => {
|
|
||||||
const colorSwatchCount = Array.from(
|
|
||||||
html.matchAll(/class="[^"]*\bcolor-swatch\b[^"]*"/g)
|
|
||||||
).length;
|
|
||||||
|
|
||||||
expect(colorSwatchCount).toBe(3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
507
src/index.ts
507
src/index.ts
|
|
@ -2,16 +2,22 @@ import GameLoop from './game-loop/game-loop';
|
||||||
|
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
|
|
||||||
import { initAnalytics, trackExport, trackVibeChange } from './analytics';
|
import { initAnalytics, trackExport, trackStart, trackVibeChange } from './analytics';
|
||||||
|
import {
|
||||||
|
APP_STORAGE_KEYS,
|
||||||
|
DEFAULT_AUDIO_VOLUME,
|
||||||
|
DISABLED_FLAG_VALUE,
|
||||||
|
ENABLED_FLAG_VALUE,
|
||||||
|
UNIT_INTERVAL_INPUT_MAX,
|
||||||
|
UNIT_INTERVAL_INPUT_MIN,
|
||||||
|
} from './app-constants';
|
||||||
import { preloadPianoSamples } from './audio/piano-samples';
|
import { preloadPianoSamples } from './audio/piano-samples';
|
||||||
import { appConfig } from './config';
|
|
||||||
import { CollapsiblePanelAnimator } from './page/collapsible-panel-animator';
|
import { CollapsiblePanelAnimator } from './page/collapsible-panel-animator';
|
||||||
import { ConfigPane } from './page/config-pane';
|
import { ConfigPane } from './page/config-pane';
|
||||||
import { FullScreenHandler } from './page/full-screen-handler';
|
import { FullScreenHandler } from './page/full-screen-handler';
|
||||||
import { MenuHider } from './page/menu-hider';
|
import { MenuHider } from './page/menu-hider';
|
||||||
import { activeVibe, applyVibeSettings, resetSettings, settings } from './settings';
|
import { activeVibe, applyVibeSettings, resetSettings, settings } from './settings';
|
||||||
import { readBrowserStorage, writeBrowserStorage } from './utils/browser-storage';
|
import { readBrowserStorage, writeBrowserStorage } from './utils/browser-storage';
|
||||||
import { clamp } from './utils/clamp';
|
|
||||||
import { DeltaTimeCalculator } from './utils/delta-time-calculator';
|
import { DeltaTimeCalculator } from './utils/delta-time-calculator';
|
||||||
import { queryRequiredElement, queryRequiredElements } from './utils/dom';
|
import { queryRequiredElement, queryRequiredElements } from './utils/dom';
|
||||||
import {
|
import {
|
||||||
|
|
@ -21,56 +27,165 @@ import {
|
||||||
Severity,
|
Severity,
|
||||||
} from './utils/error-handler';
|
} from './utils/error-handler';
|
||||||
import { initializeGpu } from './utils/graphics/initialize-gpu';
|
import { initializeGpu } from './utils/graphics/initialize-gpu';
|
||||||
|
import { clamp01 } from './utils/math';
|
||||||
import { VIBE_PRESETS } from './vibes';
|
import { VIBE_PRESETS } from './vibes';
|
||||||
|
|
||||||
|
const AUDIO_VOLUME_STEP = 0.01;
|
||||||
|
|
||||||
|
const ERASER_CONTROL_SCALE_MAX = 1.33;
|
||||||
|
const ERASER_CONTROL_SCALE_MIN = 0.75;
|
||||||
|
const ERASER_SIZE_DEFAULT = 96;
|
||||||
|
const ERASER_SIZE_MAX = 240;
|
||||||
|
const ERASER_SIZE_MIN = 24;
|
||||||
|
const ERASER_SIZE_STEP = 1;
|
||||||
|
|
||||||
|
const MIRROR_SEGMENT_DEFAULT = 1;
|
||||||
|
const MIRROR_SEGMENT_MAX = 12;
|
||||||
|
const MIRROR_SEGMENT_MIN = 1;
|
||||||
|
const MIRROR_SEGMENT_OFF_LABEL = 'Mirror off';
|
||||||
|
const MIRROR_SEGMENT_STEP = 1;
|
||||||
|
const MIRROR_SEGMENT_LABEL_SUFFIX = 'slices';
|
||||||
|
|
||||||
|
const ELEMENT_TAGS = {
|
||||||
|
div: 'div',
|
||||||
|
pre: 'pre',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const ARIA_ATTRIBUTES = {
|
||||||
|
label: 'aria-label',
|
||||||
|
live: 'aria-live',
|
||||||
|
pressed: 'aria-pressed',
|
||||||
|
role: 'role',
|
||||||
|
valueNow: 'aria-valuenow',
|
||||||
|
valueText: 'aria-valuetext',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const ARIA_LIVE_VALUES = {
|
||||||
|
assertive: 'assertive',
|
||||||
|
polite: 'polite',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const ARIA_ROLES = {
|
||||||
|
alert: 'alert',
|
||||||
|
status: 'status',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const CSS_CLASSES = {
|
||||||
|
active: 'active',
|
||||||
|
errorsContainer: 'errors-container',
|
||||||
|
isLoading: 'is-loading',
|
||||||
|
muted: 'muted',
|
||||||
|
preDrawing: 'pre-drawing',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const CSS_VARIABLES = {
|
||||||
|
eraserControlScale: '--eraser-control-scale',
|
||||||
|
eraserProgress: '--eraser-progress',
|
||||||
|
gardenBackground: '--garden-background',
|
||||||
|
loadingProgress: '--loading-progress',
|
||||||
|
mirrorAngle: '--mirror-angle',
|
||||||
|
mirrorProgress: '--mirror-progress',
|
||||||
|
volumeProgress: '--volume-progress',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const DOM_EVENTS = {
|
||||||
|
click: 'click',
|
||||||
|
focus: 'focus',
|
||||||
|
input: 'input',
|
||||||
|
keydown: 'keydown',
|
||||||
|
pointerDown: 'pointerdown',
|
||||||
|
pointerUp: 'pointerup',
|
||||||
|
touchEnd: 'touchend',
|
||||||
|
touchStart: 'touchstart',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const APP_SELECTORS = {
|
||||||
|
aside: 'aside',
|
||||||
|
canvas: 'canvas',
|
||||||
|
eraserPreview: '.eraser-preview',
|
||||||
|
eraserSizeControl: '.eraser-size-control',
|
||||||
|
eraserSizeSlider: '.eraser-size-slider',
|
||||||
|
errorContainer: '.errors-container',
|
||||||
|
export4k: '.export-4k',
|
||||||
|
exportStatus: '.export-status',
|
||||||
|
infoButton: 'button.info',
|
||||||
|
infoElement: '.info-page',
|
||||||
|
loadingBar: '.loading-bar',
|
||||||
|
loadingProgress: '.loading-progress',
|
||||||
|
loadingStatus: '.loading-status',
|
||||||
|
maximizeFullScreenButton: 'button.maximize-full-screen',
|
||||||
|
minimizeFullScreenButton: 'button.minimize-full-screen',
|
||||||
|
mirrorSegmentControl: '.mirror-segment-control',
|
||||||
|
mirrorSegmentSlider: '.mirror-segment-slider',
|
||||||
|
nextVibe: '.next-vibe',
|
||||||
|
previousVibe: '.previous-vibe',
|
||||||
|
prompt: '.garden-prompt',
|
||||||
|
restartButton: 'button.restart',
|
||||||
|
settingsButton: 'button.settings',
|
||||||
|
soundButton: 'button.sound',
|
||||||
|
splash: '.splash',
|
||||||
|
startButton: '.start-button',
|
||||||
|
swatches: '.color-swatch',
|
||||||
|
toolbarRow: '.toolbar-row',
|
||||||
|
volumeControl: '.volume-control',
|
||||||
|
volumeSlider: '.volume-slider',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const AUDIO_LABELS = {
|
||||||
|
mutedPrefix: 'Muted',
|
||||||
|
mute: 'Mute audio',
|
||||||
|
unmute: 'Unmute audio',
|
||||||
|
volumeSuffix: 'volume',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const LOADING_MESSAGES = {
|
||||||
|
fontsError: 'Could not load fonts.',
|
||||||
|
pianoSamplesError: 'Could not preload piano samples.',
|
||||||
|
ready: 'Ready',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const VIBE_CHANGE_SOURCES = {
|
||||||
|
nextButton: 'next-button',
|
||||||
|
previousButton: 'previous-button',
|
||||||
|
settings: 'settings',
|
||||||
|
} as const;
|
||||||
|
|
||||||
const clampEraserSize = (value: number): number => {
|
const clampEraserSize = (value: number): number => {
|
||||||
const safeValue = Number.isFinite(value) ? value : appConfig.toolbar.eraser.default;
|
const safeValue = Number.isFinite(value) ? value : ERASER_SIZE_DEFAULT;
|
||||||
return Math.min(
|
return Math.min(ERASER_SIZE_MAX, Math.max(ERASER_SIZE_MIN, Math.round(safeValue)));
|
||||||
appConfig.toolbar.eraser.max,
|
|
||||||
Math.max(appConfig.toolbar.eraser.min, Math.round(safeValue))
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getEraserSizeRatio = (size: number): number =>
|
const getEraserSizeRatio = (size: number): number =>
|
||||||
(size - appConfig.toolbar.eraser.min) /
|
(size - ERASER_SIZE_MIN) / (ERASER_SIZE_MAX - ERASER_SIZE_MIN);
|
||||||
(appConfig.toolbar.eraser.max - appConfig.toolbar.eraser.min);
|
|
||||||
|
|
||||||
const clampMirrorSegmentCount = (value: number): number => {
|
const clampMirrorSegmentCount = (value: number): number => {
|
||||||
const safeValue = Number.isFinite(value) ? value : appConfig.toolbar.mirror.default;
|
const safeValue = Number.isFinite(value) ? value : MIRROR_SEGMENT_DEFAULT;
|
||||||
return Math.min(
|
return Math.min(
|
||||||
appConfig.toolbar.mirror.max,
|
MIRROR_SEGMENT_MAX,
|
||||||
Math.max(appConfig.toolbar.mirror.min, Math.round(safeValue))
|
Math.max(MIRROR_SEGMENT_MIN, Math.round(safeValue))
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getMirrorSegmentRatio = (count: number): number =>
|
const getMirrorSegmentRatio = (count: number): number =>
|
||||||
(count - appConfig.toolbar.mirror.min) /
|
(count - MIRROR_SEGMENT_MIN) / (MIRROR_SEGMENT_MAX - MIRROR_SEGMENT_MIN);
|
||||||
(appConfig.toolbar.mirror.max - appConfig.toolbar.mirror.min);
|
|
||||||
|
|
||||||
const mirrorSegmentNames: Readonly<Record<number, string>> =
|
|
||||||
appConfig.toolbar.mirror.names;
|
|
||||||
|
|
||||||
const formatMirrorSegmentCount = (count: number): string =>
|
const formatMirrorSegmentCount = (count: number): string =>
|
||||||
count === appConfig.toolbar.mirror.default
|
count === MIRROR_SEGMENT_DEFAULT
|
||||||
? appConfig.toolbar.mirror.offLabel
|
? MIRROR_SEGMENT_OFF_LABEL
|
||||||
: `${count} ${mirrorSegmentNames[count] ?? appConfig.toolbar.mirror.fallbackSegmentName}`;
|
: `${count} ${MIRROR_SEGMENT_LABEL_SUFFIX}`;
|
||||||
|
|
||||||
const clampAudioVolume = (value: number): number => {
|
const clampAudioVolume = (value: number): number => {
|
||||||
const safeValue = Number.isFinite(value) ? value : appConfig.toolbar.volume.default;
|
const safeValue = Number.isFinite(value) ? value : DEFAULT_AUDIO_VOLUME;
|
||||||
return clamp(safeValue, appConfig.toolbar.volume.min, appConfig.toolbar.volume.max);
|
return clamp01(safeValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAudioVolumeRatio = (volume: number): number =>
|
|
||||||
(volume - appConfig.toolbar.volume.min) /
|
|
||||||
(appConfig.toolbar.volume.max - appConfig.toolbar.volume.min);
|
|
||||||
|
|
||||||
const getAudioVolumePercent = (volume: number): number =>
|
const getAudioVolumePercent = (volume: number): number =>
|
||||||
Math.round(getAudioVolumeRatio(volume) * 100);
|
Math.round(clampAudioVolume(volume) * 100);
|
||||||
|
|
||||||
const readInitialAudioVolume = (): number => {
|
const readInitialAudioVolume = (): number => {
|
||||||
const storedVolume = readBrowserStorage(appConfig.storage.audioVolumeKey);
|
const storedVolume = readBrowserStorage(APP_STORAGE_KEYS.audioVolume);
|
||||||
return storedVolume === null
|
return storedVolume === null
|
||||||
? appConfig.toolbar.volume.default
|
? DEFAULT_AUDIO_VOLUME
|
||||||
: clampAudioVolume(Number(storedVolume));
|
: clampAudioVolume(Number(storedVolume));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -82,13 +197,18 @@ type RuntimeUiError = Parameters<
|
||||||
>[0];
|
>[0];
|
||||||
|
|
||||||
const renderRuntimeMessage = (container: HTMLElement, error: RuntimeUiError) => {
|
const renderRuntimeMessage = (container: HTMLElement, error: RuntimeUiError) => {
|
||||||
const message = document.createElement('pre');
|
const message = document.createElement(ELEMENT_TAGS.pre);
|
||||||
message.className = error.severity;
|
message.className = error.severity;
|
||||||
message.textContent = error.code ? `${error.message}\n${error.code}` : error.message;
|
message.textContent = error.code ? `${error.message}\n${error.code}` : error.message;
|
||||||
message.setAttribute('role', error.severity === Severity.ERROR ? 'alert' : 'status');
|
|
||||||
message.setAttribute(
|
message.setAttribute(
|
||||||
'aria-live',
|
ARIA_ATTRIBUTES.role,
|
||||||
error.severity === Severity.ERROR ? 'assertive' : 'polite'
|
error.severity === Severity.ERROR ? ARIA_ROLES.alert : ARIA_ROLES.status
|
||||||
|
);
|
||||||
|
message.setAttribute(
|
||||||
|
ARIA_ATTRIBUTES.live,
|
||||||
|
error.severity === Severity.ERROR
|
||||||
|
? ARIA_LIVE_VALUES.assertive
|
||||||
|
: ARIA_LIVE_VALUES.polite
|
||||||
);
|
);
|
||||||
container.append(message);
|
container.append(message);
|
||||||
|
|
||||||
|
|
@ -105,54 +225,69 @@ const getRuntimeUiError = (exception: unknown): RuntimeUiError => ({
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderStartupException = (exception: unknown) => {
|
const renderStartupException = (exception: unknown) => {
|
||||||
const existingContainer = document.querySelector('.errors-container');
|
const existingContainer = document.querySelector(APP_SELECTORS.errorContainer);
|
||||||
const container =
|
const container =
|
||||||
existingContainer instanceof HTMLElement
|
existingContainer instanceof HTMLElement
|
||||||
? existingContainer
|
? existingContainer
|
||||||
: document.createElement('div');
|
: document.createElement(ELEMENT_TAGS.div);
|
||||||
|
|
||||||
if (!(existingContainer instanceof HTMLElement)) {
|
if (!(existingContainer instanceof HTMLElement)) {
|
||||||
container.className = 'errors-container';
|
container.className = CSS_CLASSES.errorsContainer;
|
||||||
document.body.append(container);
|
document.body.append(container);
|
||||||
}
|
}
|
||||||
|
|
||||||
container.setAttribute('aria-live', 'assertive');
|
container.setAttribute(ARIA_ATTRIBUTES.live, ARIA_LIVE_VALUES.assertive);
|
||||||
renderRuntimeMessage(container, getRuntimeUiError(exception));
|
renderRuntimeMessage(container, getRuntimeUiError(exception));
|
||||||
};
|
};
|
||||||
|
|
||||||
const queryAppElements = () => ({
|
const queryAppElements = () => ({
|
||||||
aside: queryRequiredElement('aside', HTMLElement),
|
aside: queryRequiredElement(APP_SELECTORS.aside, HTMLElement),
|
||||||
toolbarRow: queryRequiredElement('.toolbar-row', HTMLElement),
|
toolbarRow: queryRequiredElement(APP_SELECTORS.toolbarRow, HTMLElement),
|
||||||
infoButton: queryRequiredElement('button.info', HTMLButtonElement),
|
infoButton: queryRequiredElement(APP_SELECTORS.infoButton, HTMLButtonElement),
|
||||||
infoElement: queryRequiredElement('.info-page', HTMLElement),
|
infoElement: queryRequiredElement(APP_SELECTORS.infoElement, HTMLElement),
|
||||||
minimizeFullScreenButton: queryRequiredElement(
|
minimizeFullScreenButton: queryRequiredElement(
|
||||||
'button.minimize-full-screen',
|
APP_SELECTORS.minimizeFullScreenButton,
|
||||||
HTMLButtonElement
|
HTMLButtonElement
|
||||||
),
|
),
|
||||||
maximizeFullScreenButton: queryRequiredElement(
|
maximizeFullScreenButton: queryRequiredElement(
|
||||||
'button.maximize-full-screen',
|
APP_SELECTORS.maximizeFullScreenButton,
|
||||||
HTMLButtonElement
|
HTMLButtonElement
|
||||||
),
|
),
|
||||||
settingsButton: queryRequiredElement('button.settings', HTMLButtonElement),
|
settingsButton: queryRequiredElement(APP_SELECTORS.settingsButton, HTMLButtonElement),
|
||||||
soundButton: queryRequiredElement('button.sound', HTMLButtonElement),
|
soundButton: queryRequiredElement(APP_SELECTORS.soundButton, HTMLButtonElement),
|
||||||
volumeControl: queryRequiredElement('.volume-control', HTMLLabelElement),
|
volumeControl: queryRequiredElement(APP_SELECTORS.volumeControl, HTMLLabelElement),
|
||||||
volumeSlider: queryRequiredElement('.volume-slider', HTMLInputElement),
|
volumeSlider: queryRequiredElement(APP_SELECTORS.volumeSlider, HTMLInputElement),
|
||||||
restartButton: queryRequiredElement('button.restart', HTMLButtonElement),
|
restartButton: queryRequiredElement(APP_SELECTORS.restartButton, HTMLButtonElement),
|
||||||
canvas: queryRequiredElement('canvas', HTMLCanvasElement),
|
canvas: queryRequiredElement(APP_SELECTORS.canvas, HTMLCanvasElement),
|
||||||
eraserPreview: queryRequiredElement('.eraser-preview', HTMLDivElement),
|
eraserPreview: queryRequiredElement(APP_SELECTORS.eraserPreview, HTMLDivElement),
|
||||||
errorContainer: queryRequiredElement('.errors-container', HTMLElement),
|
errorContainer: queryRequiredElement(APP_SELECTORS.errorContainer, HTMLElement),
|
||||||
previousVibe: queryRequiredElement('.previous-vibe', HTMLButtonElement),
|
previousVibe: queryRequiredElement(APP_SELECTORS.previousVibe, HTMLButtonElement),
|
||||||
nextVibe: queryRequiredElement('.next-vibe', HTMLButtonElement),
|
nextVibe: queryRequiredElement(APP_SELECTORS.nextVibe, HTMLButtonElement),
|
||||||
swatches: queryRequiredElements('.color-swatch', HTMLButtonElement),
|
swatches: queryRequiredElements(APP_SELECTORS.swatches, HTMLButtonElement),
|
||||||
eraserSizeControl: queryRequiredElement('.eraser-size-control', HTMLLabelElement),
|
eraserSizeControl: queryRequiredElement(
|
||||||
eraserSizeSlider: queryRequiredElement('.eraser-size-slider', HTMLInputElement),
|
APP_SELECTORS.eraserSizeControl,
|
||||||
mirrorSegmentControl: queryRequiredElement('.mirror-segment-control', HTMLLabelElement),
|
HTMLLabelElement
|
||||||
mirrorSegmentSlider: queryRequiredElement('.mirror-segment-slider', HTMLInputElement),
|
),
|
||||||
export4k: queryRequiredElement('.export-4k', HTMLButtonElement),
|
eraserSizeSlider: queryRequiredElement(
|
||||||
exportStatus: queryRequiredElement('.export-status', HTMLSpanElement),
|
APP_SELECTORS.eraserSizeSlider,
|
||||||
prompt: queryRequiredElement('.garden-prompt', HTMLDivElement),
|
HTMLInputElement
|
||||||
loadingStatus: queryRequiredElement('.loading-status', HTMLDivElement),
|
),
|
||||||
loadingProgress: queryRequiredElement('.loading-progress', HTMLDivElement),
|
mirrorSegmentControl: queryRequiredElement(
|
||||||
|
APP_SELECTORS.mirrorSegmentControl,
|
||||||
|
HTMLLabelElement
|
||||||
|
),
|
||||||
|
mirrorSegmentSlider: queryRequiredElement(
|
||||||
|
APP_SELECTORS.mirrorSegmentSlider,
|
||||||
|
HTMLInputElement
|
||||||
|
),
|
||||||
|
export4k: queryRequiredElement(APP_SELECTORS.export4k, HTMLButtonElement),
|
||||||
|
exportStatus: queryRequiredElement(APP_SELECTORS.exportStatus, HTMLSpanElement),
|
||||||
|
prompt: queryRequiredElement(APP_SELECTORS.prompt, HTMLDivElement),
|
||||||
|
loadingStatus: queryRequiredElement(APP_SELECTORS.loadingStatus, HTMLDivElement),
|
||||||
|
loadingProgress: queryRequiredElement(APP_SELECTORS.loadingProgress, HTMLDivElement),
|
||||||
|
splash: queryRequiredElement(APP_SELECTORS.splash, HTMLDivElement),
|
||||||
|
loadingBar: queryRequiredElement(APP_SELECTORS.loadingBar, HTMLDivElement),
|
||||||
|
startButton: queryRequiredElement(APP_SELECTORS.startButton, HTMLButtonElement),
|
||||||
});
|
});
|
||||||
|
|
||||||
type AppElements = ReturnType<typeof queryAppElements>;
|
type AppElements = ReturnType<typeof queryAppElements>;
|
||||||
|
|
@ -160,52 +295,62 @@ type AppElements = ReturnType<typeof queryAppElements>;
|
||||||
let elements: AppElements;
|
let elements: AppElements;
|
||||||
|
|
||||||
const setLoadingStage = (label: string, ratio: number) => {
|
const setLoadingStage = (label: string, ratio: number) => {
|
||||||
const percent = Math.round(Math.max(0, Math.min(1, ratio)) * 100);
|
const percent = Math.round(clamp01(ratio) * 100);
|
||||||
elements.loadingStatus.textContent = label;
|
elements.loadingStatus.textContent = label;
|
||||||
elements.loadingProgress.style.setProperty('--loading-progress', `${percent}%`);
|
elements.loadingProgress.style.setProperty(
|
||||||
elements.loadingProgress.setAttribute('aria-valuenow', String(percent));
|
CSS_VARIABLES.loadingProgress,
|
||||||
|
`${percent}%`
|
||||||
|
);
|
||||||
|
elements.loadingProgress.setAttribute(ARIA_ATTRIBUTES.valueNow, String(percent));
|
||||||
};
|
};
|
||||||
|
|
||||||
let audioVolume = readInitialAudioVolume();
|
let audioVolume = readInitialAudioVolume();
|
||||||
let isAudioMuted =
|
let isAudioMuted =
|
||||||
readBrowserStorage(appConfig.storage.audioMutedKey) === '1' ||
|
readBrowserStorage(APP_STORAGE_KEYS.audioMuted) === ENABLED_FLAG_VALUE ||
|
||||||
audioVolume <= appConfig.toolbar.volume.min;
|
audioVolume <= 0;
|
||||||
let isEraserActive = false;
|
let isEraserActive = false;
|
||||||
|
|
||||||
const persistAudioUiState = () => {
|
const persistAudioUiState = () => {
|
||||||
writeBrowserStorage(appConfig.storage.audioMutedKey, isAudioMuted ? '1' : '0');
|
|
||||||
writeBrowserStorage(
|
writeBrowserStorage(
|
||||||
appConfig.storage.audioVolumeKey,
|
APP_STORAGE_KEYS.audioMuted,
|
||||||
formatStoredAudioVolume(audioVolume)
|
isAudioMuted ? ENABLED_FLAG_VALUE : DISABLED_FLAG_VALUE
|
||||||
);
|
);
|
||||||
|
writeBrowserStorage(APP_STORAGE_KEYS.audioVolume, formatStoredAudioVolume(audioVolume));
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderAudioUi = (game: GameLoop | null) => {
|
const renderAudioUi = (game: GameLoop | null) => {
|
||||||
audioVolume = clampAudioVolume(audioVolume);
|
audioVolume = clampAudioVolume(audioVolume);
|
||||||
const isEffectivelyMuted = isAudioMuted || audioVolume <= appConfig.toolbar.volume.min;
|
const isEffectivelyMuted = isAudioMuted || audioVolume <= 0;
|
||||||
const volumePercent = getAudioVolumePercent(audioVolume);
|
const volumePercent = getAudioVolumePercent(audioVolume);
|
||||||
|
|
||||||
elements.soundButton.classList.toggle('muted', isEffectivelyMuted);
|
elements.soundButton.classList.toggle(CSS_CLASSES.muted, isEffectivelyMuted);
|
||||||
elements.soundButton.setAttribute('aria-pressed', String(isEffectivelyMuted));
|
elements.soundButton.setAttribute(ARIA_ATTRIBUTES.pressed, String(isEffectivelyMuted));
|
||||||
elements.soundButton.setAttribute(
|
elements.soundButton.setAttribute(
|
||||||
'aria-label',
|
ARIA_ATTRIBUTES.label,
|
||||||
isEffectivelyMuted ? 'Unmute audio' : 'Mute audio'
|
isEffectivelyMuted ? AUDIO_LABELS.unmute : AUDIO_LABELS.mute
|
||||||
);
|
);
|
||||||
elements.soundButton.title = isEffectivelyMuted ? 'Unmute audio' : 'Mute audio';
|
elements.soundButton.title = isEffectivelyMuted
|
||||||
|
? AUDIO_LABELS.unmute
|
||||||
|
: AUDIO_LABELS.mute;
|
||||||
|
|
||||||
elements.volumeSlider.min = appConfig.toolbar.volume.min.toString();
|
elements.volumeSlider.min = UNIT_INTERVAL_INPUT_MIN;
|
||||||
elements.volumeSlider.max = appConfig.toolbar.volume.max.toString();
|
elements.volumeSlider.max = UNIT_INTERVAL_INPUT_MAX;
|
||||||
elements.volumeSlider.step = appConfig.toolbar.volume.step.toString();
|
elements.volumeSlider.step = AUDIO_VOLUME_STEP.toString();
|
||||||
elements.volumeSlider.value = formatStoredAudioVolume(audioVolume);
|
elements.volumeSlider.value = formatStoredAudioVolume(audioVolume);
|
||||||
elements.volumeSlider.setAttribute(
|
elements.volumeSlider.setAttribute(
|
||||||
'aria-valuetext',
|
ARIA_ATTRIBUTES.valueText,
|
||||||
isEffectivelyMuted ? `Muted, ${volumePercent}%` : `${volumePercent}%`
|
isEffectivelyMuted
|
||||||
|
? `${AUDIO_LABELS.mutedPrefix}, ${volumePercent}%`
|
||||||
|
: `${volumePercent}%`
|
||||||
);
|
);
|
||||||
elements.volumeControl.classList.toggle('muted', isEffectivelyMuted);
|
elements.volumeControl.classList.toggle(CSS_CLASSES.muted, isEffectivelyMuted);
|
||||||
elements.volumeControl.title = isEffectivelyMuted
|
elements.volumeControl.title = isEffectivelyMuted
|
||||||
? `Muted, ${volumePercent}% volume`
|
? `${AUDIO_LABELS.mutedPrefix}, ${volumePercent}% ${AUDIO_LABELS.volumeSuffix}`
|
||||||
: `${volumePercent}% volume`;
|
: `${volumePercent}% ${AUDIO_LABELS.volumeSuffix}`;
|
||||||
elements.volumeControl.style.setProperty('--volume-progress', `${volumePercent}%`);
|
elements.volumeControl.style.setProperty(
|
||||||
|
CSS_VARIABLES.volumeProgress,
|
||||||
|
`${volumePercent}%`
|
||||||
|
);
|
||||||
|
|
||||||
game?.setAudioVolume(audioVolume);
|
game?.setAudioVolume(audioVolume);
|
||||||
game?.setAudioMuted(isEffectivelyMuted);
|
game?.setAudioMuted(isEffectivelyMuted);
|
||||||
|
|
@ -215,14 +360,14 @@ const renderPaletteUi = (game: GameLoop | null) => {
|
||||||
elements.swatches.forEach((swatch, index) => {
|
elements.swatches.forEach((swatch, index) => {
|
||||||
swatch.style.backgroundColor = activeVibe.colors[index];
|
swatch.style.backgroundColor = activeVibe.colors[index];
|
||||||
swatch.classList.toggle(
|
swatch.classList.toggle(
|
||||||
'active',
|
CSS_CLASSES.active,
|
||||||
settings.selectedColorIndex === index && !isEraserActive
|
settings.selectedColorIndex === index && !isEraserActive
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
elements.eraserSizeControl.classList.toggle('active', isEraserActive);
|
elements.eraserSizeControl.classList.toggle(CSS_CLASSES.active, isEraserActive);
|
||||||
game?.setEraseMode(isEraserActive);
|
game?.setEraseMode(isEraserActive);
|
||||||
document.documentElement.style.setProperty(
|
document.documentElement.style.setProperty(
|
||||||
'--garden-background',
|
CSS_VARIABLES.gardenBackground,
|
||||||
activeVibe.backgroundColor
|
activeVibe.backgroundColor
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -233,21 +378,22 @@ const renderEraserSizeUi = (game: GameLoop | null) => {
|
||||||
settings.eraserSize = size;
|
settings.eraserSize = size;
|
||||||
}
|
}
|
||||||
|
|
||||||
elements.eraserSizeSlider.min = appConfig.toolbar.eraser.min.toString();
|
elements.eraserSizeSlider.min = ERASER_SIZE_MIN.toString();
|
||||||
elements.eraserSizeSlider.max = appConfig.toolbar.eraser.max.toString();
|
elements.eraserSizeSlider.max = ERASER_SIZE_MAX.toString();
|
||||||
elements.eraserSizeSlider.step = appConfig.toolbar.eraser.step.toString();
|
elements.eraserSizeSlider.step = ERASER_SIZE_STEP.toString();
|
||||||
elements.eraserSizeSlider.value = size.toString();
|
elements.eraserSizeSlider.value = size.toString();
|
||||||
elements.eraserSizeSlider.setAttribute('aria-valuetext', `${size}px`);
|
elements.eraserSizeSlider.setAttribute(ARIA_ATTRIBUTES.valueText, `${size}px`);
|
||||||
|
|
||||||
const ratio = getEraserSizeRatio(size);
|
const ratio = getEraserSizeRatio(size);
|
||||||
const scale =
|
const scale =
|
||||||
appConfig.toolbar.eraser.controlScaleMin +
|
ERASER_CONTROL_SCALE_MIN +
|
||||||
(appConfig.toolbar.eraser.controlScaleMax -
|
(ERASER_CONTROL_SCALE_MAX - ERASER_CONTROL_SCALE_MIN) * ratio;
|
||||||
appConfig.toolbar.eraser.controlScaleMin) *
|
|
||||||
ratio;
|
|
||||||
elements.eraserSizeControl.style.setProperty('--eraser-progress', `${ratio * 100}%`);
|
|
||||||
elements.eraserSizeControl.style.setProperty(
|
elements.eraserSizeControl.style.setProperty(
|
||||||
'--eraser-control-scale',
|
CSS_VARIABLES.eraserProgress,
|
||||||
|
`${ratio * 100}%`
|
||||||
|
);
|
||||||
|
elements.eraserSizeControl.style.setProperty(
|
||||||
|
CSS_VARIABLES.eraserControlScale,
|
||||||
scale.toFixed(3)
|
scale.toFixed(3)
|
||||||
);
|
);
|
||||||
game?.updateEraserPreview();
|
game?.updateEraserPreview();
|
||||||
|
|
@ -259,19 +405,22 @@ const renderMirrorSegmentUi = () => {
|
||||||
settings.mirrorSegmentCount = count;
|
settings.mirrorSegmentCount = count;
|
||||||
}
|
}
|
||||||
|
|
||||||
elements.mirrorSegmentSlider.min = appConfig.toolbar.mirror.min.toString();
|
elements.mirrorSegmentSlider.min = MIRROR_SEGMENT_MIN.toString();
|
||||||
elements.mirrorSegmentSlider.max = appConfig.toolbar.mirror.max.toString();
|
elements.mirrorSegmentSlider.max = MIRROR_SEGMENT_MAX.toString();
|
||||||
elements.mirrorSegmentSlider.step = appConfig.toolbar.mirror.step.toString();
|
elements.mirrorSegmentSlider.step = MIRROR_SEGMENT_STEP.toString();
|
||||||
elements.mirrorSegmentSlider.value = count.toString();
|
elements.mirrorSegmentSlider.value = count.toString();
|
||||||
|
|
||||||
const label = formatMirrorSegmentCount(count);
|
const label = formatMirrorSegmentCount(count);
|
||||||
const ratio = getMirrorSegmentRatio(count);
|
const ratio = getMirrorSegmentRatio(count);
|
||||||
elements.mirrorSegmentSlider.setAttribute('aria-valuetext', label);
|
elements.mirrorSegmentSlider.setAttribute(ARIA_ATTRIBUTES.valueText, label);
|
||||||
elements.mirrorSegmentControl.title = label;
|
elements.mirrorSegmentControl.title = label;
|
||||||
elements.mirrorSegmentControl.classList.toggle('active', count > 1);
|
elements.mirrorSegmentControl.classList.toggle(CSS_CLASSES.active, count > 1);
|
||||||
elements.mirrorSegmentControl.style.setProperty('--mirror-progress', `${ratio * 100}%`);
|
|
||||||
elements.mirrorSegmentControl.style.setProperty(
|
elements.mirrorSegmentControl.style.setProperty(
|
||||||
'--mirror-angle',
|
CSS_VARIABLES.mirrorProgress,
|
||||||
|
`${ratio * 100}%`
|
||||||
|
);
|
||||||
|
elements.mirrorSegmentControl.style.setProperty(
|
||||||
|
CSS_VARIABLES.mirrorAngle,
|
||||||
`${(360 / count).toFixed(3)}deg`
|
`${(360 / count).toFixed(3)}deg`
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -286,11 +435,14 @@ const main = async () => {
|
||||||
let configPane: ConfigPane | null = null;
|
let configPane: ConfigPane | null = null;
|
||||||
|
|
||||||
elements = queryAppElements();
|
elements = queryAppElements();
|
||||||
elements.errorContainer.setAttribute('aria-live', 'assertive');
|
elements.errorContainer.setAttribute(
|
||||||
|
ARIA_ATTRIBUTES.live,
|
||||||
|
ARIA_LIVE_VALUES.assertive
|
||||||
|
);
|
||||||
ErrorHandler.addOnErrorListener((error) => {
|
ErrorHandler.addOnErrorListener((error) => {
|
||||||
renderRuntimeMessage(elements.errorContainer, error);
|
renderRuntimeMessage(elements.errorContainer, error);
|
||||||
if (error.severity === Severity.ERROR) {
|
if (error.severity === Severity.ERROR) {
|
||||||
document.body.classList.remove('is-loading');
|
document.body.classList.remove(CSS_CLASSES.isLoading);
|
||||||
game?.destroy();
|
game?.destroy();
|
||||||
shouldStop = true;
|
shouldStop = true;
|
||||||
}
|
}
|
||||||
|
|
@ -325,6 +477,7 @@ const main = async () => {
|
||||||
const startAudioFromUserGesture = (event: Event) => {
|
const startAudioFromUserGesture = (event: Event) => {
|
||||||
if (
|
if (
|
||||||
isAudioMuted ||
|
isAudioMuted ||
|
||||||
|
(event.target instanceof Node && elements.startButton.contains(event.target)) ||
|
||||||
(event.target instanceof Node && elements.soundButton.contains(event.target))
|
(event.target instanceof Node && elements.soundButton.contains(event.target))
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -333,22 +486,34 @@ const main = async () => {
|
||||||
game?.startAudio(true);
|
game?.startAudio(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('touchend', startAudioFromUserGesture, {
|
window.addEventListener(DOM_EVENTS.touchStart, startAudioFromUserGesture, {
|
||||||
capture: true,
|
capture: true,
|
||||||
passive: true,
|
passive: true,
|
||||||
});
|
});
|
||||||
window.addEventListener('pointerup', startAudioFromUserGesture, {
|
window.addEventListener(DOM_EVENTS.pointerDown, startAudioFromUserGesture, {
|
||||||
capture: true,
|
capture: true,
|
||||||
passive: true,
|
passive: true,
|
||||||
});
|
});
|
||||||
window.addEventListener('click', startAudioFromUserGesture, { capture: true });
|
window.addEventListener(DOM_EVENTS.touchEnd, startAudioFromUserGesture, {
|
||||||
window.addEventListener('keydown', startAudioFromUserGesture, { capture: true });
|
capture: true,
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
|
window.addEventListener(DOM_EVENTS.pointerUp, startAudioFromUserGesture, {
|
||||||
|
capture: true,
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
|
window.addEventListener(DOM_EVENTS.click, startAudioFromUserGesture, {
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
|
window.addEventListener(DOM_EVENTS.keydown, startAudioFromUserGesture, {
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
|
|
||||||
elements.restartButton.addEventListener('click', () => game?.destroy());
|
elements.restartButton.addEventListener(DOM_EVENTS.click, () => game?.destroy());
|
||||||
elements.soundButton.addEventListener('click', () => {
|
elements.soundButton.addEventListener(DOM_EVENTS.click, () => {
|
||||||
const shouldUnmute = isAudioMuted || audioVolume <= appConfig.toolbar.volume.min;
|
const shouldUnmute = isAudioMuted || audioVolume <= 0;
|
||||||
if (shouldUnmute && audioVolume <= appConfig.toolbar.volume.min) {
|
if (shouldUnmute && audioVolume <= 0) {
|
||||||
audioVolume = appConfig.toolbar.volume.default;
|
audioVolume = DEFAULT_AUDIO_VOLUME;
|
||||||
}
|
}
|
||||||
isAudioMuted = !shouldUnmute;
|
isAudioMuted = !shouldUnmute;
|
||||||
persistAudioUiState();
|
persistAudioUiState();
|
||||||
|
|
@ -357,9 +522,9 @@ const main = async () => {
|
||||||
game?.startAudio(true);
|
game?.startAudio(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
elements.volumeSlider.addEventListener('input', () => {
|
elements.volumeSlider.addEventListener(DOM_EVENTS.input, () => {
|
||||||
audioVolume = clampAudioVolume(Number(elements.volumeSlider.value));
|
audioVolume = clampAudioVolume(Number(elements.volumeSlider.value));
|
||||||
isAudioMuted = audioVolume <= appConfig.toolbar.volume.min;
|
isAudioMuted = audioVolume <= 0;
|
||||||
persistAudioUiState();
|
persistAudioUiState();
|
||||||
renderAudioUi(game);
|
renderAudioUi(game);
|
||||||
if (!isAudioMuted) {
|
if (!isAudioMuted) {
|
||||||
|
|
@ -383,16 +548,16 @@ const main = async () => {
|
||||||
game?.playVibeChangeAudio(true);
|
game?.playVibeChangeAudio(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
elements.previousVibe.addEventListener('click', () =>
|
elements.previousVibe.addEventListener(DOM_EVENTS.click, () =>
|
||||||
selectRelativeVibe(-1, 'previous-button')
|
selectRelativeVibe(-1, VIBE_CHANGE_SOURCES.previousButton)
|
||||||
);
|
);
|
||||||
|
|
||||||
elements.nextVibe.addEventListener('click', () =>
|
elements.nextVibe.addEventListener(DOM_EVENTS.click, () =>
|
||||||
selectRelativeVibe(1, 'next-button')
|
selectRelativeVibe(1, VIBE_CHANGE_SOURCES.nextButton)
|
||||||
);
|
);
|
||||||
|
|
||||||
elements.swatches.forEach((swatch, index) => {
|
elements.swatches.forEach((swatch, index) => {
|
||||||
swatch.addEventListener('click', () => {
|
swatch.addEventListener(DOM_EVENTS.click, () => {
|
||||||
settings.selectedColorIndex = index;
|
settings.selectedColorIndex = index;
|
||||||
isEraserActive = false;
|
isEraserActive = false;
|
||||||
renderPaletteUi(game);
|
renderPaletteUi(game);
|
||||||
|
|
@ -405,11 +570,11 @@ const main = async () => {
|
||||||
renderPaletteUi(game);
|
renderPaletteUi(game);
|
||||||
};
|
};
|
||||||
|
|
||||||
elements.eraserSizeControl.addEventListener('pointerdown', activateEraser);
|
elements.eraserSizeControl.addEventListener(DOM_EVENTS.pointerDown, activateEraser);
|
||||||
elements.eraserSizeControl.addEventListener('click', activateEraser);
|
elements.eraserSizeControl.addEventListener(DOM_EVENTS.click, activateEraser);
|
||||||
elements.eraserSizeSlider.addEventListener('focus', activateEraser);
|
elements.eraserSizeSlider.addEventListener(DOM_EVENTS.focus, activateEraser);
|
||||||
|
|
||||||
elements.eraserSizeSlider.addEventListener('input', () => {
|
elements.eraserSizeSlider.addEventListener(DOM_EVENTS.input, () => {
|
||||||
settings.eraserSize = clampEraserSize(Number(elements.eraserSizeSlider.value));
|
settings.eraserSize = clampEraserSize(Number(elements.eraserSizeSlider.value));
|
||||||
isEraserActive = true;
|
isEraserActive = true;
|
||||||
renderEraserSizeUi(game);
|
renderEraserSizeUi(game);
|
||||||
|
|
@ -417,7 +582,7 @@ const main = async () => {
|
||||||
configPane?.refresh();
|
configPane?.refresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
elements.mirrorSegmentSlider.addEventListener('input', () => {
|
elements.mirrorSegmentSlider.addEventListener(DOM_EVENTS.input, () => {
|
||||||
settings.mirrorSegmentCount = clampMirrorSegmentCount(
|
settings.mirrorSegmentCount = clampMirrorSegmentCount(
|
||||||
Number(elements.mirrorSegmentSlider.value)
|
Number(elements.mirrorSegmentSlider.value)
|
||||||
);
|
);
|
||||||
|
|
@ -427,7 +592,7 @@ const main = async () => {
|
||||||
configPane?.refresh();
|
configPane?.refresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
elements.export4k.addEventListener('click', async () => {
|
elements.export4k.addEventListener(DOM_EVENTS.click, async () => {
|
||||||
if (!game || elements.export4k.disabled) {
|
if (!game || elements.export4k.disabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -448,14 +613,36 @@ const main = async () => {
|
||||||
renderMirrorSegmentUi();
|
renderMirrorSegmentUi();
|
||||||
renderAudioUi(game);
|
renderAudioUi(game);
|
||||||
|
|
||||||
|
// Loading runs in the background while the splash (title + description +
|
||||||
|
// Start button) is shown. The Start tap is the user gesture that unlocks
|
||||||
|
// the AudioContext on iOS, and gates the intro.
|
||||||
const fontsReady = document.fonts.ready.catch((error) => {
|
const fontsReady = document.fonts.ready.catch((error) => {
|
||||||
ErrorHandler.addException(error, {
|
ErrorHandler.addException(error, {
|
||||||
fallbackMessage: 'Could not load fonts.',
|
fallbackMessage: LOADING_MESSAGES.fontsError,
|
||||||
severity: Severity.WARNING,
|
severity: Severity.WARNING,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
setLoadingStage('Connecting to GPU…', 0.1);
|
const gpuPromise = initializeGpu();
|
||||||
const gpu = await initializeGpu();
|
|
||||||
|
let isPreloadComplete = false;
|
||||||
|
const preloadPromise = preloadPianoSamples(({ loadedCount, totalCount }) => {
|
||||||
|
const ratio = totalCount > 0 ? loadedCount / totalCount : 0;
|
||||||
|
setLoadingStage(`Loading piano samples ${loadedCount}/${totalCount}…`, ratio);
|
||||||
|
}).then(
|
||||||
|
() => {
|
||||||
|
isPreloadComplete = true;
|
||||||
|
setLoadingStage(LOADING_MESSAGES.ready, 1);
|
||||||
|
},
|
||||||
|
(error: unknown) => {
|
||||||
|
isPreloadComplete = true;
|
||||||
|
ErrorHandler.addException(error, {
|
||||||
|
fallbackMessage: LOADING_MESSAGES.pianoSamplesError,
|
||||||
|
severity: Severity.WARNING,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const gpu = await gpuPromise;
|
||||||
configPane = new ConfigPane({
|
configPane = new ConfigPane({
|
||||||
settingsButton: elements.settingsButton,
|
settingsButton: elements.settingsButton,
|
||||||
onConfigChange: () => {
|
onConfigChange: () => {
|
||||||
|
|
@ -480,7 +667,7 @@ const main = async () => {
|
||||||
trackVibeChange({
|
trackVibeChange({
|
||||||
vibeId: activePreset.id,
|
vibeId: activePreset.id,
|
||||||
vibeName: activePreset.name,
|
vibeName: activePreset.name,
|
||||||
source: 'settings',
|
source: VIBE_CHANGE_SOURCES.settings,
|
||||||
});
|
});
|
||||||
game?.onVibeChanged();
|
game?.onVibeChanged();
|
||||||
syncRuntimeUi();
|
syncRuntimeUi();
|
||||||
|
|
@ -488,17 +675,7 @@ const main = async () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
infoPageHandler.onOpen = configPane.close.bind(configPane);
|
infoPageHandler.onOpen = configPane.close.bind(configPane);
|
||||||
setLoadingStage('Loading fonts…', 0.3);
|
|
||||||
await fontsReady;
|
await fontsReady;
|
||||||
setLoadingStage('Loading piano samples…', 0.45);
|
|
||||||
await preloadPianoSamples(({ loadedCount, totalCount }) => {
|
|
||||||
const sampleRatio = totalCount > 0 ? loadedCount / totalCount : 1;
|
|
||||||
setLoadingStage(
|
|
||||||
`Loading piano samples ${loadedCount}/${totalCount}…`,
|
|
||||||
0.45 + sampleRatio * 0.3
|
|
||||||
);
|
|
||||||
});
|
|
||||||
setLoadingStage('Compiling shaders…', 0.8);
|
|
||||||
|
|
||||||
const deltaTimeCalculator = new DeltaTimeCalculator();
|
const deltaTimeCalculator = new DeltaTimeCalculator();
|
||||||
|
|
||||||
|
|
@ -515,18 +692,48 @@ const main = async () => {
|
||||||
renderMirrorSegmentUi();
|
renderMirrorSegmentUi();
|
||||||
renderAudioUi(game);
|
renderAudioUi(game);
|
||||||
|
|
||||||
const startPromise = game.start();
|
|
||||||
if (isFirstStart) {
|
if (isFirstStart) {
|
||||||
isFirstStart = false;
|
isFirstStart = false;
|
||||||
setLoadingStage('Ready', 1);
|
|
||||||
|
// Splash is in the DOM by default; enable the button now that the
|
||||||
|
// audio system (GameLoop) is constructed and ready to be unlocked.
|
||||||
|
elements.startButton.disabled = false;
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const onClick = () => {
|
||||||
|
elements.startButton.removeEventListener(DOM_EVENTS.click, onClick);
|
||||||
|
game?.startAudio(true);
|
||||||
|
trackStart();
|
||||||
|
elements.splash.hidden = true;
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
elements.startButton.addEventListener(DOM_EVENTS.click, onClick);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isPreloadComplete) {
|
||||||
|
elements.loadingBar.hidden = false;
|
||||||
|
void preloadPromise.finally(() => {
|
||||||
|
elements.loadingBar.hidden = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the toolbar/dock hidden until the user actually starts drawing.
|
||||||
|
document.body.classList.add(CSS_CLASSES.preDrawing);
|
||||||
|
elements.canvas.addEventListener(
|
||||||
|
DOM_EVENTS.pointerDown,
|
||||||
|
() => document.body.classList.remove(CSS_CLASSES.preDrawing),
|
||||||
|
{ once: true }
|
||||||
|
);
|
||||||
|
|
||||||
requestAnimationFrame(() =>
|
requestAnimationFrame(() =>
|
||||||
requestAnimationFrame(() => document.body.classList.remove('is-loading'))
|
requestAnimationFrame(() =>
|
||||||
|
document.body.classList.remove(CSS_CLASSES.isLoading)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await startPromise;
|
await game.start();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
document.body.classList.remove('is-loading');
|
document.body.classList.remove(CSS_CLASSES.isLoading);
|
||||||
if (hasRuntimeErrorListener) {
|
if (hasRuntimeErrorListener) {
|
||||||
ErrorHandler.addException(e);
|
ErrorHandler.addException(e);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -1,137 +0,0 @@
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
import { CollapsiblePanelAnimator } from './collapsible-panel-animator';
|
|
||||||
|
|
||||||
type Listener = (event: Record<string, unknown>) => void;
|
|
||||||
|
|
||||||
class FakeClassList {
|
|
||||||
private readonly classes = new Set<string>();
|
|
||||||
|
|
||||||
public add(className: string): void {
|
|
||||||
this.classes.add(className);
|
|
||||||
}
|
|
||||||
|
|
||||||
public contains(className: string): boolean {
|
|
||||||
return this.classes.has(className);
|
|
||||||
}
|
|
||||||
|
|
||||||
public remove(className: string): void {
|
|
||||||
this.classes.delete(className);
|
|
||||||
}
|
|
||||||
|
|
||||||
public toggle(className: string, force?: boolean): boolean {
|
|
||||||
const shouldAdd = force ?? !this.classes.has(className);
|
|
||||||
if (shouldAdd) {
|
|
||||||
this.add(className);
|
|
||||||
} else {
|
|
||||||
this.remove(className);
|
|
||||||
}
|
|
||||||
return shouldAdd;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class FakeElement {
|
|
||||||
public readonly classList = new FakeClassList();
|
|
||||||
public inert = false;
|
|
||||||
|
|
||||||
private readonly attributes = new Map<string, string>();
|
|
||||||
private readonly children = new Set<FakeElement>();
|
|
||||||
private readonly listeners = new Map<string, Array<Listener>>();
|
|
||||||
|
|
||||||
public addChild(child: FakeElement): void {
|
|
||||||
this.children.add(child);
|
|
||||||
}
|
|
||||||
|
|
||||||
public addEventListener(type: string, listener: Listener): void {
|
|
||||||
this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public contains(target: unknown): boolean {
|
|
||||||
return target === this || this.children.has(target as FakeElement);
|
|
||||||
}
|
|
||||||
|
|
||||||
public dispatch(type: string, event: Record<string, unknown> = {}): void {
|
|
||||||
this.listeners.get(type)?.forEach((listener) => listener({ target: this, ...event }));
|
|
||||||
}
|
|
||||||
|
|
||||||
public focus(): void {
|
|
||||||
fakeDocument.activeElement = this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getAttribute(name: string): string | null {
|
|
||||||
return this.attributes.get(name) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public setAttribute(name: string, value: string): void {
|
|
||||||
this.attributes.set(name, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const windowListeners = new Map<string, Array<Listener>>();
|
|
||||||
const fakeDocument: { activeElement: FakeElement | null } = {
|
|
||||||
activeElement: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const dispatchWindowEvent = (type: string, event: Record<string, unknown> = {}) => {
|
|
||||||
windowListeners.get(type)?.forEach((listener) => listener(event));
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('CollapsiblePanelAnimator', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
windowListeners.clear();
|
|
||||||
fakeDocument.activeElement = null;
|
|
||||||
vi.stubGlobal('HTMLElement', FakeElement);
|
|
||||||
vi.stubGlobal('document', fakeDocument);
|
|
||||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
|
||||||
callback(0);
|
|
||||||
return 1;
|
|
||||||
});
|
|
||||||
vi.stubGlobal('window', {
|
|
||||||
addEventListener: (type: string, listener: Listener) => {
|
|
||||||
windowListeners.set(type, [...(windowListeners.get(type) ?? []), listener]);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('syncs About panel accessibility when toggled and closed with Escape', () => {
|
|
||||||
const button = new FakeElement();
|
|
||||||
const panel = new FakeElement();
|
|
||||||
const dock = new FakeElement();
|
|
||||||
dock.addChild(button);
|
|
||||||
dock.addChild(panel);
|
|
||||||
|
|
||||||
new CollapsiblePanelAnimator(
|
|
||||||
button as unknown as HTMLButtonElement,
|
|
||||||
panel as unknown as HTMLElement,
|
|
||||||
dock as unknown as HTMLElement
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(button.getAttribute('aria-expanded')).toBe('false');
|
|
||||||
expect(panel.getAttribute('aria-hidden')).toBe('true');
|
|
||||||
expect(panel.inert).toBe(true);
|
|
||||||
expect(panel.classList.contains('hidden')).toBe(true);
|
|
||||||
|
|
||||||
fakeDocument.activeElement = button;
|
|
||||||
button.dispatch('click');
|
|
||||||
|
|
||||||
expect(button.getAttribute('aria-expanded')).toBe('true');
|
|
||||||
expect(button.classList.contains('active')).toBe(true);
|
|
||||||
expect(panel.getAttribute('aria-hidden')).toBe('false');
|
|
||||||
expect(panel.inert).toBe(false);
|
|
||||||
expect(panel.classList.contains('hidden')).toBe(false);
|
|
||||||
expect(fakeDocument.activeElement).toBe(panel);
|
|
||||||
|
|
||||||
const preventDefault = vi.fn();
|
|
||||||
dispatchWindowEvent('keydown', { key: 'Escape', preventDefault });
|
|
||||||
|
|
||||||
expect(preventDefault).toHaveBeenCalledOnce();
|
|
||||||
expect(button.getAttribute('aria-expanded')).toBe('false');
|
|
||||||
expect(panel.getAttribute('aria-hidden')).toBe('true');
|
|
||||||
expect(panel.inert).toBe(true);
|
|
||||||
expect(fakeDocument.activeElement).toBe(button);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -12,20 +12,22 @@ import { isVibeId, VIBE_PRESETS, type VibeId } from '../vibes';
|
||||||
type PaneContainer = Pick<FolderApi, 'addBinding' | 'addButton' | 'addFolder'>;
|
type PaneContainer = Pick<FolderApi, 'addBinding' | 'addButton' | 'addFolder'>;
|
||||||
type ColorReactionKey = (typeof colorReactionRows)[number]['keys'][number];
|
type ColorReactionKey = (typeof colorReactionRows)[number]['keys'][number];
|
||||||
|
|
||||||
|
const COLOR_REACTION_LABELS = ['1', '2', '3'] as const;
|
||||||
|
|
||||||
const colorReactionRows = [
|
const colorReactionRows = [
|
||||||
{
|
{
|
||||||
colorIndex: 0,
|
colorIndex: 0,
|
||||||
label: '1',
|
label: COLOR_REACTION_LABELS[0],
|
||||||
keys: ['color1ToColor1', 'color1ToColor2', 'color1ToColor3'],
|
keys: ['color1ToColor1', 'color1ToColor2', 'color1ToColor3'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
colorIndex: 1,
|
colorIndex: 1,
|
||||||
label: '2',
|
label: COLOR_REACTION_LABELS[1],
|
||||||
keys: ['color2ToColor1', 'color2ToColor2', 'color2ToColor3'],
|
keys: ['color2ToColor1', 'color2ToColor2', 'color2ToColor3'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
colorIndex: 2,
|
colorIndex: 2,
|
||||||
label: '3',
|
label: COLOR_REACTION_LABELS[2],
|
||||||
keys: ['color3ToColor1', 'color3ToColor2', 'color3ToColor3'],
|
keys: ['color3ToColor1', 'color3ToColor2', 'color3ToColor3'],
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
@ -70,24 +72,34 @@ const normalizeNumber = (value: number, config: NumberControlConfig): number =>
|
||||||
if (optionValues.includes(value)) {
|
if (optionValues.includes(value)) {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
return optionValues.includes(0) ? 0 : (optionValues[0] ?? config.min);
|
return optionValues.includes(0) ? 0 : (optionValues[0] ?? config.min ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const finiteValue = Number.isFinite(value) ? value : config.min;
|
const min = config.min ?? Number.NEGATIVE_INFINITY;
|
||||||
const clampedValue = Math.min(config.max, Math.max(config.min, finiteValue));
|
const max = config.max ?? Number.POSITIVE_INFINITY;
|
||||||
|
const fallbackValue = config.min ?? 0;
|
||||||
|
const finiteValue = Number.isFinite(value) ? value : fallbackValue;
|
||||||
|
const clampedValue = Math.min(max, Math.max(min, finiteValue));
|
||||||
return config.integer ? Math.round(clampedValue) : clampedValue;
|
return config.integer ? Math.round(clampedValue) : clampedValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getNumberBindingParams = (
|
const getNumberBindingParams = (
|
||||||
key: keyof GardenRuntimeSettings & string,
|
key: keyof GardenRuntimeSettings & string,
|
||||||
config: NumberControlConfig
|
config: NumberControlConfig
|
||||||
): BindingParams => ({
|
): BindingParams => {
|
||||||
|
const params: BindingParams = {
|
||||||
label: config.label ?? toLabel(key),
|
label: config.label ?? toLabel(key),
|
||||||
min: config.min,
|
|
||||||
max: config.max,
|
|
||||||
options: config.options,
|
options: config.options,
|
||||||
step: config.step,
|
step: config.step,
|
||||||
});
|
};
|
||||||
|
if (config.min !== undefined) {
|
||||||
|
params.min = config.min;
|
||||||
|
}
|
||||||
|
if (config.max !== undefined) {
|
||||||
|
params.max = config.max;
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
};
|
||||||
|
|
||||||
export class ConfigPane {
|
export class ConfigPane {
|
||||||
private readonly container: HTMLDivElement;
|
private readonly container: HTMLDivElement;
|
||||||
|
|
|
||||||
|
|
@ -1,138 +0,0 @@
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
import { appConfig } from '../config';
|
|
||||||
import { MenuHider } from './menu-hider';
|
|
||||||
|
|
||||||
type Listener<T = Record<string, unknown>> = (event: T) => void;
|
|
||||||
|
|
||||||
class FakeClassList {
|
|
||||||
private readonly classes = new Set<string>();
|
|
||||||
|
|
||||||
public add(className: string): void {
|
|
||||||
this.classes.add(className);
|
|
||||||
}
|
|
||||||
|
|
||||||
public contains(className: string): boolean {
|
|
||||||
return this.classes.has(className);
|
|
||||||
}
|
|
||||||
|
|
||||||
public remove(className: string): void {
|
|
||||||
this.classes.delete(className);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class FakeDockElement {
|
|
||||||
public readonly classList = new FakeClassList();
|
|
||||||
public inert = false;
|
|
||||||
|
|
||||||
private readonly attributes = new Map<string, string>();
|
|
||||||
private readonly listeners = new Map<string, Array<Listener>>();
|
|
||||||
|
|
||||||
public addEventListener(type: string, listener: Listener): void {
|
|
||||||
this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public contains(target: unknown): boolean {
|
|
||||||
return target === this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public dispatch(type: string, event: Record<string, unknown> = {}): void {
|
|
||||||
this.listeners.get(type)?.forEach((listener) => listener(event));
|
|
||||||
}
|
|
||||||
|
|
||||||
public getAttribute(name: string): string | null {
|
|
||||||
return this.attributes.get(name) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getBoundingClientRect(): DOMRect {
|
|
||||||
return {
|
|
||||||
bottom: 720,
|
|
||||||
height: 120,
|
|
||||||
left: 0,
|
|
||||||
right: 1280,
|
|
||||||
toJSON: () => ({}),
|
|
||||||
top: 600,
|
|
||||||
width: 1280,
|
|
||||||
x: 0,
|
|
||||||
y: 600,
|
|
||||||
} as DOMRect;
|
|
||||||
}
|
|
||||||
|
|
||||||
public setAttribute(name: string, value: string): void {
|
|
||||||
this.attributes.set(name, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const windowListeners = new Map<string, Array<Listener>>();
|
|
||||||
let isDesktop = true;
|
|
||||||
|
|
||||||
const dispatchWindowEvent = <T extends Record<string, unknown>>(
|
|
||||||
type: string,
|
|
||||||
event: T
|
|
||||||
) => {
|
|
||||||
windowListeners.get(type)?.forEach((listener) => listener(event));
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('MenuHider', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
windowListeners.clear();
|
|
||||||
isDesktop = true;
|
|
||||||
|
|
||||||
vi.stubGlobal('document', {
|
|
||||||
activeElement: null,
|
|
||||||
addEventListener: vi.fn(),
|
|
||||||
documentElement: {
|
|
||||||
clientHeight: 720,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
vi.stubGlobal('window', {
|
|
||||||
addEventListener: (type: string, listener: Listener) => {
|
|
||||||
windowListeners.set(type, [...(windowListeners.get(type) ?? []), listener]);
|
|
||||||
},
|
|
||||||
clearTimeout,
|
|
||||||
innerHeight: 720,
|
|
||||||
matchMedia: () => ({
|
|
||||||
addEventListener: vi.fn(),
|
|
||||||
matches: isDesktop,
|
|
||||||
}),
|
|
||||||
setTimeout,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('hides the dock after the desktop fullscreen pointer leaves it', () => {
|
|
||||||
const dock = new FakeDockElement();
|
|
||||||
|
|
||||||
new MenuHider(dock as unknown as HTMLElement, () => true);
|
|
||||||
dock.dispatch('pointerleave');
|
|
||||||
vi.advanceTimersByTime(appConfig.menuHider.hideDelayMs);
|
|
||||||
|
|
||||||
expect(dock.classList.contains('menu-hidden')).toBe(true);
|
|
||||||
expect(dock.getAttribute('aria-hidden')).toBe('true');
|
|
||||||
expect(dock.inert).toBe(true);
|
|
||||||
|
|
||||||
dispatchWindowEvent('pointermove', { clientX: 640, clientY: 710 });
|
|
||||||
|
|
||||||
expect(dock.classList.contains('menu-hidden')).toBe(false);
|
|
||||||
expect(dock.getAttribute('aria-hidden')).toBe('false');
|
|
||||||
expect(dock.inert).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the dock visible outside the desktop auto-hide breakpoint', () => {
|
|
||||||
isDesktop = false;
|
|
||||||
const dock = new FakeDockElement();
|
|
||||||
|
|
||||||
new MenuHider(dock as unknown as HTMLElement, () => true);
|
|
||||||
dock.dispatch('pointerleave');
|
|
||||||
vi.advanceTimersByTime(appConfig.menuHider.hideDelayMs);
|
|
||||||
|
|
||||||
expect(dock.classList.contains('menu-hidden')).toBe(false);
|
|
||||||
expect(dock.getAttribute('aria-hidden')).toBe('false');
|
|
||||||
expect(dock.inert).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -16,12 +16,17 @@ struct Counters {
|
||||||
var<workgroup> workgroupAliveCount: atomic<u32>;
|
var<workgroup> workgroupAliveCount: atomic<u32>;
|
||||||
var<workgroup> workgroupCompactedOffset: u32;
|
var<workgroup> workgroupCompactedOffset: u32;
|
||||||
|
|
||||||
|
fn dead_agent() -> Agent {
|
||||||
|
return Agent(vec2<f32>(0.0, 0.0), 0.0, -1.0, vec2<f32>(-1.0, -1.0), 0.0, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
@compute @workgroup_size(64)
|
@compute @workgroup_size(64)
|
||||||
fn main(
|
fn main(
|
||||||
@builtin(global_invocation_id) global_id: vec3<u32>,
|
@builtin(global_invocation_id) global_id: vec3<u32>,
|
||||||
@builtin(local_invocation_id) local_id: vec3<u32>
|
@builtin(local_invocation_id) local_id: vec3<u32>,
|
||||||
|
@builtin(num_workgroups) num_workgroups: vec3<u32>
|
||||||
) {
|
) {
|
||||||
let id = get_id(global_id);
|
let id = get_id(global_id, num_workgroups);
|
||||||
|
|
||||||
if local_id.x == 0u {
|
if local_id.x == 0u {
|
||||||
atomicStore(&workgroupAliveCount, 0u);
|
atomicStore(&workgroupAliveCount, 0u);
|
||||||
|
|
@ -30,7 +35,7 @@ fn main(
|
||||||
workgroupBarrier();
|
workgroupBarrier();
|
||||||
|
|
||||||
var localCompactedIndex = 0u;
|
var localCompactedIndex = 0u;
|
||||||
var agent = Agent(vec2<f32>(0.0, 0.0), 0.0, -1.0, vec2<f32>(-1.0, -1.0), 0.0, 0.0);
|
var agent = dead_agent();
|
||||||
var isAlive = false;
|
var isAlive = false;
|
||||||
if id < settings.agentCount {
|
if id < settings.agentCount {
|
||||||
agent = agents[id];
|
agent = agents[id];
|
||||||
|
|
@ -57,3 +62,20 @@ fn main(
|
||||||
compactedAgents[workgroupCompactedOffset + localCompactedIndex] = agent;
|
compactedAgents[workgroupCompactedOffset + localCompactedIndex] = agent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@compute @workgroup_size(64)
|
||||||
|
fn clearCompactedTail(
|
||||||
|
@builtin(global_invocation_id) global_id: vec3<u32>,
|
||||||
|
@builtin(num_workgroups) num_workgroups: vec3<u32>
|
||||||
|
) {
|
||||||
|
let id = get_id(global_id, num_workgroups);
|
||||||
|
|
||||||
|
if id >= settings.agentCount {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let aliveAgentCount = atomicLoad(&counters.aliveAgentCount);
|
||||||
|
if id >= aliveAgentCount {
|
||||||
|
compactedAgents[id] = dead_agent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,210 +0,0 @@
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
import { AGENT_SIZE_IN_BYTES } from './agent';
|
|
||||||
import { AgentGenerationPipeline } from './agent-generation-pipeline';
|
|
||||||
|
|
||||||
const installGpuConstants = () => {
|
|
||||||
Object.defineProperties(globalThis, {
|
|
||||||
GPUBufferUsage: {
|
|
||||||
configurable: true,
|
|
||||||
value: {
|
|
||||||
MAP_READ: 1,
|
|
||||||
COPY_DST: 2,
|
|
||||||
COPY_SRC: 4,
|
|
||||||
STORAGE: 8,
|
|
||||||
UNIFORM: 16,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
GPUMapMode: {
|
|
||||||
configurable: true,
|
|
||||||
value: {
|
|
||||||
READ: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
GPUShaderStage: {
|
|
||||||
configurable: true,
|
|
||||||
value: {
|
|
||||||
COMPUTE: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
type CopyCall = {
|
|
||||||
source: GPUBuffer;
|
|
||||||
sourceOffset: number;
|
|
||||||
destination: GPUBuffer;
|
|
||||||
destinationOffset: number;
|
|
||||||
size: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type DispatchCall = {
|
|
||||||
entryPoint: string;
|
|
||||||
workgroups: [number, number, number];
|
|
||||||
};
|
|
||||||
|
|
||||||
type FakePipeline = {
|
|
||||||
entryPoint: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
class FakeBuffer {
|
|
||||||
private readonly mappedRange: ArrayBuffer;
|
|
||||||
|
|
||||||
public readonly destroy = vi.fn();
|
|
||||||
public readonly mapAsync = vi.fn(async () => undefined);
|
|
||||||
public readonly getMappedRange = vi.fn(() => this.mappedRange);
|
|
||||||
public readonly unmap = vi.fn();
|
|
||||||
|
|
||||||
public constructor(
|
|
||||||
public readonly label: string,
|
|
||||||
size: number,
|
|
||||||
mappedValue = 0
|
|
||||||
) {
|
|
||||||
this.mappedRange = new ArrayBuffer(Math.max(size, Uint32Array.BYTES_PER_ELEMENT));
|
|
||||||
new Uint32Array(this.mappedRange)[0] = mappedValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class FakeComputePass {
|
|
||||||
private pipeline: FakePipeline | null = null;
|
|
||||||
|
|
||||||
public readonly setPipeline = vi.fn((pipeline: GPUComputePipeline) => {
|
|
||||||
this.pipeline = pipeline as unknown as FakePipeline;
|
|
||||||
});
|
|
||||||
public readonly setBindGroup = vi.fn(() => undefined);
|
|
||||||
public readonly dispatchWorkgroups = vi.fn((x: number, y = 1, z = 1) => {
|
|
||||||
this.device.dispatchCalls.push({
|
|
||||||
entryPoint: this.pipeline?.entryPoint ?? 'unset',
|
|
||||||
workgroups: [x, y, z],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
public readonly end = vi.fn();
|
|
||||||
|
|
||||||
public constructor(private readonly device: FakeDevice) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
class FakeCommandEncoder {
|
|
||||||
public readonly beginComputePass = vi.fn(() => new FakeComputePass(this.device));
|
|
||||||
public readonly copyBufferToBuffer = vi.fn(
|
|
||||||
(
|
|
||||||
source: GPUBuffer,
|
|
||||||
sourceOffset: number,
|
|
||||||
destination: GPUBuffer,
|
|
||||||
destinationOffset: number,
|
|
||||||
size: number
|
|
||||||
) => {
|
|
||||||
this.device.copyCalls.push({
|
|
||||||
source,
|
|
||||||
sourceOffset,
|
|
||||||
destination,
|
|
||||||
destinationOffset,
|
|
||||||
size,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
public readonly finish = vi.fn(() => ({}) as GPUCommandBuffer);
|
|
||||||
|
|
||||||
public constructor(private readonly device: FakeDevice) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
class FakeQueue {
|
|
||||||
public readonly writeBuffer = vi.fn(() => undefined);
|
|
||||||
public readonly submit = vi.fn(() => undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
class FakeShaderModule {
|
|
||||||
public readonly getCompilationInfo = vi.fn(async () => ({
|
|
||||||
messages: [],
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
class FakeDevice {
|
|
||||||
public readonly copyCalls: Array<CopyCall> = [];
|
|
||||||
public readonly dispatchCalls: Array<DispatchCall> = [];
|
|
||||||
public readonly createdComputeEntryPoints: Array<string> = [];
|
|
||||||
public readonly limits = {
|
|
||||||
maxBufferSize: 1024 * 1024 * 1024,
|
|
||||||
maxComputeWorkgroupsPerDimension: 65_535,
|
|
||||||
};
|
|
||||||
public readonly queue = new FakeQueue();
|
|
||||||
|
|
||||||
private bufferIndex = 0;
|
|
||||||
|
|
||||||
public readonly createBindGroupLayout = vi.fn(() => ({}) as GPUBindGroupLayout);
|
|
||||||
public readonly createBuffer = vi.fn((descriptor: GPUBufferDescriptor) => {
|
|
||||||
const label =
|
|
||||||
['agents', 'compactedAgents', 'counters', 'countersStaging', 'uniforms'][
|
|
||||||
this.bufferIndex
|
|
||||||
] ?? `buffer${this.bufferIndex}`;
|
|
||||||
this.bufferIndex += 1;
|
|
||||||
|
|
||||||
const isMappedReadBuffer = (Number(descriptor.usage) & GPUBufferUsage.MAP_READ) !== 0;
|
|
||||||
|
|
||||||
return new FakeBuffer(
|
|
||||||
label,
|
|
||||||
Number(descriptor.size),
|
|
||||||
isMappedReadBuffer ? this.compactedCount : 0
|
|
||||||
) as unknown as GPUBuffer;
|
|
||||||
});
|
|
||||||
public readonly createBindGroup = vi.fn(() => ({}) as GPUBindGroup);
|
|
||||||
public readonly createPipelineLayout = vi.fn(() => ({}) as GPUPipelineLayout);
|
|
||||||
public readonly createShaderModule = vi.fn(
|
|
||||||
() => new FakeShaderModule() as unknown as GPUShaderModule
|
|
||||||
);
|
|
||||||
public readonly createComputePipeline = vi.fn(
|
|
||||||
(descriptor: GPUComputePipelineDescriptor) => {
|
|
||||||
const pipeline = {
|
|
||||||
entryPoint: descriptor.compute.entryPoint ?? 'main',
|
|
||||||
};
|
|
||||||
this.createdComputeEntryPoints.push(pipeline.entryPoint);
|
|
||||||
return pipeline as unknown as GPUComputePipeline;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
public readonly createCommandEncoder = vi.fn(() => new FakeCommandEncoder(this));
|
|
||||||
|
|
||||||
public constructor(private readonly compactedCount: number) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const createPipeline = (compactedCount: number) => {
|
|
||||||
installGpuConstants();
|
|
||||||
|
|
||||||
const device = new FakeDevice(compactedCount);
|
|
||||||
|
|
||||||
return {
|
|
||||||
device,
|
|
||||||
pipeline: new AgentGenerationPipeline(device as unknown as GPUDevice, 1024),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('AgentGenerationPipeline compaction', () => {
|
|
||||||
it('swaps compacted agents into the active buffer without a copy-back dispatch', async () => {
|
|
||||||
const agentCount = 10;
|
|
||||||
const { device, pipeline } = createPipeline(3);
|
|
||||||
|
|
||||||
await expect(pipeline.compactAgents(agentCount)).resolves.toBe(3);
|
|
||||||
|
|
||||||
expect(device.createdComputeEntryPoints).not.toContain('copyCompactedAgents');
|
|
||||||
expect(device.dispatchCalls.map((call) => call.entryPoint)).toEqual(['main']);
|
|
||||||
expect(device.copyCalls.map((call) => call.size)).toEqual([
|
|
||||||
Uint32Array.BYTES_PER_ELEMENT,
|
|
||||||
]);
|
|
||||||
expect(
|
|
||||||
device.copyCalls.some((call) => call.size === agentCount * AGENT_SIZE_IN_BYTES)
|
|
||||||
).toBe(false);
|
|
||||||
expect(device.queue.submit).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
pipeline.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not encode work for empty compaction requests', async () => {
|
|
||||||
const { device, pipeline } = createPipeline(0);
|
|
||||||
|
|
||||||
await expect(pipeline.compactAgents(0)).resolves.toBe(0);
|
|
||||||
|
|
||||||
expect(device.dispatchCalls).toEqual([]);
|
|
||||||
expect(device.copyCalls).toEqual([]);
|
|
||||||
expect(device.queue.submit).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
pipeline.destroy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,14 +1,19 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
import { getWorkgroupCount } from '../../../utils/graphics/get-workgroup-count';
|
|
||||||
import { smartCompile } from '../../../utils/graphics/smart-compile';
|
import { smartCompile } from '../../../utils/graphics/smart-compile';
|
||||||
import { AGENT_SIZE_IN_BYTES } from './agent';
|
import {
|
||||||
|
AGENT_MAX_DISPATCHABLE_COUNT,
|
||||||
|
dispatchAgentWorkgroups,
|
||||||
|
} from '../agent-dispatch';
|
||||||
import compactionShader from './agent-compaction.wgsl?raw';
|
import compactionShader from './agent-compaction.wgsl?raw';
|
||||||
import resizeShader from './agent-resize.wgsl?raw';
|
import resizeShader from './agent-resize.wgsl?raw';
|
||||||
import agentSchema from './agent-schema.wgsl?raw';
|
import agentSchema from './agent-schema.wgsl?raw';
|
||||||
|
|
||||||
|
export const AGENT_FLOAT_COUNT = 8;
|
||||||
|
export const AGENT_SIZE_IN_BYTES =
|
||||||
|
AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT;
|
||||||
|
|
||||||
export class AgentGenerationPipeline {
|
export class AgentGenerationPipeline {
|
||||||
private static readonly WORKGROUP_SIZE = 64;
|
|
||||||
private static readonly UNIFORM_COUNT = 4;
|
private static readonly UNIFORM_COUNT = 4;
|
||||||
private static readonly COUNTER_COUNT = 1;
|
private static readonly COUNTER_COUNT = 1;
|
||||||
|
|
||||||
|
|
@ -21,9 +26,11 @@ export class AgentGenerationPipeline {
|
||||||
|
|
||||||
private readonly resizePipeline: GPUComputePipeline;
|
private readonly resizePipeline: GPUComputePipeline;
|
||||||
private readonly compactionPipeline: GPUComputePipeline;
|
private readonly compactionPipeline: GPUComputePipeline;
|
||||||
|
private readonly clearCompactedTailPipeline: GPUComputePipeline;
|
||||||
|
|
||||||
private activeAgentsBuffer: GPUBuffer;
|
private activeAgentsBuffer: GPUBuffer;
|
||||||
private inactiveAgentsBuffer: GPUBuffer;
|
private inactiveAgentsBuffer: GPUBuffer;
|
||||||
|
private allocatedMaxAgentCount: number;
|
||||||
private readonly countersBuffer: GPUBuffer;
|
private readonly countersBuffer: GPUBuffer;
|
||||||
private readonly countersStagingBuffer: GPUBuffer;
|
private readonly countersStagingBuffer: GPUBuffer;
|
||||||
private readonly counterClearValues = new Uint32Array(
|
private readonly counterClearValues = new Uint32Array(
|
||||||
|
|
@ -35,8 +42,10 @@ export class AgentGenerationPipeline {
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly device: GPUDevice,
|
private readonly device: GPUDevice,
|
||||||
private readonly maxAgentCountUpperLimit: number
|
initialMaxAgentCount: number,
|
||||||
|
private readonly maxAgentCountUpperLimit = Number.POSITIVE_INFINITY
|
||||||
) {
|
) {
|
||||||
|
this.allocatedMaxAgentCount = this.clampMaxAgentCount(initialMaxAgentCount);
|
||||||
const emptyBindGroupLayout = device.createBindGroupLayout({ entries: [] });
|
const emptyBindGroupLayout = device.createBindGroupLayout({ entries: [] });
|
||||||
this.bindGroupLayout = device.createBindGroupLayout({
|
this.bindGroupLayout = device.createBindGroupLayout({
|
||||||
entries: [
|
entries: [
|
||||||
|
|
@ -72,7 +81,7 @@ export class AgentGenerationPipeline {
|
||||||
});
|
});
|
||||||
|
|
||||||
this.activeAgentsBuffer = this.createAgentsBuffer();
|
this.activeAgentsBuffer = this.createAgentsBuffer();
|
||||||
this.inactiveAgentsBuffer = this.createAgentsBuffer();
|
this.inactiveAgentsBuffer = this.createInactivePlaceholderBuffer();
|
||||||
|
|
||||||
this.countersBuffer = this.device.createBuffer({
|
this.countersBuffer = this.device.createBuffer({
|
||||||
size: AgentGenerationPipeline.COUNTER_COUNT * Uint32Array.BYTES_PER_ELEMENT,
|
size: AgentGenerationPipeline.COUNTER_COUNT * Uint32Array.BYTES_PER_ELEMENT,
|
||||||
|
|
@ -110,6 +119,16 @@ export class AgentGenerationPipeline {
|
||||||
entryPoint: 'main',
|
entryPoint: 'main',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.clearCompactedTailPipeline = device.createComputePipeline({
|
||||||
|
layout: device.createPipelineLayout({
|
||||||
|
bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout],
|
||||||
|
}),
|
||||||
|
compute: {
|
||||||
|
module: compactionModule,
|
||||||
|
entryPoint: 'clearCompactedTail',
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public get agentsBuffer(): GPUBuffer {
|
public get agentsBuffer(): GPUBuffer {
|
||||||
|
|
@ -118,23 +137,86 @@ export class AgentGenerationPipeline {
|
||||||
|
|
||||||
private createAgentsBuffer(): GPUBuffer {
|
private createAgentsBuffer(): GPUBuffer {
|
||||||
return this.device.createBuffer({
|
return this.device.createBuffer({
|
||||||
size: this.maxAgentCount * AGENT_SIZE_IN_BYTES,
|
size: this.allocatedMaxAgentCount * AGENT_SIZE_IN_BYTES,
|
||||||
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The inactive slot only needs a real allocation during compaction. The rest of
|
||||||
|
// the time we keep a one-agent placeholder so the bind group at binding 3 stays
|
||||||
|
// valid for resize without holding a second N-agent buffer in GPU memory.
|
||||||
|
private createInactivePlaceholderBuffer(): GPUBuffer {
|
||||||
|
return this.device.createBuffer({
|
||||||
|
size: AGENT_SIZE_IN_BYTES,
|
||||||
|
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public get maxAgentCount(): number {
|
public get maxAgentCount(): number {
|
||||||
|
return this.allocatedMaxAgentCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get maxSupportedAgentCount(): number {
|
||||||
|
return this.clampMaxAgentCount(Number.POSITIVE_INFINITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ensureMaxAgentCount(
|
||||||
|
requestedMaxAgentCount: number,
|
||||||
|
activeAgentCount: number
|
||||||
|
): number {
|
||||||
|
const nextMaxAgentCount = this.clampMaxAgentCount(requestedMaxAgentCount);
|
||||||
|
if (nextMaxAgentCount <= this.allocatedMaxAgentCount) {
|
||||||
|
return this.allocatedMaxAgentCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousActiveAgentsBuffer = this.activeAgentsBuffer;
|
||||||
|
const previousMaxAgentCount = this.allocatedMaxAgentCount;
|
||||||
|
this.allocatedMaxAgentCount = nextMaxAgentCount;
|
||||||
|
this.activeAgentsBuffer = this.createAgentsBuffer();
|
||||||
|
|
||||||
|
const copyAgentCount = Math.min(
|
||||||
|
Math.max(0, Math.floor(activeAgentCount)),
|
||||||
|
previousMaxAgentCount,
|
||||||
|
nextMaxAgentCount
|
||||||
|
);
|
||||||
|
if (copyAgentCount > 0) {
|
||||||
|
const commandEncoder = this.device.createCommandEncoder();
|
||||||
|
commandEncoder.copyBufferToBuffer(
|
||||||
|
previousActiveAgentsBuffer,
|
||||||
|
0,
|
||||||
|
this.activeAgentsBuffer,
|
||||||
|
0,
|
||||||
|
copyAgentCount * AGENT_SIZE_IN_BYTES
|
||||||
|
);
|
||||||
|
this.device.queue.submit([commandEncoder.finish()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GPUBuffer.destroy() defers actual freeing until pending submissions
|
||||||
|
// finish, so calling it synchronously after submit is safe and avoids the
|
||||||
|
// transient 4-buffers-live spike that pushes iOS Safari past its per-tab
|
||||||
|
// memory ceiling.
|
||||||
|
previousActiveAgentsBuffer.destroy();
|
||||||
|
return this.allocatedMaxAgentCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
private clampMaxAgentCount(value: number): number {
|
||||||
|
const requestedMaxAgentCount =
|
||||||
|
value === Number.POSITIVE_INFINITY
|
||||||
|
? Number.POSITIVE_INFINITY
|
||||||
|
: Number.isFinite(value)
|
||||||
|
? Math.floor(value)
|
||||||
|
: 0;
|
||||||
return Math.min(
|
return Math.min(
|
||||||
Number.isFinite(this.maxAgentCountUpperLimit)
|
Number.isFinite(this.maxAgentCountUpperLimit)
|
||||||
? this.maxAgentCountUpperLimit
|
? this.maxAgentCountUpperLimit
|
||||||
: Number.POSITIVE_INFINITY,
|
: Number.POSITIVE_INFINITY,
|
||||||
Math.floor(this.device.limits.maxBufferSize / AGENT_SIZE_IN_BYTES) - 1,
|
Math.floor(this.device.limits.maxBufferSize / AGENT_SIZE_IN_BYTES),
|
||||||
Math.floor(
|
Math.floor(
|
||||||
((this.device.limits as GPUSupportedLimits).maxStorageBufferBindingSize ??
|
((this.device.limits as GPUSupportedLimits).maxStorageBufferBindingSize ??
|
||||||
this.device.limits.maxBufferSize) / AGENT_SIZE_IN_BYTES
|
this.device.limits.maxBufferSize) / AGENT_SIZE_IN_BYTES
|
||||||
) - 1,
|
),
|
||||||
this.device.limits.maxComputeWorkgroupsPerDimension *
|
AGENT_MAX_DISPATCHABLE_COUNT,
|
||||||
AgentGenerationPipeline.WORKGROUP_SIZE
|
Math.max(0, requestedMaxAgentCount)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -161,9 +243,7 @@ export class AgentGenerationPipeline {
|
||||||
const passEncoder = commandEncoder.beginComputePass();
|
const passEncoder = commandEncoder.beginComputePass();
|
||||||
passEncoder.setPipeline(this.resizePipeline);
|
passEncoder.setPipeline(this.resizePipeline);
|
||||||
passEncoder.setBindGroup(1, this.getBindGroup());
|
passEncoder.setBindGroup(1, this.getBindGroup());
|
||||||
passEncoder.dispatchWorkgroups(
|
dispatchAgentWorkgroups(passEncoder, agentCount);
|
||||||
getWorkgroupCount(agentCount, AgentGenerationPipeline.WORKGROUP_SIZE)
|
|
||||||
);
|
|
||||||
passEncoder.end();
|
passEncoder.end();
|
||||||
|
|
||||||
this.device.queue.submit([commandEncoder.finish()]);
|
this.device.queue.submit([commandEncoder.finish()]);
|
||||||
|
|
@ -174,6 +254,12 @@ export class AgentGenerationPipeline {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stash the placeholder, swap in a real N-agent destination buffer just
|
||||||
|
// for this compaction so the rest of the time we only carry one full
|
||||||
|
// agent buffer in memory.
|
||||||
|
const placeholder = this.inactiveAgentsBuffer;
|
||||||
|
this.inactiveAgentsBuffer = this.createAgentsBuffer();
|
||||||
|
|
||||||
this.agentCountUniformValues[0] = agentCount;
|
this.agentCountUniformValues[0] = agentCount;
|
||||||
this.device.queue.writeBuffer(this.countersBuffer, 0, this.counterClearValues);
|
this.device.queue.writeBuffer(this.countersBuffer, 0, this.counterClearValues);
|
||||||
this.device.queue.writeBuffer(this.uniforms, 0, this.agentCountUniformValues);
|
this.device.queue.writeBuffer(this.uniforms, 0, this.agentCountUniformValues);
|
||||||
|
|
@ -182,9 +268,9 @@ export class AgentGenerationPipeline {
|
||||||
const passEncoder = commandEncoder.beginComputePass();
|
const passEncoder = commandEncoder.beginComputePass();
|
||||||
passEncoder.setPipeline(this.compactionPipeline);
|
passEncoder.setPipeline(this.compactionPipeline);
|
||||||
passEncoder.setBindGroup(1, this.getBindGroup());
|
passEncoder.setBindGroup(1, this.getBindGroup());
|
||||||
passEncoder.dispatchWorkgroups(
|
dispatchAgentWorkgroups(passEncoder, agentCount);
|
||||||
getWorkgroupCount(agentCount, AgentGenerationPipeline.WORKGROUP_SIZE)
|
passEncoder.setPipeline(this.clearCompactedTailPipeline);
|
||||||
);
|
dispatchAgentWorkgroups(passEncoder, agentCount);
|
||||||
passEncoder.end();
|
passEncoder.end();
|
||||||
|
|
||||||
commandEncoder.copyBufferToBuffer(
|
commandEncoder.copyBufferToBuffer(
|
||||||
|
|
@ -196,6 +282,14 @@ export class AgentGenerationPipeline {
|
||||||
);
|
);
|
||||||
|
|
||||||
this.device.queue.submit([commandEncoder.finish()]);
|
this.device.queue.submit([commandEncoder.finish()]);
|
||||||
|
this.swapAgentBuffers();
|
||||||
|
|
||||||
|
// After swap, inactive is the previous active (full size). Destroy it and
|
||||||
|
// restore the placeholder; the destroy is deferred by WebGPU until the
|
||||||
|
// submitted compaction work has finished.
|
||||||
|
const previousActiveAgentsBuffer = this.inactiveAgentsBuffer;
|
||||||
|
this.inactiveAgentsBuffer = placeholder;
|
||||||
|
previousActiveAgentsBuffer.destroy();
|
||||||
|
|
||||||
await this.countersStagingBuffer.mapAsync(GPUMapMode.READ);
|
await this.countersStagingBuffer.mapAsync(GPUMapMode.READ);
|
||||||
const compactedCount = new Uint32Array(
|
const compactedCount = new Uint32Array(
|
||||||
|
|
@ -204,7 +298,6 @@ export class AgentGenerationPipeline {
|
||||||
1
|
1
|
||||||
)[0];
|
)[0];
|
||||||
this.countersStagingBuffer.unmap();
|
this.countersStagingBuffer.unmap();
|
||||||
this.swapAgentBuffers();
|
|
||||||
|
|
||||||
return compactedCount;
|
return compactedCount;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ struct ResizeSettings {
|
||||||
|
|
||||||
@compute @workgroup_size(64)
|
@compute @workgroup_size(64)
|
||||||
fn main(
|
fn main(
|
||||||
@builtin(global_invocation_id) global_id: vec3<u32>
|
@builtin(global_invocation_id) global_id: vec3<u32>,
|
||||||
|
@builtin(num_workgroups) num_workgroups: vec3<u32>
|
||||||
) {
|
) {
|
||||||
let id = get_id(global_id);
|
let id = get_id(global_id, num_workgroups);
|
||||||
|
|
||||||
if id >= u32(resizeSettings.agentCount) {
|
if id >= u32(resizeSettings.agentCount) {
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import { AGENT_FLOAT_COUNT, AGENT_SIZE_IN_BYTES } from './agent';
|
|
||||||
import compactionShader from './agent-compaction.wgsl?raw';
|
|
||||||
import resizeShader from './agent-resize.wgsl?raw';
|
|
||||||
import agentSchema from './agent-schema.wgsl?raw';
|
|
||||||
|
|
||||||
const wgslFloatCountByType: Record<string, number> = {
|
|
||||||
f32: 1,
|
|
||||||
'vec2<f32>': 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
const getAgentStructFields = () => {
|
|
||||||
const match = /struct Agent\s*\{(?<body>[\s\S]*?)\n\}/.exec(agentSchema);
|
|
||||||
if (!match?.groups?.body) {
|
|
||||||
throw new Error('Agent struct was not found in agent-schema.wgsl');
|
|
||||||
}
|
|
||||||
|
|
||||||
return match.groups.body
|
|
||||||
.split('\n')
|
|
||||||
.map((line) => line.trim().replace(/,$/, ''))
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((line) => {
|
|
||||||
const fieldMatch = /^(?<name>\w+):\s*(?<type>[^,]+)$/.exec(line);
|
|
||||||
if (!fieldMatch?.groups) {
|
|
||||||
throw new Error(`Unsupported Agent field syntax: ${line}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: fieldMatch.groups.name,
|
|
||||||
type: fieldMatch.groups.type,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('Agent TS/WGSL contract', () => {
|
|
||||||
it('keeps the TypeScript float count aligned with the WGSL Agent struct', () => {
|
|
||||||
const fields = getAgentStructFields();
|
|
||||||
const wgslFloatCount = fields.reduce((sum, field) => {
|
|
||||||
const count = wgslFloatCountByType[field.type];
|
|
||||||
if (!count) {
|
|
||||||
throw new Error(`Unsupported WGSL Agent field type: ${field.type}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return sum + count;
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
expect(fields.map((field) => field.name)).toEqual([
|
|
||||||
'position',
|
|
||||||
'angle',
|
|
||||||
'colorIndex',
|
|
||||||
'targetPosition',
|
|
||||||
'targetAngle',
|
|
||||||
'introDelay',
|
|
||||||
]);
|
|
||||||
expect(wgslFloatCount).toBe(AGENT_FLOAT_COUNT);
|
|
||||||
expect(AGENT_SIZE_IN_BYTES).toBe(AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps generation shader workgroup sizes aligned with agent indexing', () => {
|
|
||||||
[resizeShader, compactionShader].forEach((shader) => {
|
|
||||||
expect(shader).toMatch(/@workgroup_size\(64\)/);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(agentSchema).toContain('return global_id.x;');
|
|
||||||
expect(compactionShader).toContain('let id = get_id(global_id);');
|
|
||||||
expect(compactionShader).toContain('if id < settings.agentCount');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps compaction as a ping-pong write without copy-back shader work', () => {
|
|
||||||
expect(compactionShader).not.toContain('fn copyCompactedAgents');
|
|
||||||
expect(compactionShader).not.toContain('agents[id] = compactedAgents[id];');
|
|
||||||
expect(compactionShader).toContain(
|
|
||||||
'compactedAgents[workgroupCompactedOffset + localCompactedIndex] = agent;'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses workgroup-local counting before allocating global compacted ranges', () => {
|
|
||||||
expect(compactionShader).toContain(
|
|
||||||
'var<workgroup> workgroupAliveCount: atomic<u32>;'
|
|
||||||
);
|
|
||||||
expect(compactionShader).toContain(
|
|
||||||
'localCompactedIndex = atomicAdd(&workgroupAliveCount, 1u);'
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
compactionShader.match(/atomicAdd\(&counters\.aliveAgentCount/g) ?? []
|
|
||||||
).toHaveLength(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -9,6 +9,8 @@ struct Agent {
|
||||||
|
|
||||||
@group(1) @binding(1) var<storage, read_write> agents: array<Agent>;
|
@group(1) @binding(1) var<storage, read_write> agents: array<Agent>;
|
||||||
|
|
||||||
fn get_id(global_id: vec3<u32>) -> u32 {
|
const agentWorkgroupSize = 64u;
|
||||||
return global_id.x;
|
|
||||||
|
fn get_id(global_id: vec3<u32>, num_workgroups: vec3<u32>) -> u32 {
|
||||||
|
return global_id.x + global_id.y * num_workgroups.x * agentWorkgroupSize;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
export const AGENT_FLOAT_COUNT = 8;
|
|
||||||
export const AGENT_SIZE_IN_BYTES = AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT;
|
|
||||||
|
|
@ -2,15 +2,14 @@ import {
|
||||||
createCachedFloat32BufferWrite,
|
createCachedFloat32BufferWrite,
|
||||||
writeFloat32BufferIfChanged,
|
writeFloat32BufferIfChanged,
|
||||||
} from '../../utils/graphics/cached-buffer-write';
|
} from '../../utils/graphics/cached-buffer-write';
|
||||||
import { getWorkgroupCount } from '../../utils/graphics/get-workgroup-count';
|
|
||||||
import { smartCompile } from '../../utils/graphics/smart-compile';
|
import { smartCompile } from '../../utils/graphics/smart-compile';
|
||||||
import { CommonState } from '../common-state/common-state';
|
import { CommonState } from '../common-state/common-state';
|
||||||
|
import { dispatchAgentWorkgroups } from './agent-dispatch';
|
||||||
import agentSchema from './agent-generation/agent-schema.wgsl?raw';
|
import agentSchema from './agent-generation/agent-schema.wgsl?raw';
|
||||||
import { AgentSettings } from './agent-settings';
|
import { AgentSettings } from './agent-settings';
|
||||||
import shader from './agent.wgsl?raw';
|
import shader from './agent.wgsl?raw';
|
||||||
|
|
||||||
export class AgentPipeline {
|
export class AgentPipeline {
|
||||||
private static readonly WORKGROUP_SIZE = 64;
|
|
||||||
private static readonly UNIFORM_COUNT = 33;
|
private static readonly UNIFORM_COUNT = 33;
|
||||||
|
|
||||||
private readonly bindGroupLayout: GPUBindGroupLayout;
|
private readonly bindGroupLayout: GPUBindGroupLayout;
|
||||||
|
|
@ -151,9 +150,7 @@ export class AgentPipeline {
|
||||||
passEncoder.setPipeline(this.pipeline);
|
passEncoder.setPipeline(this.pipeline);
|
||||||
this.commonState.execute(passEncoder);
|
this.commonState.execute(passEncoder);
|
||||||
passEncoder.setBindGroup(1, bindGroup);
|
passEncoder.setBindGroup(1, bindGroup);
|
||||||
passEncoder.dispatchWorkgroups(
|
dispatchAgentWorkgroups(passEncoder, this.agentCount);
|
||||||
getWorkgroupCount(this.agentCount, AgentPipeline.WORKGROUP_SIZE)
|
|
||||||
);
|
|
||||||
passEncoder.end();
|
passEncoder.end();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,9 +41,10 @@ struct Settings {
|
||||||
|
|
||||||
@compute @workgroup_size(64)
|
@compute @workgroup_size(64)
|
||||||
fn main(
|
fn main(
|
||||||
@builtin(global_invocation_id) global_id: vec3<u32>
|
@builtin(global_invocation_id) global_id: vec3<u32>,
|
||||||
|
@builtin(num_workgroups) num_workgroups: vec3<u32>
|
||||||
) {
|
) {
|
||||||
let id = get_id(global_id);
|
let id = get_id(global_id, num_workgroups);
|
||||||
|
|
||||||
if id >= u32(settings.agentCount) {
|
if id >= u32(settings.agentCount) {
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import { getSafeDevicePixelRatio, setBrushUniformValues } from './brush-pipeline';
|
|
||||||
|
|
||||||
const brushSettings = {
|
|
||||||
brushAlpha: 0.75,
|
|
||||||
brushCoarseNoiseScale: 100,
|
|
||||||
brushDiscardThreshold: 0.02,
|
|
||||||
brushFeatherRatio: 0.25,
|
|
||||||
brushGrainMaxStrength: 1,
|
|
||||||
brushGrainMinStrength: 0.4,
|
|
||||||
brushGrainNoiseOffsetX: 0.1,
|
|
||||||
brushGrainNoiseOffsetY: 0.2,
|
|
||||||
brushGrainNoiseScale: 25,
|
|
||||||
brushMinimumFeather: 2,
|
|
||||||
brushSize: 10,
|
|
||||||
brushSizeVariation: 0.5,
|
|
||||||
selectedColorIndex: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('brush pipeline parameters', () => {
|
|
||||||
it('scales pixel-space brush uniforms by device pixel ratio', () => {
|
|
||||||
const uniformValues = new Float32Array(16);
|
|
||||||
|
|
||||||
setBrushUniformValues(uniformValues, {
|
|
||||||
...brushSettings,
|
|
||||||
devicePixelRatio: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(uniformValues[0]).toBe(10);
|
|
||||||
expect(uniformValues[1]).toBe(5);
|
|
||||||
expect(uniformValues[3]).toBe(4);
|
|
||||||
expect(uniformValues[5]).toBe(1);
|
|
||||||
expect(uniformValues[8]).toBe(200);
|
|
||||||
expect(uniformValues[9]).toBe(50);
|
|
||||||
expect(uniformValues[15]).toBe(19);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to a 1x pixel ratio for invalid values', () => {
|
|
||||||
expect(getSafeDevicePixelRatio(0)).toBe(1);
|
|
||||||
expect(getSafeDevicePixelRatio(Number.NaN)).toBe(1);
|
|
||||||
expect(getSafeDevicePixelRatio(undefined)).toBe(1);
|
|
||||||
expect(getSafeDevicePixelRatio(1.5)).toBe(1.5);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -16,15 +16,13 @@ interface LineSegment {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BrushParameterSettings extends BrushSettings {
|
interface BrushParameterSettings extends BrushSettings {
|
||||||
devicePixelRatio?: number;
|
pixelRatio?: number;
|
||||||
selectedColorIndex: number;
|
selectedColorIndex: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getSafeDevicePixelRatio = (devicePixelRatio: number | undefined): number =>
|
export const getSafePixelRatio = (pixelRatio: number | undefined): number =>
|
||||||
typeof devicePixelRatio === 'number' &&
|
typeof pixelRatio === 'number' && Number.isFinite(pixelRatio) && pixelRatio > 0
|
||||||
Number.isFinite(devicePixelRatio) &&
|
? pixelRatio
|
||||||
devicePixelRatio > 0
|
|
||||||
? devicePixelRatio
|
|
||||||
: 1;
|
: 1;
|
||||||
|
|
||||||
export const setBrushUniformValues = (
|
export const setBrushUniformValues = (
|
||||||
|
|
@ -43,13 +41,13 @@ export const setBrushUniformValues = (
|
||||||
brushGrainMinStrength,
|
brushGrainMinStrength,
|
||||||
brushGrainMaxStrength,
|
brushGrainMaxStrength,
|
||||||
selectedColorIndex,
|
selectedColorIndex,
|
||||||
devicePixelRatio,
|
pixelRatio,
|
||||||
}: BrushParameterSettings
|
}: BrushParameterSettings
|
||||||
): void => {
|
): void => {
|
||||||
const pixelRatio = getSafeDevicePixelRatio(devicePixelRatio);
|
const safePixelRatio = getSafePixelRatio(pixelRatio);
|
||||||
const brushRadius = (brushSize * pixelRatio) / 2;
|
const brushRadius = (brushSize * safePixelRatio) / 2;
|
||||||
const brushRadiusVariation = Math.floor(brushRadius * brushSizeVariation);
|
const brushRadiusVariation = Math.floor(brushRadius * brushSizeVariation);
|
||||||
const brushMinimumFeatherPixels = brushMinimumFeather * pixelRatio;
|
const brushMinimumFeatherPixels = brushMinimumFeather * safePixelRatio;
|
||||||
const brushFeather = Math.max(
|
const brushFeather = Math.max(
|
||||||
brushMinimumFeatherPixels,
|
brushMinimumFeatherPixels,
|
||||||
brushRadius * brushFeatherRatio
|
brushRadius * brushFeatherRatio
|
||||||
|
|
@ -65,8 +63,8 @@ export const setBrushUniformValues = (
|
||||||
target[5] = selectedColorIndex === 1 ? 1 : 0;
|
target[5] = selectedColorIndex === 1 ? 1 : 0;
|
||||||
target[6] = selectedColorIndex === 2 ? 1 : 0;
|
target[6] = selectedColorIndex === 2 ? 1 : 0;
|
||||||
target[7] = brushAlpha;
|
target[7] = brushAlpha;
|
||||||
target[8] = brushCoarseNoiseScale * pixelRatio;
|
target[8] = brushCoarseNoiseScale * safePixelRatio;
|
||||||
target[9] = brushGrainNoiseScale * pixelRatio;
|
target[9] = brushGrainNoiseScale * safePixelRatio;
|
||||||
target[10] = brushGrainNoiseOffsetX;
|
target[10] = brushGrainNoiseOffsetX;
|
||||||
target[11] = brushGrainNoiseOffsetY;
|
target[11] = brushGrainNoiseOffsetY;
|
||||||
target[12] = brushDiscardThreshold;
|
target[12] = brushDiscardThreshold;
|
||||||
|
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import shader from './diffuse.wgsl?raw';
|
|
||||||
import {
|
|
||||||
getSafeInverseDiffusionRate,
|
|
||||||
setDiffusionUniformValues,
|
|
||||||
} from './diffusion-pipeline';
|
|
||||||
|
|
||||||
describe('diffusion pipeline parameters', () => {
|
|
||||||
it('keeps zero diffusion rates finite before writing shader uniforms', () => {
|
|
||||||
const uniformValues = new Float32Array(8);
|
|
||||||
|
|
||||||
setDiffusionUniformValues(uniformValues, {
|
|
||||||
brushDecayAlphaOffset: 1.001,
|
|
||||||
decayRateBrush: 900,
|
|
||||||
decayRateTrails: 970,
|
|
||||||
diffusionDecayRateDivisor: 1000,
|
|
||||||
diffusionNeighborDivisor: 8,
|
|
||||||
diffusionRateBrush: 0,
|
|
||||||
diffusionRateTrails: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(Number.isFinite(uniformValues[0])).toBe(true);
|
|
||||||
expect(Number.isFinite(uniformValues[2])).toBe(true);
|
|
||||||
expect(uniformValues[0]).toBeGreaterThan(0);
|
|
||||||
expect(uniformValues[2]).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes valid diffusion rates through as inverse values', () => {
|
|
||||||
expect(getSafeInverseDiffusionRate(2)).toBe(0.5);
|
|
||||||
expect(getSafeInverseDiffusionRate(0.25)).toBe(4);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the diffusion shader on the tiled compute sampling path', () => {
|
|
||||||
expect(shader).toContain('@compute @workgroup_size(16, 16)');
|
|
||||||
expect(shader).toContain('var<workgroup> tile');
|
|
||||||
expect(shader).toContain('textureLoad');
|
|
||||||
expect(shader).not.toContain('textureSample');
|
|
||||||
expect(shader).not.toContain('pow(');
|
|
||||||
expect(shader).not.toContain('noise');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps shader resource groups aligned with the simplified pipeline layout', () => {
|
|
||||||
expect(shader).toContain('@group(0) @binding(0) var<uniform> settings');
|
|
||||||
expect(shader).toContain('@group(0) @binding(1) var trailMap');
|
|
||||||
expect(shader).toContain('@group(0) @binding(2) var trailMapOut');
|
|
||||||
expect(shader).not.toContain('@group(1)');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -2,13 +2,12 @@ import {
|
||||||
createCachedFloat32BufferWrite,
|
createCachedFloat32BufferWrite,
|
||||||
writeFloat32BufferIfChanged,
|
writeFloat32BufferIfChanged,
|
||||||
} from '../../utils/graphics/cached-buffer-write';
|
} from '../../utils/graphics/cached-buffer-write';
|
||||||
import { getWorkgroupCount } from '../../utils/graphics/get-workgroup-count';
|
|
||||||
import { smartCompile } from '../../utils/graphics/smart-compile';
|
import { smartCompile } from '../../utils/graphics/smart-compile';
|
||||||
|
import { dispatchAgentWorkgroups } from '../agents/agent-dispatch';
|
||||||
import agentSchema from '../agents/agent-generation/agent-schema.wgsl?raw';
|
import agentSchema from '../agents/agent-generation/agent-schema.wgsl?raw';
|
||||||
import shader from './eraser-agent.wgsl?raw';
|
import shader from './eraser-agent.wgsl?raw';
|
||||||
|
|
||||||
export class EraserAgentPipeline {
|
export class EraserAgentPipeline {
|
||||||
private static readonly WORKGROUP_SIZE = 64;
|
|
||||||
private static readonly UNIFORM_COUNT = 4;
|
private static readonly UNIFORM_COUNT = 4;
|
||||||
|
|
||||||
private readonly bindGroupLayout: GPUBindGroupLayout;
|
private readonly bindGroupLayout: GPUBindGroupLayout;
|
||||||
|
|
@ -118,9 +117,7 @@ export class EraserAgentPipeline {
|
||||||
const passEncoder = commandEncoder.beginComputePass();
|
const passEncoder = commandEncoder.beginComputePass();
|
||||||
passEncoder.setPipeline(this.pipeline);
|
passEncoder.setPipeline(this.pipeline);
|
||||||
passEncoder.setBindGroup(1, this.getBindGroup(eraserMask));
|
passEncoder.setBindGroup(1, this.getBindGroup(eraserMask));
|
||||||
passEncoder.dispatchWorkgroups(
|
dispatchAgentWorkgroups(passEncoder, this.agentCount);
|
||||||
getWorkgroupCount(this.agentCount, EraserAgentPipeline.WORKGROUP_SIZE)
|
|
||||||
);
|
|
||||||
passEncoder.end();
|
passEncoder.end();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,10 @@ struct Settings {
|
||||||
|
|
||||||
@compute @workgroup_size(64)
|
@compute @workgroup_size(64)
|
||||||
fn main(
|
fn main(
|
||||||
@builtin(global_invocation_id) global_id: vec3<u32>
|
@builtin(global_invocation_id) global_id: vec3<u32>,
|
||||||
|
@builtin(num_workgroups) num_workgroups: vec3<u32>
|
||||||
) {
|
) {
|
||||||
let id = get_id(global_id);
|
let id = get_id(global_id, num_workgroups);
|
||||||
|
|
||||||
if id >= u32(settings.agentCount) {
|
if id >= u32(settings.agentCount) {
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -58,19 +58,24 @@ fn renderColor(traces: vec4<f32>, sources: vec4<f32>, pixel: vec2<i32>) -> vec4<
|
||||||
strengths.r * settings.colorA
|
strengths.r * settings.colorA
|
||||||
+ strengths.g * settings.colorB
|
+ strengths.g * settings.colorB
|
||||||
+ strengths.b * settings.colorC;
|
+ strengths.b * settings.colorC;
|
||||||
let normalizedTraceColor =
|
let normalizedTraceColor = normalizeColorIntensity(traceColor);
|
||||||
traceColor / max(settings.traceNormalizationFloor, strengths.r + strengths.g + strengths.b);
|
|
||||||
let brushColor =
|
let brushColor =
|
||||||
sourceStrengths.r * settings.colorA
|
sourceStrengths.r * settings.colorA
|
||||||
+ sourceStrengths.g * settings.colorB
|
+ sourceStrengths.g * settings.colorB
|
||||||
+ sourceStrengths.b * settings.colorC;
|
+ sourceStrengths.b * settings.colorC;
|
||||||
|
let normalizedBrushColor = normalizeColorIntensity(brushColor);
|
||||||
let brushStrength = max(max(sourceStrengths.r, sourceStrengths.g), sourceStrengths.b);
|
let brushStrength = max(max(sourceStrengths.r, sourceStrengths.g), sourceStrengths.b);
|
||||||
let color = max(
|
let brushVisibility = clamp(
|
||||||
normalizedTraceColor,
|
brushStrength * (
|
||||||
brushColor * (settings.brushColorBase + brushStrength * settings.brushColorStrengthMultiplier)
|
settings.brushColorBase +
|
||||||
|
brushStrength * settings.brushColorStrengthMultiplier
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
1
|
||||||
);
|
);
|
||||||
|
let color = max(normalizedTraceColor, normalizedBrushColor);
|
||||||
|
|
||||||
let strength = max(max(strengths.r, strengths.g), strengths.b);
|
let strength = max(max(max(strengths.r, strengths.g), strengths.b), brushVisibility);
|
||||||
return vec4(mix(background, clamp(color, vec3(0), vec3(1)), strength), 1);
|
return vec4(mix(background, clamp(color, vec3(0), vec3(1)), strength), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -78,6 +83,11 @@ fn clarity(strength: f32) -> f32 {
|
||||||
return pow(clamp(strength, 0, 1), settings.clarity);
|
return pow(clamp(strength, 0, 1), settings.clarity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalizeColorIntensity(color: vec3<f32>) -> vec3<f32> {
|
||||||
|
let brightestChannel = max(max(color.r, color.g), color.b);
|
||||||
|
return color / max(settings.traceNormalizationFloor, brightestChannel);
|
||||||
|
}
|
||||||
|
|
||||||
fn getTexturedBackground(pixel: vec2<i32>) -> vec3<f32> {
|
fn getTexturedBackground(pixel: vec2<i32>) -> vec3<f32> {
|
||||||
let noiseSize = vec2<i32>(textureDimensions(noise, 0));
|
let noiseSize = vec2<i32>(textureDimensions(noise, 0));
|
||||||
let noiseCoord = pixel % noiseSize;
|
let noiseCoord = pixel % noiseSize;
|
||||||
|
|
|
||||||
|
|
@ -1,233 +0,0 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import compactionShader from './agents/agent-generation/agent-compaction.wgsl?raw';
|
|
||||||
import { AgentGenerationPipeline } from './agents/agent-generation/agent-generation-pipeline';
|
|
||||||
import resizeShader from './agents/agent-generation/agent-resize.wgsl?raw';
|
|
||||||
import { AgentPipeline } from './agents/agent-pipeline';
|
|
||||||
import agentShader from './agents/agent.wgsl?raw';
|
|
||||||
import { BrushPipeline } from './brush/brush-pipeline';
|
|
||||||
import brushShader from './brush/brush.wgsl?raw';
|
|
||||||
import { CommonState } from './common-state/common-state';
|
|
||||||
import diffusionShader from './diffusion/diffuse.wgsl?raw';
|
|
||||||
import { DiffusionPipeline } from './diffusion/diffusion-pipeline';
|
|
||||||
import { EraserAgentPipeline } from './eraser/eraser-agent-pipeline';
|
|
||||||
import eraserAgentShader from './eraser/eraser-agent.wgsl?raw';
|
|
||||||
import { EraserTexturePipeline } from './eraser/eraser-texture-pipeline';
|
|
||||||
import eraserTextureShader from './eraser/eraser-texture.wgsl?raw';
|
|
||||||
import { RenderPipeline } from './render/render-pipeline';
|
|
||||||
import renderShader from './render/render.wgsl?raw';
|
|
||||||
|
|
||||||
const wgslFloatCountsByType: Record<string, number> = {
|
|
||||||
f32: 1,
|
|
||||||
u32: 1,
|
|
||||||
'vec2<f32>': 2,
|
|
||||||
'vec3<f32>': 3,
|
|
||||||
'vec4<f32>': 4,
|
|
||||||
};
|
|
||||||
|
|
||||||
const stripComments = (source: string): string =>
|
|
||||||
source.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
||||||
|
|
||||||
const getStructFields = (source: string, structName: string) => {
|
|
||||||
const match = new RegExp(
|
|
||||||
`struct ${structName}\\s*\\{(?<body>[\\s\\S]*?)\\n\\s*\\}`
|
|
||||||
).exec(stripComments(source));
|
|
||||||
if (!match?.groups?.body) {
|
|
||||||
throw new Error(`${structName} struct was not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return match.groups.body
|
|
||||||
.split('\n')
|
|
||||||
.map((line) => line.trim().replace(/,$/, ''))
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((line) => {
|
|
||||||
const fieldMatch = /^(?<name>\w+):\s*(?<type>[^,]+)$/.exec(line);
|
|
||||||
if (!fieldMatch?.groups) {
|
|
||||||
throw new Error(`Unsupported WGSL struct field syntax: ${line}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: fieldMatch.groups.name,
|
|
||||||
type: fieldMatch.groups.type,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const countUniformScalars = (source: string, structName: string): number =>
|
|
||||||
getStructFields(source, structName).reduce((sum, field) => {
|
|
||||||
const count = wgslFloatCountsByType[field.type];
|
|
||||||
if (!count) {
|
|
||||||
throw new Error(`Unsupported WGSL uniform field type: ${field.type}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return sum + count;
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
const getUniformCount = (pipeline: unknown): number =>
|
|
||||||
(pipeline as { UNIFORM_COUNT: number }).UNIFORM_COUNT;
|
|
||||||
|
|
||||||
const expectStructUniformLayout = ({
|
|
||||||
pipeline,
|
|
||||||
source,
|
|
||||||
structName,
|
|
||||||
fieldNames,
|
|
||||||
}: {
|
|
||||||
pipeline: unknown;
|
|
||||||
source: string;
|
|
||||||
structName: string;
|
|
||||||
fieldNames: Array<string>;
|
|
||||||
}) => {
|
|
||||||
const fields = getStructFields(source, structName);
|
|
||||||
|
|
||||||
expect(fields.map((field) => field.name)).toEqual(fieldNames);
|
|
||||||
expect(countUniformScalars(source, structName)).toBe(getUniformCount(pipeline));
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('WGSL uniform layout contracts', () => {
|
|
||||||
it('keeps shared common-state uniforms aligned with WGSL', () => {
|
|
||||||
expectStructUniformLayout({
|
|
||||||
pipeline: CommonState,
|
|
||||||
source: CommonState.shaderCode,
|
|
||||||
structName: 'State',
|
|
||||||
fieldNames: ['size', 'time', 'padding0'],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps render and simulation uniforms aligned with WGSL', () => {
|
|
||||||
expectStructUniformLayout({
|
|
||||||
pipeline: AgentPipeline,
|
|
||||||
source: agentShader,
|
|
||||||
structName: 'Settings',
|
|
||||||
fieldNames: [
|
|
||||||
'moveRate',
|
|
||||||
'turnRate',
|
|
||||||
'sensorAngleSin',
|
|
||||||
'sensorAngleCos',
|
|
||||||
'sensorOffset',
|
|
||||||
'turnWhenLost',
|
|
||||||
'individualTrailWeight',
|
|
||||||
'agentCount',
|
|
||||||
'introProgress',
|
|
||||||
'color1ToColor1',
|
|
||||||
'color1ToColor2',
|
|
||||||
'color1ToColor3',
|
|
||||||
'color2ToColor1',
|
|
||||||
'color2ToColor2',
|
|
||||||
'color2ToColor3',
|
|
||||||
'color3ToColor1',
|
|
||||||
'color3ToColor2',
|
|
||||||
'color3ToColor3',
|
|
||||||
'sourceAttractionWeight',
|
|
||||||
'sourceSlowMoveRate',
|
|
||||||
'sourceTrailWeightMultiplier',
|
|
||||||
'forwardRotationScale',
|
|
||||||
'introNearDistanceInner',
|
|
||||||
'introNearDistanceMin',
|
|
||||||
'introNearSensorOffsetMultiplier',
|
|
||||||
'introTargetAngleBlend',
|
|
||||||
'introProgressCutoff',
|
|
||||||
'introTurnRateMultiplier',
|
|
||||||
'introRandomTurnMultiplier',
|
|
||||||
'introFarMoveMultiplier',
|
|
||||||
'introNearMoveMultiplier',
|
|
||||||
'introStepStopDistance',
|
|
||||||
'randomTimeScale',
|
|
||||||
],
|
|
||||||
});
|
|
||||||
expectStructUniformLayout({
|
|
||||||
pipeline: BrushPipeline,
|
|
||||||
source: brushShader,
|
|
||||||
structName: 'Settings',
|
|
||||||
fieldNames: [
|
|
||||||
'brushSize',
|
|
||||||
'brushSizeVariation',
|
|
||||||
'brushFeatherRatio',
|
|
||||||
'brushMinimumFeather',
|
|
||||||
'brushValue',
|
|
||||||
'brushCoarseNoiseScale',
|
|
||||||
'brushGrainNoiseScale',
|
|
||||||
'brushGrainNoiseOffsetX',
|
|
||||||
'brushGrainNoiseOffsetY',
|
|
||||||
'brushDiscardThreshold',
|
|
||||||
'brushGrainMinStrength',
|
|
||||||
'brushGrainMaxStrength',
|
|
||||||
'brushGeometryRadius',
|
|
||||||
],
|
|
||||||
});
|
|
||||||
expectStructUniformLayout({
|
|
||||||
pipeline: DiffusionPipeline,
|
|
||||||
source: diffusionShader,
|
|
||||||
structName: 'Settings',
|
|
||||||
fieldNames: [
|
|
||||||
'inverseDiffusionRateTrails',
|
|
||||||
'decayRateTrails',
|
|
||||||
'inverseDiffusionRateBrush',
|
|
||||||
'decayRateBrush',
|
|
||||||
'diffusionNeighborDivisor',
|
|
||||||
'brushDecayAlphaOffset',
|
|
||||||
'padding0',
|
|
||||||
'padding1',
|
|
||||||
],
|
|
||||||
});
|
|
||||||
expectStructUniformLayout({
|
|
||||||
pipeline: RenderPipeline,
|
|
||||||
source: renderShader,
|
|
||||||
structName: 'Settings',
|
|
||||||
fieldNames: [
|
|
||||||
'colorA',
|
|
||||||
'backgroundColorPadding0',
|
|
||||||
'colorB',
|
|
||||||
'backgroundColorPadding1',
|
|
||||||
'colorC',
|
|
||||||
'backgroundColorPadding2',
|
|
||||||
'backgroundColor',
|
|
||||||
'clarity',
|
|
||||||
'traceNormalizationFloor',
|
|
||||||
'brushColorBase',
|
|
||||||
'brushColorStrengthMultiplier',
|
|
||||||
'backgroundGrainStrength',
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps eraser uniforms aligned with WGSL', () => {
|
|
||||||
expectStructUniformLayout({
|
|
||||||
pipeline: EraserAgentPipeline,
|
|
||||||
source: eraserAgentShader,
|
|
||||||
structName: 'Settings',
|
|
||||||
fieldNames: ['agentCount', 'eraserMaskAlphaThreshold', 'padding1', 'padding2'],
|
|
||||||
});
|
|
||||||
expectStructUniformLayout({
|
|
||||||
pipeline: EraserTexturePipeline,
|
|
||||||
source: eraserTextureShader,
|
|
||||||
structName: 'Settings',
|
|
||||||
fieldNames: [
|
|
||||||
'eraserRadiusSquared',
|
|
||||||
'lineDistanceEpsilon',
|
|
||||||
'clearRed',
|
|
||||||
'clearGreen',
|
|
||||||
'clearBlue',
|
|
||||||
'clearAlpha',
|
|
||||||
'padding0',
|
|
||||||
'padding1',
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps agent-generation uniforms large enough for every generation shader', () => {
|
|
||||||
const generationUniformCounts = [
|
|
||||||
countUniformScalars(resizeShader, 'ResizeSettings'),
|
|
||||||
countUniformScalars(compactionShader, 'Settings'),
|
|
||||||
];
|
|
||||||
|
|
||||||
expect(Math.max(...generationUniformCounts)).toBe(
|
|
||||||
getUniformCount(AgentGenerationPipeline)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('guards invalid high agent color indexes instead of treating them as color 3', () => {
|
|
||||||
expect(agentShader).toContain('colorIndex < 0.0 || colorIndex >= 2.5');
|
|
||||||
expect(agentShader).toContain('if colorIndex < 2.5');
|
|
||||||
expect(agentShader).toContain('return vec3<f32>(0.0, 0.0, 0.0);');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -25,6 +25,8 @@ export const applyVibeSettings = (vibe: VibePreset) => {
|
||||||
Object.assign(settings, {
|
Object.assign(settings, {
|
||||||
...buildSettings(vibe),
|
...buildSettings(vibe),
|
||||||
eraserSize: settings.eraserSize,
|
eraserSize: settings.eraserSize,
|
||||||
|
internalRenderAreaMegapixels: settings.internalRenderAreaMegapixels,
|
||||||
|
maxAgentCount: settings.maxAgentCount,
|
||||||
mirrorSegmentCount: settings.mirrorSegmentCount,
|
mirrorSegmentCount: settings.mirrorSegmentCount,
|
||||||
selectedColorIndex: Math.min(settings.selectedColorIndex, vibe.colors.length - 1),
|
selectedColorIndex: Math.min(settings.selectedColorIndex, vibe.colors.length - 1),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
|
html > body.pre-drawing .dev-stats-overlay {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
html > body {
|
html > body {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
|
@ -47,6 +51,28 @@ html > body {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
> .dev-stats-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: max(8px, env(safe-area-inset-top));
|
||||||
|
left: max(8px, env(safe-area-inset-left));
|
||||||
|
z-index: 6;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid rgb(255 255 255 / 18%);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgb(0 0 0 / 62%);
|
||||||
|
color: rgb(255 255 255 / 92%);
|
||||||
|
font:
|
||||||
|
600 12px/1.35 ui-monospace,
|
||||||
|
SFMono-Regular,
|
||||||
|
Menlo,
|
||||||
|
Consolas,
|
||||||
|
monospace;
|
||||||
|
white-space: pre;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
box-shadow: 0 8px 24px rgb(0 0 0 / 28%);
|
||||||
|
}
|
||||||
|
|
||||||
> .errors-container {
|
> .errors-container {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|
|
||||||
|
|
@ -4,44 +4,101 @@
|
||||||
left: 50%;
|
left: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 18px;
|
gap: 22px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
width: min(78vw, 320px);
|
width: min(86vw, 380px);
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition: opacity var(--transition-time-long);
|
transition: opacity var(--transition-time-long);
|
||||||
|
|
||||||
> .loading-dots {
|
> .splash {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 14px;
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
pointer-events: auto;
|
||||||
|
|
||||||
> .loading-dot {
|
&[hidden] {
|
||||||
width: 14px;
|
display: none;
|
||||||
height: 14px;
|
}
|
||||||
border-radius: 50%;
|
|
||||||
background: rgb(255 255 255 / 92%);
|
> .splash-title {
|
||||||
|
margin: 0;
|
||||||
|
color: rgb(255 255 255 / 96%);
|
||||||
|
font-size: clamp(28px, 6vw, 42px);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
text-align: center;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
text-shadow:
|
||||||
|
0 2px 18px rgb(0 0 0 / 60%),
|
||||||
|
0 0 32px rgb(255 255 255 / 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
> .splash-description {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 28ch;
|
||||||
|
color: rgb(255 255 255 / 80%);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.45;
|
||||||
|
text-align: center;
|
||||||
|
text-shadow: 0 1px 12px rgb(0 0 0 / 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
> .start-button {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 14px 40px;
|
||||||
|
border: 1px solid rgb(255 255 255 / 38%);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgb(255 255 255 / 8%);
|
||||||
|
color: rgb(255 255 255 / 96%);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
cursor: pointer;
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 18px rgb(255 255 255 / 38%),
|
0 0 24px rgb(255 255 255 / 14%),
|
||||||
0 0 4px rgb(255 255 255 / 60%);
|
0 1px 6px rgb(0 0 0 / 28%);
|
||||||
transform: scale(0.5);
|
transition:
|
||||||
opacity: 0.4;
|
opacity var(--transition-time),
|
||||||
animation: loading-bloom 1.4s ease-in-out infinite;
|
transform var(--transition-time),
|
||||||
|
background var(--transition-time);
|
||||||
|
|
||||||
&:nth-child(2) {
|
&[disabled] {
|
||||||
animation-delay: 0.18s;
|
opacity: 0.5;
|
||||||
|
cursor: progress;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:nth-child(3) {
|
&:not([disabled]):hover,
|
||||||
animation-delay: 0.36s;
|
&:not([disabled]):focus-visible {
|
||||||
|
background: rgb(255 255 255 / 16%);
|
||||||
|
transform: scale(1.04);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not([disabled]):active {
|
||||||
|
transform: scale(0.98);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
> .loading-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
> .loading-status {
|
> .loading-status {
|
||||||
color: rgb(255 255 255 / 88%);
|
color: rgb(255 255 255 / 88%);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
|
|
@ -72,11 +129,16 @@
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: var(--loading-progress);
|
width: var(--loading-progress);
|
||||||
border-radius: inherit;
|
border-radius: inherit;
|
||||||
background: linear-gradient(90deg, rgb(255 255 255 / 72%), rgb(255 255 255 / 96%));
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgb(255 255 255 / 72%),
|
||||||
|
rgb(255 255 255 / 96%)
|
||||||
|
);
|
||||||
box-shadow: 0 0 12px rgb(255 255 255 / 38%);
|
box-shadow: 0 0 12px rgb(255 255 255 / 38%);
|
||||||
transition: width var(--transition-time-long) ease-out;
|
transition: width var(--transition-time-long) ease-out;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
html > body.is-loading {
|
html > body.is-loading {
|
||||||
|
|
@ -94,22 +156,3 @@ html > body.is-loading {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@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 {
|
|
||||||
transform: scale(0.85);
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
> .toolbar-shell > nav.buttons > button:hover::after {
|
> nav.buttons > button:hover::after {
|
||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,14 +39,19 @@ html > body > aside.control-dock > .toolbar-row {
|
||||||
--toolbar-background-opacity: 0%;
|
--toolbar-background-opacity: 0%;
|
||||||
--toolbar-background-strength: 0;
|
--toolbar-background-strength: 0;
|
||||||
|
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-template-areas:
|
||||||
|
'previous controls next'
|
||||||
|
'previous buttons next';
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: fit-content;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding-inline: clamp(8px, 1.4vw, 14px);
|
padding-inline: clamp(8px, 1.4vw, 14px);
|
||||||
gap: clamp(6px, 1.8vw, 14px);
|
column-gap: 0;
|
||||||
|
row-gap: clamp(6px, 1.8vw, 14px);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
color: rgb(245 250 244 / 92%);
|
color: rgb(245 250 244 / 92%);
|
||||||
background-color: rgb(5 8 13 / var(--toolbar-background-opacity));
|
background-color: rgb(5 8 13 / var(--toolbar-background-opacity));
|
||||||
|
|
@ -91,16 +96,15 @@ html > body > aside.control-dock > .toolbar-row {
|
||||||
}
|
}
|
||||||
|
|
||||||
> .toolbar-shell {
|
> .toolbar-shell {
|
||||||
|
grid-area: controls;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-areas:
|
grid-template-areas: 'swatches';
|
||||||
'swatches'
|
|
||||||
'nav';
|
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
justify-self: center;
|
||||||
|
width: min(100%, max-content);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 86px;
|
|
||||||
padding: 8px 9px;
|
padding: 8px 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -150,13 +154,22 @@ html > body > aside.control-dock > .toolbar-row {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
> .toolbar-shell > nav.buttons {
|
> .previous-vibe {
|
||||||
grid-area: nav;
|
grid-area: previous;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .next-vibe {
|
||||||
|
grid-area: next;
|
||||||
|
}
|
||||||
|
|
||||||
|
> nav.buttons {
|
||||||
|
grid-area: buttons;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: space-between;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding-top: 7px;
|
padding-top: 7px;
|
||||||
border-top: 1px solid rgb(255 255 255 / 12%);
|
border-top: 1px solid rgb(255 255 255 / 12%);
|
||||||
|
|
@ -166,6 +179,9 @@ html > body > aside.control-dock > .toolbar-row {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 44px;
|
width: 44px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
|
flex: 1 1 44px;
|
||||||
|
max-width: 54px;
|
||||||
|
min-width: 0;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|
@ -235,7 +251,8 @@ html > body > aside.control-dock > .toolbar-row {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 132px;
|
width: 132px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
flex: 0 0 132px;
|
flex: 2 1 132px;
|
||||||
|
max-width: 150px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding-right: 10px;
|
padding-right: 10px;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
|
|
@ -252,18 +269,13 @@ html > body > aside.control-dock > .toolbar-row {
|
||||||
background: rgb(255 255 255 / 7%);
|
background: rgb(255 255 255 / 7%);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus-within {
|
|
||||||
outline: 2px solid white;
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
> button {
|
> button {
|
||||||
flex: 0 0 42px;
|
flex: 0 0 42px;
|
||||||
min-width: 42px;
|
min-width: 42px;
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
|
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
outline: none;
|
outline-offset: -4px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -296,7 +308,9 @@ html > body > aside.control-dock > .toolbar-row {
|
||||||
touch-action: pan-y;
|
touch-action: pan-y;
|
||||||
|
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
outline: none;
|
border-radius: 8px;
|
||||||
|
outline: 2px solid white;
|
||||||
|
outline-offset: -4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::-webkit-slider-runnable-track {
|
&::-webkit-slider-runnable-track {
|
||||||
|
|
@ -558,11 +572,12 @@ html > body > aside.control-dock > .toolbar-row {
|
||||||
|
|
||||||
@include on-small-screen {
|
@include on-small-screen {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding-inline: 6px;
|
padding-inline: 4px;
|
||||||
gap: 6px;
|
column-gap: 0;
|
||||||
|
row-gap: 4px;
|
||||||
|
|
||||||
> .vibe-button {
|
> .vibe-button {
|
||||||
width: 44px;
|
width: 36px;
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
|
|
@ -572,15 +587,18 @@ html > body > aside.control-dock > .toolbar-row {
|
||||||
}
|
}
|
||||||
|
|
||||||
> .toolbar-shell {
|
> .toolbar-shell {
|
||||||
flex: 1 1 auto;
|
padding: 4px;
|
||||||
padding: 4px 8px;
|
}
|
||||||
|
|
||||||
> nav.buttons {
|
> nav.buttons {
|
||||||
gap: 2px;
|
gap: clamp(1px, 0.55vw, 2px);
|
||||||
padding-top: 3px;
|
padding-top: 3px;
|
||||||
|
|
||||||
> button {
|
> button {
|
||||||
|
width: auto;
|
||||||
height: 38px;
|
height: 38px;
|
||||||
|
flex: 1 1 clamp(28px, 8vw, 38px);
|
||||||
|
max-width: 38px;
|
||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
|
|
@ -590,14 +608,16 @@ html > body > aside.control-dock > .toolbar-row {
|
||||||
}
|
}
|
||||||
|
|
||||||
> .audio-control {
|
> .audio-control {
|
||||||
width: 118px;
|
width: auto;
|
||||||
height: 38px;
|
height: 38px;
|
||||||
flex-basis: 118px;
|
flex: 2 1 clamp(58px, 18vw, 118px);
|
||||||
padding-right: 9px;
|
max-width: 118px;
|
||||||
|
padding-right: clamp(4px, 1.8vw, 9px);
|
||||||
|
|
||||||
> button {
|
> button {
|
||||||
flex-basis: 38px;
|
width: auto;
|
||||||
min-width: 38px;
|
flex: 1 1 clamp(28px, 8vw, 38px);
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
> .volume-control {
|
> .volume-control {
|
||||||
|
|
@ -606,12 +626,13 @@ html > body > aside.control-dock > .toolbar-row {
|
||||||
}
|
}
|
||||||
|
|
||||||
> .export-status {
|
> .export-status {
|
||||||
flex-basis: 100%;
|
flex-basis: 0;
|
||||||
max-width: 100%;
|
max-width: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
> .toolbar-shell {
|
||||||
> .garden-controls {
|
> .garden-controls {
|
||||||
padding: 2px 4px;
|
padding: 2px 4px;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,10 @@ export const writeBrowserStorage = (key: string, value: string): void => {
|
||||||
if (typeof localStorage !== 'undefined') {
|
if (typeof localStorage !== 'undefined') {
|
||||||
localStorage.setItem(key, value);
|
localStorage.setItem(key, value);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
// Storage can be unavailable in private browsing or embedded contexts.
|
console.warn(
|
||||||
|
'Storage can be unavailable in private browsing or embedded contexts.',
|
||||||
|
error,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
export const clamp = (value: number, min: number, max: number): number =>
|
|
||||||
Math.min(max, Math.max(min, value));
|
|
||||||
|
|
||||||
export const clamp01 = (value: number): number => Math.min(1, Math.max(0, value));
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { appConfig } from '../config';
|
import { appConfig } from '../config';
|
||||||
import { clamp } from './clamp';
|
import { clamp } from './math';
|
||||||
|
|
||||||
export class DeltaTimeCalculator {
|
export class DeltaTimeCalculator {
|
||||||
private previousTime: DOMHighResTimeStamp | null = null;
|
private previousTime: DOMHighResTimeStamp | null = null;
|
||||||
|
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
import {
|
|
||||||
createCachedFloat32BufferWrite,
|
|
||||||
writeFloat32BufferIfChanged,
|
|
||||||
} from './cached-buffer-write';
|
|
||||||
|
|
||||||
const createGpuWriteStub = () => {
|
|
||||||
const writeBuffer = vi.fn();
|
|
||||||
const device = {
|
|
||||||
queue: {
|
|
||||||
writeBuffer,
|
|
||||||
},
|
|
||||||
} as unknown as GPUDevice;
|
|
||||||
|
|
||||||
return { device, writeBuffer };
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('cached float32 buffer writes', () => {
|
|
||||||
it('writes the first value set and skips unchanged values', () => {
|
|
||||||
const { device, writeBuffer } = createGpuWriteStub();
|
|
||||||
const buffer = {} as GPUBuffer;
|
|
||||||
const cache = createCachedFloat32BufferWrite(3);
|
|
||||||
const values = new Float32Array([1, 2, 3]);
|
|
||||||
|
|
||||||
expect(writeFloat32BufferIfChanged(device, buffer, values, cache)).toBe(true);
|
|
||||||
expect(writeBuffer).toHaveBeenCalledTimes(1);
|
|
||||||
expect(writeBuffer).toHaveBeenLastCalledWith(buffer, 0, values);
|
|
||||||
|
|
||||||
expect(writeFloat32BufferIfChanged(device, buffer, values, cache)).toBe(false);
|
|
||||||
expect(writeBuffer).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('writes again when any float changes', () => {
|
|
||||||
const { device, writeBuffer } = createGpuWriteStub();
|
|
||||||
const buffer = {} as GPUBuffer;
|
|
||||||
const cache = createCachedFloat32BufferWrite(3);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
writeFloat32BufferIfChanged(device, buffer, new Float32Array([1, 2, 3]), cache)
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
writeFloat32BufferIfChanged(device, buffer, new Float32Array([1, 2, 4]), cache)
|
|
||||||
).toBe(true);
|
|
||||||
expect(writeBuffer).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects cache length mismatches before writing', () => {
|
|
||||||
const { device, writeBuffer } = createGpuWriteStub();
|
|
||||||
const buffer = {} as GPUBuffer;
|
|
||||||
const cache = createCachedFloat32BufferWrite(2);
|
|
||||||
|
|
||||||
expect(() =>
|
|
||||||
writeFloat32BufferIfChanged(device, buffer, new Float32Array([1]), cache)
|
|
||||||
).toThrow('Cached buffer write length mismatch');
|
|
||||||
expect(writeBuffer).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import { getWorkgroupCount } from './get-workgroup-count';
|
|
||||||
|
|
||||||
describe('getWorkgroupCount', () => {
|
|
||||||
it('returns at least one workgroup for positive invocation counts', () => {
|
|
||||||
expect(getWorkgroupCount(1, 64)).toBe(1);
|
|
||||||
expect(getWorkgroupCount(65, 64)).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects zero and non-finite dispatch inputs', () => {
|
|
||||||
expect(() => getWorkgroupCount(0, 64)).toThrow(/positive finite/);
|
|
||||||
expect(() => getWorkgroupCount(-1, 64)).toThrow(/positive finite/);
|
|
||||||
expect(() => getWorkgroupCount(Number.POSITIVE_INFINITY, 64)).toThrow(
|
|
||||||
/positive finite/
|
|
||||||
);
|
|
||||||
expect(() => getWorkgroupCount(1, 0)).toThrow(/positive finite/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,253 +0,0 @@
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
import { ErrorCode, ErrorHandler, RuntimeError, Severity } from '../error-handler';
|
|
||||||
import { initializeGpu } from './initialize-gpu';
|
|
||||||
|
|
||||||
const gpuLimits = {
|
|
||||||
maxBufferSize: 256 * 1024 * 1024,
|
|
||||||
maxComputeWorkgroupsPerDimension: 65_535,
|
|
||||||
maxStorageBufferBindingSize: 128 * 1024 * 1024,
|
|
||||||
} as GPUSupportedLimits;
|
|
||||||
|
|
||||||
const observedErrors: Array<{
|
|
||||||
code?: string;
|
|
||||||
message: string;
|
|
||||||
severity: Severity;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
ErrorHandler.addOnErrorListener((error) => {
|
|
||||||
observedErrors.push(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
const defer = <T>() => {
|
|
||||||
let resolve!: (value: T) => void;
|
|
||||||
const promise = new Promise<T>((nextResolve) => {
|
|
||||||
resolve = nextResolve;
|
|
||||||
});
|
|
||||||
|
|
||||||
return { promise, resolve };
|
|
||||||
};
|
|
||||||
|
|
||||||
const stubBrowser = ({
|
|
||||||
gpu,
|
|
||||||
isSecureContext = true,
|
|
||||||
}: {
|
|
||||||
gpu?: GPU;
|
|
||||||
isSecureContext?: boolean;
|
|
||||||
}) => {
|
|
||||||
vi.stubGlobal('window', { isSecureContext });
|
|
||||||
vi.stubGlobal('navigator', { gpu });
|
|
||||||
};
|
|
||||||
|
|
||||||
const createDevice = (
|
|
||||||
lost: Promise<GPUDeviceLostInfo> = new Promise<GPUDeviceLostInfo>(() => {})
|
|
||||||
) => {
|
|
||||||
const listeners = new Map<string, EventListener>();
|
|
||||||
const device = {
|
|
||||||
addEventListener: vi.fn((type: string, listener: EventListener) => {
|
|
||||||
listeners.set(type, listener);
|
|
||||||
}),
|
|
||||||
lost,
|
|
||||||
} as unknown as GPUDevice;
|
|
||||||
|
|
||||||
return { device, listeners };
|
|
||||||
};
|
|
||||||
|
|
||||||
const createAdapter = ({
|
|
||||||
requestDevice = vi.fn(),
|
|
||||||
}: {
|
|
||||||
requestDevice?: ReturnType<typeof vi.fn>;
|
|
||||||
} = {}) =>
|
|
||||||
({
|
|
||||||
features: new Set(),
|
|
||||||
info: {
|
|
||||||
architecture: 'test',
|
|
||||||
description: 'unit-test adapter',
|
|
||||||
device: 'test-device',
|
|
||||||
isFallbackAdapter: false,
|
|
||||||
subgroupMaxSize: 0,
|
|
||||||
subgroupMinSize: 0,
|
|
||||||
vendor: 'test-vendor',
|
|
||||||
},
|
|
||||||
limits: gpuLimits,
|
|
||||||
requestDevice,
|
|
||||||
}) as unknown as GPUAdapter;
|
|
||||||
|
|
||||||
const captureInitializeGpuError = async (): Promise<RuntimeError> => {
|
|
||||||
try {
|
|
||||||
await initializeGpu();
|
|
||||||
} catch (error) {
|
|
||||||
expect(error).toBeInstanceOf(RuntimeError);
|
|
||||||
return error as RuntimeError;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('Expected initializeGpu to reject.');
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('initializeGpu', () => {
|
|
||||||
afterEach(() => {
|
|
||||||
observedErrors.length = 0;
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects insecure contexts before touching WebGPU', async () => {
|
|
||||||
stubBrowser({ isSecureContext: false });
|
|
||||||
|
|
||||||
const error = await captureInitializeGpuError();
|
|
||||||
|
|
||||||
expect(error.code).toBe(ErrorCode.WEBGPU_INSECURE_CONTEXT);
|
|
||||||
expect(error.message).toContain('WebGPU requires a secure context');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects browsers without navigator.gpu', async () => {
|
|
||||||
stubBrowser({});
|
|
||||||
|
|
||||||
const error = await captureInitializeGpuError();
|
|
||||||
|
|
||||||
expect(error.code).toBe(ErrorCode.WEBGPU_UNSUPPORTED);
|
|
||||||
expect(error.message).toContain('Fleeting Garden needs WebGPU');
|
|
||||||
expect(error.details).toMatchObject({
|
|
||||||
hasNavigatorGpu: false,
|
|
||||||
isSecureContext: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('wraps adapter request exceptions with adapter diagnostics', async () => {
|
|
||||||
const requestAdapter = vi.fn(async () => {
|
|
||||||
throw new Error('adapter request failed');
|
|
||||||
});
|
|
||||||
stubBrowser({ gpu: { requestAdapter } as unknown as GPU });
|
|
||||||
|
|
||||||
const error = await captureInitializeGpuError();
|
|
||||||
|
|
||||||
expect(requestAdapter).toHaveBeenCalledOnce();
|
|
||||||
expect(requestAdapter).toHaveBeenCalledWith({ powerPreference: 'high-performance' });
|
|
||||||
expect(error.code).toBe(ErrorCode.WEBGPU_ADAPTER_UNAVAILABLE);
|
|
||||||
expect(error.message).toBe('Could not request a WebGPU adapter.');
|
|
||||||
expect(error.details).toMatchObject({
|
|
||||||
causeMessage: 'adapter request failed',
|
|
||||||
powerPreference: 'high-performance',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('tries the default adapter before reporting adapter unavailability', async () => {
|
|
||||||
const requestAdapter = vi.fn(async () => null);
|
|
||||||
stubBrowser({ gpu: { requestAdapter } as unknown as GPU });
|
|
||||||
|
|
||||||
const error = await captureInitializeGpuError();
|
|
||||||
|
|
||||||
expect(requestAdapter).toHaveBeenNthCalledWith(1, {
|
|
||||||
powerPreference: 'high-performance',
|
|
||||||
});
|
|
||||||
expect(requestAdapter).toHaveBeenNthCalledWith(2, undefined);
|
|
||||||
expect(error.code).toBe(ErrorCode.WEBGPU_ADAPTER_UNAVAILABLE);
|
|
||||||
expect(error.message).toContain('could not provide a compatible GPU adapter');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('requests the device with the adapter limits needed by the pipelines', async () => {
|
|
||||||
const { device } = createDevice();
|
|
||||||
const requestDevice = vi.fn(async () => device);
|
|
||||||
const adapter = createAdapter({ requestDevice });
|
|
||||||
stubBrowser({
|
|
||||||
gpu: { requestAdapter: vi.fn(async () => adapter) } as unknown as GPU,
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(initializeGpu()).resolves.toBe(device);
|
|
||||||
|
|
||||||
expect(requestDevice).toHaveBeenCalledWith({
|
|
||||||
requiredLimits: {
|
|
||||||
maxBufferSize: gpuLimits.maxBufferSize,
|
|
||||||
maxComputeWorkgroupsPerDimension: gpuLimits.maxComputeWorkgroupsPerDimension,
|
|
||||||
maxStorageBufferBindingSize: gpuLimits.maxStorageBufferBindingSize,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(device.addEventListener).toHaveBeenCalledWith(
|
|
||||||
'uncapturederror',
|
|
||||||
expect.any(Function)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('wraps device request failures with required limit details', async () => {
|
|
||||||
const requestDevice = vi.fn(async () => {
|
|
||||||
throw new Error('device request failed');
|
|
||||||
});
|
|
||||||
const adapter = createAdapter({ requestDevice });
|
|
||||||
stubBrowser({
|
|
||||||
gpu: { requestAdapter: vi.fn(async () => adapter) } as unknown as GPU,
|
|
||||||
});
|
|
||||||
|
|
||||||
const error = await captureInitializeGpuError();
|
|
||||||
|
|
||||||
expect(error.code).toBe(ErrorCode.WEBGPU_DEVICE_UNAVAILABLE);
|
|
||||||
expect(error.message).toBe('Could not create a WebGPU device for this adapter.');
|
|
||||||
expect(error.details).toMatchObject({
|
|
||||||
causeMessage: 'device request failed',
|
|
||||||
requiredLimits: {
|
|
||||||
maxBufferSize: gpuLimits.maxBufferSize,
|
|
||||||
maxComputeWorkgroupsPerDimension: gpuLimits.maxComputeWorkgroupsPerDimension,
|
|
||||||
maxStorageBufferBindingSize: gpuLimits.maxStorageBufferBindingSize,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('routes uncaptured GPU errors through the runtime error handler', async () => {
|
|
||||||
const { device, listeners } = createDevice();
|
|
||||||
const adapter = createAdapter({ requestDevice: vi.fn(async () => device) });
|
|
||||||
stubBrowser({
|
|
||||||
gpu: { requestAdapter: vi.fn(async () => adapter) } as unknown as GPU,
|
|
||||||
});
|
|
||||||
|
|
||||||
await initializeGpu();
|
|
||||||
listeners.get('uncapturederror')?.({
|
|
||||||
error: new Error('uncaptured GPU validation failure'),
|
|
||||||
} as unknown as GPUUncapturedErrorEvent);
|
|
||||||
|
|
||||||
expect(observedErrors.at(-1)).toMatchObject({
|
|
||||||
code: ErrorCode.WEBGPU_UNCAPTURED_ERROR,
|
|
||||||
message: 'uncaptured GPU validation failure',
|
|
||||||
severity: Severity.ERROR,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reports unexpected device loss but ignores intentional destruction', async () => {
|
|
||||||
const unexpectedLoss = defer<GPUDeviceLostInfo>();
|
|
||||||
const { device } = createDevice(unexpectedLoss.promise);
|
|
||||||
const adapter = createAdapter({ requestDevice: vi.fn(async () => device) });
|
|
||||||
stubBrowser({
|
|
||||||
gpu: { requestAdapter: vi.fn(async () => adapter) } as unknown as GPU,
|
|
||||||
});
|
|
||||||
|
|
||||||
await initializeGpu();
|
|
||||||
unexpectedLoss.resolve({
|
|
||||||
message: 'device lost during rendering',
|
|
||||||
reason: 'unknown',
|
|
||||||
} as GPUDeviceLostInfo);
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
expect(observedErrors.at(-1)).toMatchObject({
|
|
||||||
code: ErrorCode.WEBGPU_DEVICE_LOST,
|
|
||||||
message: 'device lost during rendering',
|
|
||||||
severity: Severity.ERROR,
|
|
||||||
});
|
|
||||||
|
|
||||||
observedErrors.length = 0;
|
|
||||||
const destroyedLoss = defer<GPUDeviceLostInfo>();
|
|
||||||
const { device: destroyedDevice } = createDevice(destroyedLoss.promise);
|
|
||||||
const destroyedAdapter = createAdapter({
|
|
||||||
requestDevice: vi.fn(async () => destroyedDevice),
|
|
||||||
});
|
|
||||||
stubBrowser({
|
|
||||||
gpu: { requestAdapter: vi.fn(async () => destroyedAdapter) } as unknown as GPU,
|
|
||||||
});
|
|
||||||
|
|
||||||
await initializeGpu();
|
|
||||||
destroyedLoss.resolve({
|
|
||||||
message: 'device destroyed intentionally',
|
|
||||||
reason: 'destroyed',
|
|
||||||
} as GPUDeviceLostInfo);
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
expect(observedErrors).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import { clamp, clamp01 } from './clamp';
|
|
||||||
|
|
||||||
describe('clamp', () => {
|
|
||||||
it('returns value when within bounds', () => {
|
|
||||||
expect(clamp(5, 0, 10)).toBe(5);
|
|
||||||
});
|
|
||||||
it('clamps below to lower bound', () => {
|
|
||||||
expect(clamp(-3, 0, 10)).toBe(0);
|
|
||||||
});
|
|
||||||
it('clamps above to upper bound', () => {
|
|
||||||
expect(clamp(42, 0, 10)).toBe(10);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('clamp01', () => {
|
|
||||||
it('passes through values in [0, 1]', () => {
|
|
||||||
expect(clamp01(0.25)).toBe(0.25);
|
|
||||||
});
|
|
||||||
it('clamps negatives to 0', () => {
|
|
||||||
expect(clamp01(-1)).toBe(0);
|
|
||||||
});
|
|
||||||
it('clamps above 1 to 1', () => {
|
|
||||||
expect(clamp01(2)).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,134 +0,0 @@
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
import { gardenAudioConfig } from './audio/garden-audio-config';
|
|
||||||
import { getInitialVibe, hexToRgb, VIBE_PRESETS, VibeId } from './vibes';
|
|
||||||
|
|
||||||
const originalLocalStorage = globalThis.localStorage;
|
|
||||||
|
|
||||||
const setBrowserVibeState = ({
|
|
||||||
storedVibeId = null,
|
|
||||||
}: {
|
|
||||||
storedVibeId?: string | null;
|
|
||||||
}) => {
|
|
||||||
Object.defineProperty(globalThis, 'localStorage', {
|
|
||||||
configurable: true,
|
|
||||||
value: {
|
|
||||||
getItem: vi.fn((key: string) =>
|
|
||||||
key === 'fleeting-garden:vibe' ? storedVibeId : null
|
|
||||||
),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('vibe selection', () => {
|
|
||||||
afterEach(() => {
|
|
||||||
Object.defineProperty(globalThis, 'localStorage', {
|
|
||||||
configurable: true,
|
|
||||||
value: originalLocalStorage,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses a valid stored vibe id', () => {
|
|
||||||
setBrowserVibeState({ storedVibeId: VibeId.SunlitMoss });
|
|
||||||
|
|
||||||
expect(getInitialVibe().id).toBe(VibeId.SunlitMoss);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to the default preset for an unknown stored vibe id', () => {
|
|
||||||
setBrowserVibeState({ storedVibeId: 'unknown' });
|
|
||||||
|
|
||||||
expect(getInitialVibe()).toBe(VIBE_PRESETS[0]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('vibe and audio config contract', () => {
|
|
||||||
it('keeps preset ids unique and URL-safe', () => {
|
|
||||||
const vibeIds = VIBE_PRESETS.map((vibe) => vibe.id);
|
|
||||||
|
|
||||||
expect(new Set(vibeIds).size).toBe(vibeIds.length);
|
|
||||||
expect(vibeIds.every((id) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps each vibe palette and audio profile complete', () => {
|
|
||||||
VIBE_PRESETS.forEach((vibe) => {
|
|
||||||
expect(vibe.colors).toHaveLength(3);
|
|
||||||
vibe.colors.forEach((color) => {
|
|
||||||
expect(color).toMatch(/^#[0-9a-f]{6}$/i);
|
|
||||||
hexToRgb(color).forEach((channel) => {
|
|
||||||
expect(channel).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(channel).toBeLessThanOrEqual(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const profile = vibe.audio;
|
|
||||||
expect(Number.isFinite(profile.rootMidi)).toBe(true);
|
|
||||||
expect(profile.scale.length).toBeGreaterThan(0);
|
|
||||||
expect(profile.scale.every((degree) => Number.isFinite(degree))).toBe(true);
|
|
||||||
expect(profile.brightness).toBeGreaterThan(0);
|
|
||||||
expect(profile.delayTimeMultiplier).toBeGreaterThan(0);
|
|
||||||
expect(profile.progression.length).toBeGreaterThan(0);
|
|
||||||
profile.progression.forEach((chord) => {
|
|
||||||
expect(Number.isFinite(chord.rootOffset)).toBe(true);
|
|
||||||
expect(['major', 'minor']).toContain(chord.quality);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps audio style voices aligned with the rotating style pools', () => {
|
|
||||||
expect(gardenAudioConfig.styleVoices).toHaveLength(
|
|
||||||
gardenAudioConfig.generativePiano.stylePools.length
|
|
||||||
);
|
|
||||||
gardenAudioConfig.styleVoices.forEach((voice) => {
|
|
||||||
expect(Number.isFinite(voice.scaleDegreeOffset)).toBe(true);
|
|
||||||
expect(voice.velocityMultiplier).toBeGreaterThan(0);
|
|
||||||
expect(Math.abs(voice.panOffset)).toBeLessThanOrEqual(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps audio timing, graph, and density settings bounded', () => {
|
|
||||||
const { delay, generativePiano, graph, piano, rhythm } = gardenAudioConfig;
|
|
||||||
|
|
||||||
expect(rhythm.bpm).toBeGreaterThan(0);
|
|
||||||
expect(rhythm.stepsPerBeat).toBeGreaterThan(0);
|
|
||||||
expect(rhythm.stepsPerBar).toBeGreaterThanOrEqual(rhythm.stepsPerBeat);
|
|
||||||
expect(rhythm.lookaheadSeconds).toBeGreaterThanOrEqual(piano.scheduleAheadSeconds);
|
|
||||||
|
|
||||||
expect(delay.feedbackMin).toBeLessThanOrEqual(delay.feedback);
|
|
||||||
expect(delay.feedback).toBeLessThanOrEqual(delay.feedbackMax);
|
|
||||||
expect(delay.feedbackHighPassHz).toBeLessThan(delay.feedbackLowPassHz);
|
|
||||||
expect(delay.returnLowPassHz).toBeGreaterThan(delay.feedbackHighPassHz);
|
|
||||||
|
|
||||||
generativePiano.stylePools.forEach((register) => {
|
|
||||||
expect(register.midiMin).toBeLessThan(register.preferredMidi);
|
|
||||||
expect(register.preferredMidi).toBeLessThan(register.midiMax);
|
|
||||||
});
|
|
||||||
generativePiano.padRegisters.forEach((register) => {
|
|
||||||
expect(register.midiMin).toBeLessThan(register.preferredMidi);
|
|
||||||
expect(register.preferredMidi).toBeLessThan(register.midiMax);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(generativePiano.brushStreamIdleIntervalBeats).toBeGreaterThanOrEqual(
|
|
||||||
generativePiano.brushStreamActiveIntervalBeats
|
|
||||||
);
|
|
||||||
expect(generativePiano.brushStreamActiveIntervalBeats).toBeGreaterThanOrEqual(
|
|
||||||
generativePiano.brushStreamIntenseIntervalBeats
|
|
||||||
);
|
|
||||||
expect(generativePiano.brushStreamIntenseIntervalBeats).toBeGreaterThanOrEqual(
|
|
||||||
generativePiano.brushStreamManicIntervalBeats
|
|
||||||
);
|
|
||||||
expect(generativePiano.maxBrushPhraseLayers).toBeLessThanOrEqual(3);
|
|
||||||
expect(generativePiano.maxBrushStreamNotesPerBar).toBeLessThanOrEqual(
|
|
||||||
rhythm.stepsPerBar
|
|
||||||
);
|
|
||||||
|
|
||||||
Object.values(graph.pianoBusGains).forEach((gain) => {
|
|
||||||
expect(gain).toBeGreaterThan(0);
|
|
||||||
expect(gain).toBeLessThanOrEqual(1.2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to finite RGB channels for malformed hex colors', () => {
|
|
||||||
expect(hexToRgb('not-a-color')).toEqual([0, 0, 0]);
|
|
||||||
expect(hexToRgb('#abcdzz')).toEqual([0, 0, 0]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
13
src/vibes.ts
13
src/vibes.ts
|
|
@ -7,19 +7,6 @@ export type { VibePreset } from './config';
|
||||||
export const VIBE_PRESETS: Array<VibePreset> = appConfig.vibes.presets;
|
export const VIBE_PRESETS: Array<VibePreset> = appConfig.vibes.presets;
|
||||||
const VIBE_IDS = new Set<VibeId>(VIBE_PRESETS.map((vibe) => vibe.id));
|
const VIBE_IDS = new Set<VibeId>(VIBE_PRESETS.map((vibe) => vibe.id));
|
||||||
|
|
||||||
const HEX_COLOR_PATTERN =
|
|
||||||
/^#?(?<red>[0-9a-f]{2})(?<green>[0-9a-f]{2})(?<blue>[0-9a-f]{2})$/i;
|
|
||||||
|
|
||||||
export const hexToRgb = (hex: string): [number, number, number] => {
|
|
||||||
const match = HEX_COLOR_PATTERN.exec(hex);
|
|
||||||
if (!match?.groups) {
|
|
||||||
return [0, 0, 0];
|
|
||||||
}
|
|
||||||
|
|
||||||
const { red, green, blue } = match.groups;
|
|
||||||
return [parseInt(red, 16) / 255, parseInt(green, 16) / 255, parseInt(blue, 16) / 255];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isVibeId = (value: unknown): value is VibeId =>
|
export const isVibeId = (value: unknown): value is VibeId =>
|
||||||
typeof value === 'string' && VIBE_IDS.has(value as VibeId);
|
typeof value === 'string' && VIBE_IDS.has(value as VibeId);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"types": ["node", "@playwright/test"]
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["playwright.config.ts", "e2e/**/*.ts"]
|
"include": ["playwright.config.ts", "e2e/**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
import basicSsl from '@vitejs/plugin-basic-ssl';
|
import basicSsl from '@vitejs/plugin-basic-ssl';
|
||||||
import browserslist from 'browserslist';
|
import browserslist from 'browserslist';
|
||||||
|
import browserslistToEsbuild from 'browserslist-to-esbuild';
|
||||||
import { browserslistToTargets } from 'lightningcss';
|
import { browserslistToTargets } from 'lightningcss';
|
||||||
import { viteSingleFile } from 'vite-plugin-singlefile';
|
import { viteSingleFile } from 'vite-plugin-singlefile';
|
||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
const cssTargets = browserslistToTargets(browserslist());
|
const cssTargets = browserslistToTargets(browserslist());
|
||||||
|
const esbuildTargets = browserslistToEsbuild();
|
||||||
|
|
||||||
export default defineConfig(({ command }) => ({
|
export default defineConfig(({ command }) => ({
|
||||||
base: command === 'build' ? './' : '/',
|
base: './',
|
||||||
plugins: [
|
plugins: [
|
||||||
viteSingleFile({ useRecommendedBuildConfig: false }),
|
viteSingleFile({ useRecommendedBuildConfig: false }),
|
||||||
...(command === 'serve' ? [basicSsl()] : []),
|
...(command === 'serve' ? [basicSsl()] : []),
|
||||||
|
|
@ -19,7 +21,7 @@ export default defineConfig(({ command }) => ({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
target: 'es2022',
|
target: esbuildTargets,
|
||||||
cssCodeSplit: false,
|
cssCodeSplit: false,
|
||||||
cssMinify: 'lightningcss',
|
cssMinify: 'lightningcss',
|
||||||
assetsInlineLimit: Number.MAX_SAFE_INTEGER,
|
assetsInlineLimit: Number.MAX_SAFE_INTEGER,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue