diff --git a/assets/icons/close.svg b/assets/icons/close.svg
deleted file mode 100644
index ae3fbc7..0000000
--- a/assets/icons/close.svg
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
diff --git a/index.html b/index.html
index 53428ea..fadc28f 100644
--- a/index.html
+++ b/index.html
@@ -4,14 +4,14 @@
@@ -22,7 +22,7 @@
@@ -35,7 +35,7 @@
@@ -46,7 +46,7 @@
"@type": "WebApplication",
"name": "Fleeting Garden",
"url": "https://schmelczer.dev/fleeting/",
- "description": "Draw colour into a canvas that keeps moving. Your strokes become paths for life that splits, drifts, and redraws the surface over time.",
+ "description": "Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser.",
"image": "https://schmelczer.dev/fleeting/og-image.jpg",
"applicationCategory": "DesignApplication",
"operatingSystem": "Any",
@@ -91,8 +91,7 @@
Fleeting Garden
- Draw colour into a canvas that keeps moving. Your strokes become paths for
- life that splits, drifts, and redraws the surface over time.
+ Tend it while you can. The garden returns to weather either way.
Start
@@ -119,54 +118,35 @@
id="info-panel"
class="hidden info-page"
role="region"
- aria-labelledby="info-panel-title"
+ aria-label="About panel"
aria-hidden="true"
tabindex="-1"
inert
>
-
-
-
-
- Draw into a field of particles and watch the simulation fold your marks back
- into motion.
+
+ Fleeting Garden
+
+ A garden is what we tend; the wild is what we get the moment we look away.
+ Both happen here at once. Your strokes plant colour, small agents follow them,
+ branch off, and slowly rewrite the patch you laid down into something you
+ didn't quite plan.
-
-
- Choose one of three colour swatches, then drag to draw.
- Use the eraser to clear space and reshape the field.
- The mirror repeats each gesture across the canvas.
- The arrows switch the current atmosphere.
-
-
-
- My implementation of
- physarum simulation
- introduces drawing and procedurally generated piano for a more immersive
- experience. Learn more about my work at
+
+ Three swatches plant the line. The eraser carves a clearing. The mirror folds
+ one gesture into many, like footpaths around a hidden well.
+
+
+ Switch vibes to change the season; your shapes stay, the light moves. Add or
+ quiet the piano. Restart when you want a fresh field. Take a snapshot if you
+ want to keep one particular instant of weather.
+
+
+ Built with WebGPU, running locally in your browser. More of my work at
schmelczer.dev
+ >.
-
+
diff --git a/public/og-image.jpg b/public/og-image.jpg
index 00d22a1..0ced8fc 100644
Binary files a/public/og-image.jpg and b/public/og-image.jpg differ
diff --git a/src/analytics.ts b/src/analytics.ts
index 427f871..b19958a 100644
--- a/src/analytics.ts
+++ b/src/analytics.ts
@@ -4,13 +4,9 @@ import {
type PlausibleEventOptions,
} from '@plausible-analytics/tracker';
+import { appConfig } from './config';
import type { VibeId } from './vibes';
-const ANALYTICS_AUTO_CAPTURE_PAGEVIEWS = true;
-const ANALYTICS_DOMAIN = 'schmelczer.dev/fleeting';
-const ANALYTICS_ENDPOINT = 'https://stats.schmelczer.dev/status';
-const ANALYTICS_LOGGING = import.meta.env.DEV;
-
let isInitialized = false;
const track = (eventName: string, options: PlausibleEventOptions = {}) => {
@@ -28,10 +24,10 @@ export const initAnalytics = () => {
try {
plausibleInit({
- domain: ANALYTICS_DOMAIN,
- endpoint: ANALYTICS_ENDPOINT,
- autoCapturePageviews: ANALYTICS_AUTO_CAPTURE_PAGEVIEWS,
- logging: ANALYTICS_LOGGING,
+ domain: appConfig.analytics.domain,
+ endpoint: appConfig.analytics.endpoint,
+ autoCapturePageviews: appConfig.analytics.autoCapturePageviews,
+ logging: appConfig.analytics.logging,
});
isInitialized = true;
} catch (error) {
diff --git a/src/audio/garden-audio-config.ts b/src/audio/garden-audio-config.ts
index 37e5ace..b5fbc1d 100644
--- a/src/audio/garden-audio-config.ts
+++ b/src/audio/garden-audio-config.ts
@@ -1,7 +1,6 @@
import type { PianoNoteRole } from './garden-audio-types';
-export const DEFAULT_AUDIO_VOLUME = 0.65;
-export const MAX_AUDIO_VOLUME = 1.5;
+export const DEFAULT_AUDIO_VOLUME = 0.5;
export const SILENT_AUDIO_GAIN = 0.0001;
type GardenAudioChordQuality = 'major' | 'minor' | 'sus2' | 'sus4';
@@ -59,33 +58,17 @@ export const createGardenAudioConfig = () => ({
timeRampSeconds: 0.12,
},
piano: {
- maxVoices: 48,
- gain: 0.78,
+ maxVoices: 24,
+ gain: 0.48,
sustainSeconds: 0.42,
sustainLevel: 0.26,
- releaseSeconds: 0.62,
- lowpassHz: 9500,
- gainAttackSeconds: 0.003,
- lowpassMaxHz: 16000,
- lowpassMinHz: 900,
+ releaseSeconds: 0.34,
+ lowpassHz: 7000,
+ gainAttackSeconds: 0.006,
+ lowpassMaxHz: 12000,
+ lowpassMinHz: 1400,
sustainBase: 0.45,
sustainVelocityRange: 0.55,
- releaseSampleGain: 0.035,
- releaseSampleVelocityBase: 0.45,
- releaseSampleVelocityRange: 0.55,
- roomSend: 0.18,
- velocityLayerCurve: 0.72,
- velocityLayerMax: 0.26,
- velocityLayerMin: 0.035,
- voiceDecayEstimateSeconds: 1.9,
- },
- room: {
- decaySeconds: 1.65,
- highPassHz: 120,
- lowPassHz: 8200,
- preDelaySeconds: 0.018,
- sendGain: 1,
- wetGain: 0.11,
},
rhythm: {
idleIntensity: defaultGardenAudioVibeSettings.idleIntensity,
diff --git a/src/audio/garden-audio-graph.ts b/src/audio/garden-audio-graph.ts
index a08ac58..a288465 100644
--- a/src/audio/garden-audio-graph.ts
+++ b/src/audio/garden-audio-graph.ts
@@ -28,11 +28,11 @@ const graphTuning = {
latencyHint: 'interactive',
outputFilterType: 'highpass',
compressor: {
- thresholdDb: -17,
+ thresholdDb: -18,
kneeDb: 18,
- ratio: 2.2,
- attackSeconds: 0.014,
- releaseSeconds: 0.28,
+ ratio: 2.1,
+ attackSeconds: 0.018,
+ releaseSeconds: 0.18,
},
} as const;
const delayFilterTuning = {
@@ -45,7 +45,6 @@ export class GardenAudioGraph {
public context: AudioContext | null = null;
public eventBus: GainNode | null = null;
public delayInput: GainNode | null = null;
- public roomInput: GainNode | null = null;
public noiseBus: GainNode | null = null;
public noiseBuffer: AudioBuffer | null = null;
@@ -88,12 +87,10 @@ export class GardenAudioGraph {
const context = new AudioContextConstructor({
latencyHint: graphTuning.latencyHint,
});
- const outputBus = context.createGain();
const masterGain = context.createGain();
const highPass = context.createBiquadFilter();
const compressor = context.createDynamicsCompressor();
- outputBus.gain.value = 1;
masterGain.gain.value = 0;
highPass.type = graphTuning.outputFilterType;
highPass.frequency.value = outputHighPassFrequencyHz;
@@ -103,18 +100,15 @@ export class GardenAudioGraph {
compressor.attack.value = graphTuning.compressor.attackSeconds;
compressor.release.value = graphTuning.compressor.releaseSeconds;
- // Keep peak control independent from the user's volume slider.
- outputBus.connect(highPass);
+ masterGain.connect(highPass);
highPass.connect(compressor);
- compressor.connect(masterGain);
- masterGain.connect(context.destination);
+ compressor.connect(context.destination);
this.context = context;
this.masterGain = masterGain;
this.noiseBuffer = this.createNoiseBuffer(context);
- this.createDelay(context, outputBus);
- this.createRoom(context, outputBus);
- this.createBuses(context, outputBus);
+ this.createDelay(context, masterGain);
+ this.createBuses(context, masterGain);
return context;
}
@@ -230,7 +224,7 @@ export class GardenAudioGraph {
}
}
- private createDelay(context: AudioContext, outputBus: GainNode): void {
+ private createDelay(context: AudioContext, masterGain: GainNode): void {
const delayInput = context.createGain();
const delayNode = context.createDelay(graphTuning.delayMaxSeconds);
const delayFeedback = context.createGain();
@@ -256,7 +250,7 @@ export class GardenAudioGraph {
delayFeedback.connect(delayNode);
delayNode.connect(returnLowPass);
returnLowPass.connect(delayOutput);
- delayOutput.connect(outputBus);
+ delayOutput.connect(masterGain);
this.delayInput = delayInput;
this.delayNode = delayNode;
@@ -264,37 +258,10 @@ export class GardenAudioGraph {
this.delayOutput = delayOutput;
}
- private createRoom(context: AudioContext, outputBus: GainNode): void {
- const roomInput = context.createGain();
- const preDelay = context.createDelay(0.08);
- const convolver = context.createConvolver();
- const highPass = context.createBiquadFilter();
- const lowPass = context.createBiquadFilter();
- const roomOutput = context.createGain();
-
- roomInput.gain.value = this.config.room.sendGain;
- preDelay.delayTime.value = this.config.room.preDelaySeconds;
- convolver.buffer = this.createRoomImpulse(context);
- highPass.type = 'highpass';
- highPass.frequency.value = this.config.room.highPassHz;
- lowPass.type = 'lowpass';
- lowPass.frequency.value = this.config.room.lowPassHz;
- roomOutput.gain.value = this.config.room.wetGain;
-
- roomInput.connect(preDelay);
- preDelay.connect(convolver);
- convolver.connect(highPass);
- highPass.connect(lowPass);
- lowPass.connect(roomOutput);
- roomOutput.connect(outputBus);
-
- this.roomInput = roomInput;
- }
-
- private createBuses(context: AudioContext, outputBus: GainNode): void {
+ private createBuses(context: AudioContext, masterGain: GainNode): void {
const eventBus = context.createGain();
eventBus.gain.value = graphTuning.eventBusGain;
- eventBus.connect(outputBus);
+ eventBus.connect(masterGain);
this.eventBus = eventBus;
this.pianoBuses.clear();
@@ -361,34 +328,10 @@ export class GardenAudioGraph {
return buffer;
}
- private createRoomImpulse(context: AudioContext): AudioBuffer {
- const sampleCount = Math.max(
- 1,
- Math.floor(context.sampleRate * this.config.room.decaySeconds)
- );
- const impulse = context.createBuffer(2, sampleCount, context.sampleRate);
-
- for (let channel = 0; channel < impulse.numberOfChannels; channel += 1) {
- const data = impulse.getChannelData(channel);
- for (let index = 0; index < sampleCount; index += 1) {
- const position = index / sampleCount;
- const decay = Math.pow(1 - position, 2.35);
- const earlyReflection =
- index % Math.max(1, Math.floor(context.sampleRate * 0.011)) === 0
- ? 0.18 * (1 - position)
- : 0;
- data[index] = (Math.random() * 2 - 1 + earlyReflection) * decay;
- }
- }
-
- return impulse;
- }
-
private clearNodes(): void {
this.context = null;
this.eventBus = null;
this.delayInput = null;
- this.roomInput = null;
this.noiseBus = null;
this.noiseBuffer = null;
this.masterGain = null;
diff --git a/src/audio/garden-audio-types.ts b/src/audio/garden-audio-types.ts
index ade78a4..fecbcf8 100644
--- a/src/audio/garden-audio-types.ts
+++ b/src/audio/garden-audio-types.ts
@@ -14,22 +14,11 @@ export interface GardenAudioStroke {
elapsedSeconds: number;
}
-export interface LoadedPianoStrikeSample {
- midi: number;
- velocityLayer: number;
- buffer: AudioBuffer;
-}
-
-export interface LoadedPianoReleaseSample {
+export interface LoadedPianoSample {
midi: number;
buffer: AudioBuffer;
}
-export interface LoadedPianoSamples {
- releases: Array
;
- strikes: Array;
-}
-
export interface PianoNote {
midi: number;
velocity: number;
diff --git a/src/audio/garden-audio.ts b/src/audio/garden-audio.ts
index d2178cc..3aa4c88 100644
--- a/src/audio/garden-audio.ts
+++ b/src/audio/garden-audio.ts
@@ -1,8 +1,7 @@
import { ErrorHandler, Severity } from '../utils/error-handler';
-import { clamp } from '../utils/math';
+import { clamp01 } from '../utils/math';
import type { VibeId, VibePreset } from '../vibes';
import {
- MAX_AUDIO_VOLUME,
SILENT_AUDIO_GAIN,
type GardenAudioConfig,
type GardenAudioVibeProfile,
@@ -50,7 +49,7 @@ export class GardenAudio {
private hasLoadedPiano = false;
public constructor(private readonly config: GardenAudioConfig) {
- this.masterVolume = clamp(config.masterVolume, 0, MAX_AUDIO_VOLUME);
+ this.masterVolume = clamp01(config.masterVolume);
this.graph = new GardenAudioGraph(config);
this.piano = new PianoSampler(config, this.graph);
this.noise = new NoiseBurstPlayer(this.graph);
@@ -229,7 +228,7 @@ export class GardenAudio {
}
public setMasterVolume(masterVolume: number): void {
- this.masterVolume = clamp(masterVolume, 0, MAX_AUDIO_VOLUME);
+ this.masterVolume = clamp01(masterVolume);
if (!this.isMuted) {
this.graph.setMasterGain(this.masterVolume, this.config.updateRampSeconds);
}
@@ -397,7 +396,7 @@ export class GardenAudio {
return;
}
- const distanceActivity = clamp(activity, 0, 1);
+ const distanceActivity = clamp01(activity);
if (distanceActivity <= 0) {
return;
}
diff --git a/src/audio/piano-sampler.ts b/src/audio/piano-sampler.ts
index 5e0b771..1a0c77c 100644
--- a/src/audio/piano-sampler.ts
+++ b/src/audio/piano-sampler.ts
@@ -2,56 +2,32 @@ import { clamp, clamp01 } from '../utils/math';
import type { GardenAudioConfig } from './garden-audio-config';
import type { GardenAudioGraph } from './garden-audio-graph';
import { PITCH_SEMITONES_PER_OCTAVE } from './garden-audio-music';
-import type {
- LoadedPianoReleaseSample,
- LoadedPianoSamples,
- LoadedPianoStrikeSample,
- PianoNote,
-} from './garden-audio-types';
+import type { LoadedPianoSample, PianoNote } from './garden-audio-types';
import { getLoadedPianoSamples, loadPianoSamples } from './piano-samples';
export const PIANO_SCHEDULE_AHEAD_SECONDS = 0.002;
interface ActivePianoVoice {
gain: GainNode;
- peakGain: number;
- releaseAt: number;
- sources: Array;
- startedAt: number;
- stopAt: number;
-}
-
-interface SelectedPianoStrikeSample {
- gainScale: number;
- sample: LoadedPianoStrikeSample;
-}
-
-interface ScheduledReleaseSample {
- gainValue: number;
- source: AudioBufferSourceNode;
- startTime: number;
+ source: AudioScheduledSourceNode;
stopAt: number;
}
const pianoSamplerTuning = {
filterType: 'lowpass',
- filterQ: 0.45,
+ filterQ: 0.7,
minDurationSeconds: 0.08,
minFadeSeconds: 0.08,
minGain: 0.0001,
- releaseSampleAttackSeconds: 0.006,
- releaseSampleDecaySeconds: 0.18,
- releaseTimeConstantCount: 6,
- tailStopExtraSeconds: 0.08,
- voiceStealFadeSeconds: 0.045,
- voiceStealStopSeconds: 0.09,
+ releaseTimeConstantCount: 5,
+ tailStopExtraSeconds: 0.05,
+ voiceStealFadeSeconds: 0.025,
+ voiceStealStopSeconds: 0.05,
} as const;
export class PianoSampler {
+ private samples: Array = [];
private activeVoices: Array = [];
- private releaseSamples: Array = [];
- private strikeSamples: Array = [];
- private velocityLayers: Array = [];
public constructor(
private readonly config: GardenAudioConfig,
@@ -59,7 +35,7 @@ export class PianoSampler {
) {}
public load(context: BaseAudioContext): Promise {
- if (this.strikeSamples.length > 0) {
+ if (this.samples.length > 0) {
return Promise.resolve();
}
@@ -91,9 +67,8 @@ export class PianoSampler {
return;
}
- const noteVelocity = clamp01(velocity);
- const selectedSamples = this.selectStrikeSamples(midi, noteVelocity);
- if (selectedSamples.length === 0) {
+ const sample = this.findNearestSample(midi);
+ if (!sample) {
return;
}
@@ -101,6 +76,7 @@ export class PianoSampler {
context.currentTime + PIANO_SCHEDULE_AHEAD_SECONDS,
startTime
);
+ const noteVelocity = clamp01(velocity);
const noteGainValue = this.computeNoteGain(noteVelocity);
const sustainSeconds =
profileSustainSeconds *
@@ -112,36 +88,45 @@ export class PianoSampler {
const stopAt =
releaseAt +
this.config.piano.releaseSeconds * pianoSamplerTuning.releaseTimeConstantCount;
- const strikeSources = selectedSamples.map(({ gainScale, sample }) => ({
- gainScale,
- source: this.createSource(
- context,
- sample.buffer,
- midi,
- sample.midi,
- scheduledStart
- ),
- }));
- const releaseSample = this.createReleaseSample({
- context,
- midi,
- noteVelocity,
- releaseAt,
- });
+ const source = context.createBufferSource();
+
+ source.buffer = sample.buffer;
+ source.playbackRate.setValueAtTime(
+ Math.pow(2, (midi - sample.midi) / PITCH_SEMITONES_PER_OCTAVE),
+ scheduledStart
+ );
this.scheduleVoice({
+ source,
+ scheduledStart,
+ stopAt,
+ pan,
+ lowpassHz,
delaySend,
eventBus,
- lowpassHz,
- noteGainValue,
- pan,
- releaseAt,
- releaseSample,
- scheduledStart,
- stopAt: releaseSample ? Math.max(stopAt, releaseSample.stopAt) : stopAt,
- strikeSources,
- sustainAt,
- sustainSeconds,
+ configureGainEnvelope: (gain) => {
+ gain.gain.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart);
+ gain.gain.exponentialRampToValueAtTime(
+ noteGainValue,
+ scheduledStart + this.config.piano.gainAttackSeconds
+ );
+ gain.gain.setTargetAtTime(
+ Math.max(
+ pianoSamplerTuning.minGain,
+ noteGainValue * this.config.piano.sustainLevel
+ ),
+ sustainAt,
+ Math.max(
+ pianoSamplerTuning.minFadeSeconds,
+ sustainSeconds * this.config.piano.sustainBase
+ )
+ );
+ gain.gain.setTargetAtTime(
+ pianoSamplerTuning.minGain,
+ releaseAt,
+ this.config.piano.releaseSeconds
+ );
+ },
});
}
@@ -161,40 +146,30 @@ export class PianoSampler {
}
public reset(): void {
- this.releaseSamples = [];
- this.strikeSamples = [];
- this.velocityLayers = [];
+ this.samples = [];
this.activeVoices = [];
}
private scheduleVoice({
- strikeSources,
- releaseSample,
+ source,
scheduledStart,
- sustainAt,
- sustainSeconds,
- releaseAt,
stopAt,
pan,
lowpassHz,
delaySend,
eventBus,
- noteGainValue,
+ configureGainEnvelope,
}: {
- delaySend: number;
- eventBus: GainNode;
- lowpassHz: number;
- noteGainValue: number;
- pan: number;
- releaseAt: number;
- releaseSample: ScheduledReleaseSample | null;
+ source: AudioScheduledSourceNode;
scheduledStart: number;
stopAt: number;
- strikeSources: Array<{ gainScale: number; source: AudioBufferSourceNode }>;
- sustainAt: number;
- sustainSeconds: number;
+ pan: number;
+ lowpassHz: number;
+ delaySend: number;
+ eventBus: GainNode;
+ configureGainEnvelope: (gain: GainNode) => void;
}): void {
- const { context, delayInput, roomInput } = this.graph;
+ const { context, delayInput } = this.graph;
if (!context) {
return;
}
@@ -202,18 +177,15 @@ export class PianoSampler {
const filter = context.createBiquadFilter();
const gain = context.createGain();
const panner = context.createStereoPanner();
- const sourceGains = strikeSources.map(({ gainScale }) => {
- const sourceGain = context.createGain();
- sourceGain.gain.value = gainScale;
- return sourceGain;
- });
- let delaySendGain: GainNode | null = null;
- let roomSendGain: GainNode | null = null;
- let releaseGain: GainNode | null = null;
+ let sendGain: GainNode | null = null;
this.trimActiveVoices(scheduledStart);
while (this.activeVoices.length >= this.config.piano.maxVoices) {
- this.stealQuietestVoice(scheduledStart);
+ const oldest = this.activeVoices.shift();
+ if (!oldest) {
+ break;
+ }
+ this.stopVoice(oldest, scheduledStart);
}
filter.type = pianoSamplerTuning.filterType;
@@ -223,352 +195,48 @@ export class PianoSampler {
);
filter.Q.value = pianoSamplerTuning.filterQ;
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
- this.configureGainEnvelope({
- gain,
- noteGainValue,
- releaseAt,
- scheduledStart,
- sustainAt,
- sustainSeconds,
- });
+ configureGainEnvelope(gain);
- strikeSources.forEach(({ source }, index) => {
- source.connect(sourceGains[index]);
- sourceGains[index].connect(filter);
- });
+ source.connect(filter);
filter.connect(gain);
gain.connect(panner);
-
- if (releaseSample) {
- releaseGain = context.createGain();
- releaseSample.source.connect(releaseGain);
- releaseGain.connect(panner);
- this.configureReleaseEnvelope(releaseGain, releaseSample);
- }
-
panner.connect(eventBus);
if (delayInput && delaySend > 0) {
- delaySendGain = context.createGain();
- delaySendGain.gain.value = delaySend;
- panner.connect(delaySendGain);
- delaySendGain.connect(delayInput);
+ sendGain = context.createGain();
+ sendGain.gain.value = delaySend;
+ panner.connect(sendGain);
+ sendGain.connect(delayInput);
}
- if (roomInput && this.config.piano.roomSend > 0) {
- roomSendGain = context.createGain();
- roomSendGain.gain.value = this.config.piano.roomSend;
- panner.connect(roomSendGain);
- roomSendGain.connect(roomInput);
- }
+ source.start(scheduledStart);
+ source.stop(stopAt + pianoSamplerTuning.tailStopExtraSeconds);
+ this.activeVoices.push({ gain, source, stopAt });
- const sources = [
- ...strikeSources.map(({ source }) => source),
- ...(releaseSample ? [releaseSample.source] : []),
- ];
-
- strikeSources.forEach(({ source }) => {
- source.start(scheduledStart);
- source.stop(stopAt + pianoSamplerTuning.tailStopExtraSeconds);
- });
- if (releaseSample) {
- releaseSample.source.start(releaseSample.startTime);
- releaseSample.source.stop(releaseSample.stopAt);
- }
-
- const voice: ActivePianoVoice = {
- gain,
- peakGain: noteGainValue,
- releaseAt,
- sources,
- startedAt: scheduledStart,
- stopAt,
- };
- this.activeVoices.push(voice);
-
- this.cleanupVoiceWhenSourcesEnd({
- delaySendGain,
- filter,
- gain,
- panner,
- releaseGain,
- roomSendGain,
- sourceGains,
- sources,
- voice,
- });
- }
-
- private configureGainEnvelope({
- gain,
- noteGainValue,
- releaseAt,
- scheduledStart,
- sustainAt,
- sustainSeconds,
- }: {
- gain: GainNode;
- noteGainValue: number;
- releaseAt: number;
- scheduledStart: number;
- sustainAt: number;
- sustainSeconds: number;
- }): void {
- gain.gain.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart);
- gain.gain.exponentialRampToValueAtTime(
- noteGainValue,
- scheduledStart + this.config.piano.gainAttackSeconds
- );
- gain.gain.setTargetAtTime(
- Math.max(
- pianoSamplerTuning.minGain,
- noteGainValue * this.config.piano.sustainLevel
- ),
- sustainAt,
- Math.max(
- pianoSamplerTuning.minFadeSeconds,
- sustainSeconds * this.config.piano.sustainBase
- )
- );
- gain.gain.setTargetAtTime(
- pianoSamplerTuning.minGain,
- releaseAt,
- this.config.piano.releaseSeconds
- );
- }
-
- private configureReleaseEnvelope(
- releaseGain: GainNode,
- releaseSample: ScheduledReleaseSample
- ): void {
- releaseGain.gain.setValueAtTime(pianoSamplerTuning.minGain, releaseSample.startTime);
- releaseGain.gain.exponentialRampToValueAtTime(
- releaseSample.gainValue,
- releaseSample.startTime + pianoSamplerTuning.releaseSampleAttackSeconds
- );
- releaseGain.gain.setTargetAtTime(
- pianoSamplerTuning.minGain,
- releaseSample.startTime + pianoSamplerTuning.releaseSampleAttackSeconds,
- pianoSamplerTuning.releaseSampleDecaySeconds
- );
- }
-
- private createSource(
- context: BaseAudioContext,
- buffer: AudioBuffer,
- midi: number,
- sampleMidi: number,
- scheduledStart: number
- ): AudioBufferSourceNode {
- const source = context.createBufferSource();
- source.buffer = buffer;
- source.playbackRate.setValueAtTime(
- Math.pow(2, (midi - sampleMidi) / PITCH_SEMITONES_PER_OCTAVE),
- scheduledStart
- );
- return source;
- }
-
- private createReleaseSample({
- context,
- midi,
- noteVelocity,
- releaseAt,
- }: {
- context: BaseAudioContext;
- midi: number;
- noteVelocity: number;
- releaseAt: number;
- }): ScheduledReleaseSample | null {
- const sample = this.findNearestReleaseSample(midi);
- if (!sample) {
- return null;
- }
-
- const source = this.createSource(
- context,
- sample.buffer,
- midi,
- sample.midi,
- releaseAt
- );
- const gainValue =
- this.config.piano.releaseSampleGain *
- (this.config.piano.releaseSampleVelocityBase +
- noteVelocity * this.config.piano.releaseSampleVelocityRange);
-
- return {
- gainValue: Math.max(pianoSamplerTuning.minGain, gainValue),
- source,
- startTime: releaseAt,
- stopAt:
- releaseAt + sample.buffer.duration + pianoSamplerTuning.tailStopExtraSeconds,
- };
- }
-
- private cleanupVoiceWhenSourcesEnd({
- sources,
- sourceGains,
- filter,
- gain,
- releaseGain,
- panner,
- delaySendGain,
- roomSendGain,
- voice,
- }: {
- delaySendGain: GainNode | null;
- filter: BiquadFilterNode;
- gain: GainNode;
- panner: StereoPannerNode;
- releaseGain: GainNode | null;
- roomSendGain: GainNode | null;
- sourceGains: Array;
- sources: Array;
- voice: ActivePianoVoice;
- }): void {
- let remainingSources = sources.length;
- const cleanup = (): void => {
- remainingSources -= 1;
- if (remainingSources > 0) {
- return;
- }
-
- sources.forEach((source) => {
+ source.addEventListener(
+ 'ended',
+ () => {
source.disconnect();
- });
- sourceGains.forEach((sourceGain) => {
- sourceGain.disconnect();
- });
- filter.disconnect();
- gain.disconnect();
- releaseGain?.disconnect();
- panner.disconnect();
- delaySendGain?.disconnect();
- roomSendGain?.disconnect();
- this.activeVoices = this.activeVoices.filter(
- (activeVoice) => activeVoice !== voice
- );
- };
-
- sources.forEach((source) => {
- source.addEventListener('ended', cleanup, { once: true });
- });
+ filter.disconnect();
+ gain.disconnect();
+ panner.disconnect();
+ sendGain?.disconnect();
+ this.activeVoices = this.activeVoices.filter((voice) => voice.gain !== gain);
+ },
+ { once: true }
+ );
}
private computeNoteGain(velocity: number): number {
return Math.max(pianoSamplerTuning.minGain, this.config.piano.gain * velocity);
}
- private selectStrikeSamples(
- midi: number,
- noteVelocity: number
- ): Array {
- if (this.strikeSamples.length === 0 || this.velocityLayers.length === 0) {
- return [];
- }
-
- const targetLayer = this.getTargetVelocityLayer(noteVelocity);
- const layerPair = this.getVelocityLayerPair(targetLayer);
- if (!layerPair) {
- return [];
- }
-
- const lowerSample = this.findNearestStrikeSample(midi, layerPair.lower);
- const upperSample =
- layerPair.upper === layerPair.lower
- ? null
- : this.findNearestStrikeSample(midi, layerPair.upper);
-
- if (!lowerSample && !upperSample) {
- return [];
- }
- if (!upperSample || layerPair.blend <= 0) {
- return lowerSample ? [{ gainScale: 1, sample: lowerSample }] : [];
- }
- if (!lowerSample || layerPair.blend >= 1) {
- return [{ gainScale: 1, sample: upperSample }];
- }
-
- return [
- {
- gainScale: Math.sqrt(1 - layerPair.blend),
- sample: lowerSample,
- },
- {
- gainScale: Math.sqrt(layerPair.blend),
- sample: upperSample,
- },
- ];
- }
-
- private getTargetVelocityLayer(noteVelocity: number): number {
- const firstLayer = this.velocityLayers[0];
- const lastLayer = this.velocityLayers[this.velocityLayers.length - 1];
- const velocityRange = Math.max(
- 0.001,
- this.config.piano.velocityLayerMax - this.config.piano.velocityLayerMin
- );
- const normalizedVelocity = clamp01(
- (noteVelocity - this.config.piano.velocityLayerMin) / velocityRange
- );
- const curvedVelocity = Math.pow(
- normalizedVelocity,
- this.config.piano.velocityLayerCurve
- );
-
- return firstLayer + (lastLayer - firstLayer) * curvedVelocity;
- }
-
- private getVelocityLayerPair(
- targetLayer: number
- ): { blend: number; lower: number; upper: number } | null {
- const firstLayer = this.velocityLayers[0];
- if (firstLayer === undefined) {
- return null;
- }
- if (targetLayer <= firstLayer) {
- return { blend: 0, lower: firstLayer, upper: firstLayer };
- }
-
- for (let index = 1; index < this.velocityLayers.length; index += 1) {
- const upper = this.velocityLayers[index];
- const lower = this.velocityLayers[index - 1];
- if (targetLayer <= upper) {
- return {
- blend: (targetLayer - lower) / (upper - lower),
- lower,
- upper,
- };
- }
- }
-
- const lastLayer = this.velocityLayers[this.velocityLayers.length - 1];
- return { blend: 0, lower: lastLayer, upper: lastLayer };
- }
-
- private findNearestStrikeSample(
- midi: number,
- velocityLayer: number
- ): LoadedPianoStrikeSample | null {
- const layerSamples = this.strikeSamples.filter(
- (sample) => sample.velocityLayer === velocityLayer
- );
- if (layerSamples.length === 0) {
+ private findNearestSample(midi: number): LoadedPianoSample | null {
+ if (this.samples.length === 0) {
return null;
}
- return layerSamples.reduce((nearest, sample) =>
- Math.abs(sample.midi - midi) < Math.abs(nearest.midi - midi) ? sample : nearest
- );
- }
-
- private findNearestReleaseSample(midi: number): LoadedPianoReleaseSample | null {
- if (this.releaseSamples.length === 0) {
- return null;
- }
-
- return this.releaseSamples.reduce((nearest, sample) =>
+ return this.samples.reduce((nearest, sample) =>
Math.abs(sample.midi - midi) < Math.abs(nearest.midi - midi) ? sample : nearest
);
}
@@ -577,33 +245,6 @@ export class PianoSampler {
this.activeVoices = this.activeVoices.filter((voice) => voice.stopAt > now);
}
- private stealQuietestVoice(now: number): void {
- const quietestVoice = this.activeVoices.reduce(
- (quietest, voice) =>
- quietest === null ||
- this.getVoiceActivityScore(voice, now) < this.getVoiceActivityScore(quietest, now)
- ? voice
- : quietest,
- null
- );
- if (!quietestVoice) {
- return;
- }
-
- this.stopVoice(quietestVoice, now);
- this.activeVoices = this.activeVoices.filter((voice) => voice !== quietestVoice);
- }
-
- private getVoiceActivityScore(voice: ActivePianoVoice, now: number): number {
- const ageSeconds = Math.max(0, now - voice.startedAt);
- const releasedScale = now >= voice.releaseAt ? 0.28 : 1;
- return (
- voice.peakGain *
- releasedScale *
- Math.exp(-ageSeconds / this.config.piano.voiceDecayEstimateSeconds)
- );
- }
-
private stopVoice(voice: ActivePianoVoice, now: number): void {
const stopAt = now + pianoSamplerTuning.voiceStealStopSeconds;
@@ -613,23 +254,11 @@ export class PianoSampler {
now,
pianoSamplerTuning.voiceStealFadeSeconds
);
- voice.sources.forEach((source) => {
- try {
- source.stop(stopAt);
- } catch {
- // The source may already have ended naturally.
- }
- });
voice.stopAt = stopAt;
+ voice.source.stop(stopAt);
}
- private setSamples(samples: LoadedPianoSamples): void {
- this.releaseSamples = samples.releases.slice().sort((a, b) => a.midi - b.midi);
- this.strikeSamples = samples.strikes
- .slice()
- .sort((a, b) => a.midi - b.midi || a.velocityLayer - b.velocityLayer);
- this.velocityLayers = [
- ...new Set(this.strikeSamples.map((sample) => sample.velocityLayer)),
- ].sort((a, b) => a - b);
+ private setSamples(samples: Array): void {
+ this.samples = samples.slice().sort((a, b) => a.midi - b.midi);
}
}
diff --git a/src/audio/piano-samples.ts b/src/audio/piano-samples.ts
index 2d2bc25..569eca4 100644
--- a/src/audio/piano-samples.ts
+++ b/src/audio/piano-samples.ts
@@ -1,26 +1,40 @@
-import type {
- LoadedPianoReleaseSample,
- LoadedPianoSamples,
- LoadedPianoStrikeSample,
-} from './garden-audio-types';
+import type { LoadedPianoSample } from './garden-audio-types';
+import a0SampleUrl from './samples/A0v12.m4a?url&no-inline';
+import a1SampleUrl from './samples/A1v12.m4a?url&no-inline';
+import a2SampleUrl from './samples/A2v12.m4a?url&no-inline';
+import a3SampleUrl from './samples/A3v12.m4a?url&no-inline';
+import a4SampleUrl from './samples/A4v12.m4a?url&no-inline';
+import a5SampleUrl from './samples/A5v12.m4a?url&no-inline';
+import a6SampleUrl from './samples/A6v12.m4a?url&no-inline';
+import a7SampleUrl from './samples/A7v12.m4a?url&no-inline';
+import c1SampleUrl from './samples/C1v12.m4a?url&no-inline';
+import c2SampleUrl from './samples/C2v12.m4a?url&no-inline';
+import c3SampleUrl from './samples/C3v12.m4a?url&no-inline';
+import c4SampleUrl from './samples/C4v12.m4a?url&no-inline';
+import c5SampleUrl from './samples/C5v12.m4a?url&no-inline';
+import c6SampleUrl from './samples/C6v12.m4a?url&no-inline';
+import c7SampleUrl from './samples/C7v12.m4a?url&no-inline';
+import c8SampleUrl from './samples/C8v12.m4a?url&no-inline';
+import dSharp1SampleUrl from './samples/Dsharp1v12.m4a?url&no-inline';
+import dSharp2SampleUrl from './samples/Dsharp2v12.m4a?url&no-inline';
+import dSharp3SampleUrl from './samples/Dsharp3v12.m4a?url&no-inline';
+import dSharp4SampleUrl from './samples/Dsharp4v12.m4a?url&no-inline';
+import dSharp5SampleUrl from './samples/Dsharp5v12.m4a?url&no-inline';
+import dSharp6SampleUrl from './samples/Dsharp6v12.m4a?url&no-inline';
+import dSharp7SampleUrl from './samples/Dsharp7v12.m4a?url&no-inline';
+import fSharp1SampleUrl from './samples/Fsharp1v12.m4a?url&no-inline';
+import fSharp2SampleUrl from './samples/Fsharp2v12.m4a?url&no-inline';
+import fSharp3SampleUrl from './samples/Fsharp3v12.m4a?url&no-inline';
+import fSharp4SampleUrl from './samples/Fsharp4v12.m4a?url&no-inline';
+import fSharp5SampleUrl from './samples/Fsharp5v12.m4a?url&no-inline';
+import fSharp6SampleUrl from './samples/Fsharp6v12.m4a?url&no-inline';
+import fSharp7SampleUrl from './samples/Fsharp7v12.m4a?url&no-inline';
-interface PianoStrikeSampleDefinition {
- kind: 'strike';
- midi: number;
- path: string;
- url: string;
- velocityLayer: number;
-}
-
-interface PianoReleaseSampleDefinition {
- kind: 'release';
- midi: number;
- path: string;
+interface PianoSampleDefinition {
+ note: string;
url: string;
}
-type PianoSampleDefinition = PianoStrikeSampleDefinition | PianoReleaseSampleDefinition;
-
export interface PianoSampleLoadProgress {
failedCount: number;
loadedCount: number;
@@ -28,28 +42,54 @@ export interface PianoSampleLoadProgress {
totalCount: number;
}
-const pianoSampleModules = import.meta.glob('./samples/*.m4a', {
- eager: true,
- import: 'default',
- query: '?url&no-inline',
-}) as Record;
-const pianoSampleDefinitions = getPianoSampleDefinitions(pianoSampleModules);
+const pianoSampleDefinitions: Array = [
+ { url: a0SampleUrl, note: 'A0' },
+ { url: c1SampleUrl, note: 'C1' },
+ { url: dSharp1SampleUrl, note: 'Dsharp1' },
+ { url: fSharp1SampleUrl, note: 'Fsharp1' },
+ { url: a1SampleUrl, note: 'A1' },
+ { url: c2SampleUrl, note: 'C2' },
+ { url: dSharp2SampleUrl, note: 'Dsharp2' },
+ { url: fSharp2SampleUrl, note: 'Fsharp2' },
+ { url: a2SampleUrl, note: 'A2' },
+ { url: c3SampleUrl, note: 'C3' },
+ { url: dSharp3SampleUrl, note: 'Dsharp3' },
+ { url: fSharp3SampleUrl, note: 'Fsharp3' },
+ { url: a3SampleUrl, note: 'A3' },
+ { url: c4SampleUrl, note: 'C4' },
+ { url: dSharp4SampleUrl, note: 'Dsharp4' },
+ { url: fSharp4SampleUrl, note: 'Fsharp4' },
+ { url: a4SampleUrl, note: 'A4' },
+ { url: c5SampleUrl, note: 'C5' },
+ { url: dSharp5SampleUrl, note: 'Dsharp5' },
+ { url: fSharp5SampleUrl, note: 'Fsharp5' },
+ { url: a5SampleUrl, note: 'A5' },
+ { url: c6SampleUrl, note: 'C6' },
+ { url: dSharp6SampleUrl, note: 'Dsharp6' },
+ { url: fSharp6SampleUrl, note: 'Fsharp6' },
+ { url: a6SampleUrl, note: 'A6' },
+ { url: c7SampleUrl, note: 'C7' },
+ { url: dSharp7SampleUrl, note: 'Dsharp7' },
+ { url: fSharp7SampleUrl, note: 'Fsharp7' },
+ { url: a7SampleUrl, note: 'A7' },
+ { url: c8SampleUrl, note: 'C8' },
+];
-let loadedPianoSamples: LoadedPianoSamples | null = null;
-let pianoSampleLoadPromise: Promise | null = null;
+let loadedPianoSamples: Array | null = null;
+let pianoSampleLoadPromise: Promise> | null = null;
let lastPianoSampleProgress: PianoSampleLoadProgress | null = null;
const pianoSampleProgressListeners = new Set<
(progress: PianoSampleLoadProgress) => void
>();
const sampleLoadTuning = {
- concurrency: 6,
+ concurrency: 4,
sampleTimeoutMs: 15_000,
};
export const preloadPianoSamples = (
onProgress?: (progress: PianoSampleLoadProgress) => void
-): Promise => {
+): Promise> => {
const OfflineAudioContextConstructor = globalThis.OfflineAudioContext;
if (!OfflineAudioContextConstructor) {
@@ -66,19 +106,18 @@ export const preloadPianoSamples = (
export const loadPianoSamples = (
decodeContext: BaseAudioContext,
onProgress?: (progress: PianoSampleLoadProgress) => void
-): Promise => {
+): Promise> => {
const unsubscribeProgress = subscribeToPianoSampleProgress(onProgress);
if (loadedPianoSamples) {
emitPianoSampleProgress({
failedCount: 0,
- loadedCount: loadedPianoSamples.strikes.length + loadedPianoSamples.releases.length,
- settledCount:
- loadedPianoSamples.strikes.length + loadedPianoSamples.releases.length,
+ loadedCount: loadedPianoSamples.length,
+ settledCount: loadedPianoSamples.length,
totalCount: pianoSampleDefinitions.length,
});
unsubscribeProgress();
- return Promise.resolve(cloneLoadedPianoSamples(loadedPianoSamples));
+ return Promise.resolve([...loadedPianoSamples]);
}
if (pianoSampleLoadPromise) {
@@ -112,15 +151,13 @@ export const loadPianoSamples = (
)
.then(
(samples) => {
- loadedPianoSamples = sortLoadedPianoSamples(samples);
- const loadedCount =
- loadedPianoSamples.strikes.length + loadedPianoSamples.releases.length;
- if (loadedCount !== pianoSampleDefinitions.length) {
+ loadedPianoSamples = samples.sort((a, b) => a.midi - b.midi);
+ if (loadedPianoSamples.length !== pianoSampleDefinitions.length) {
throw new Error(
- `Loaded ${loadedCount}/${pianoSampleDefinitions.length} piano samples.`
+ `Loaded ${loadedPianoSamples.length}/${pianoSampleDefinitions.length} piano samples.`
);
}
- return cloneLoadedPianoSamples(loadedPianoSamples);
+ return [...loadedPianoSamples];
},
(error: unknown) => {
pianoSampleLoadPromise = null;
@@ -133,38 +170,29 @@ export const loadPianoSamples = (
return pianoSampleLoadPromise;
};
-export const getLoadedPianoSamples = (): LoadedPianoSamples | null =>
- loadedPianoSamples ? cloneLoadedPianoSamples(loadedPianoSamples) : null;
+export const getLoadedPianoSamples = (): Array | null =>
+ loadedPianoSamples ? [...loadedPianoSamples] : null;
const loadPianoSample = async (
decodeContext: BaseAudioContext,
sample: PianoSampleDefinition,
signal: AbortSignal
-): Promise => {
+): Promise => {
const response = await fetch(sample.url, { signal });
if (!response.ok) {
- throw new Error(`Unable to load piano sample ${sample.path}`);
+ throw new Error(`Unable to load piano sample ${getPianoSamplePath(sample)}`);
}
const audioData = await response.arrayBuffer();
const buffer = await decodeContext.decodeAudioData(audioData);
- if (sample.kind === 'strike') {
- return {
- buffer,
- midi: sample.midi,
- velocityLayer: sample.velocityLayer,
- };
- }
- return { buffer, midi: sample.midi };
+ return { midi: getMidiForPianoSample(sample), buffer };
};
const loadPianoSampleBatch = async (
samples: Array,
- loadSample: (
- sample: PianoSampleDefinition
- ) => Promise
-): Promise> => {
- const results: Array = [];
+ loadSample: (sample: PianoSampleDefinition) => Promise
+): Promise> => {
+ const results: Array = [];
for (let index = 0; index < samples.length; index += sampleLoadTuning.concurrency) {
const batch = samples.slice(index, index + sampleLoadTuning.concurrency);
@@ -219,50 +247,13 @@ const emitPianoSampleProgress = (progress: PianoSampleLoadProgress): void => {
pianoSampleProgressListeners.forEach((listener) => listener(progress));
};
-function getPianoSampleDefinitions(
- modules: Record
-): Array {
- return Object.entries(modules)
- .map(([path, url]) => getPianoSampleDefinition(path, url))
- .sort((a, b) => a.midi - b.midi || getSampleSortValue(a) - getSampleSortValue(b));
-}
+const getPianoSamplePath = (sample: PianoSampleDefinition): string =>
+ `./samples/${sample.note}v12.m4a`;
-function getPianoSampleDefinition(path: string, url: string): PianoSampleDefinition {
- const filename = path.split('/').pop() ?? path;
- const strikeMatch = /^(?[A-G](?:sharp)?\d+)v(?\d+)\.m4a$/.exec(
- filename
- );
- if (strikeMatch?.groups) {
- return {
- kind: 'strike',
- midi: getMidiForPianoSampleNote(strikeMatch.groups.note),
- path,
- url,
- velocityLayer: Number(strikeMatch.groups.velocityLayer),
- };
- }
-
- const releaseMatch = /^rel(?\d+)\.m4a$/.exec(filename);
- if (releaseMatch?.groups) {
- return {
- kind: 'release',
- midi: getMidiForReleaseSample(Number(releaseMatch.groups.releaseIndex)),
- path,
- url,
- };
- }
-
- throw new Error(`Invalid piano sample filename ${path}`);
-}
-
-function getSampleSortValue(sample: PianoSampleDefinition): number {
- return sample.kind === 'strike' ? sample.velocityLayer : Number.MAX_SAFE_INTEGER;
-}
-
-function getMidiForPianoSampleNote(note: string): number {
- const match = /^(?[A-G])(?sharp)?(?\d+)$/.exec(note);
+const getMidiForPianoSample = (sample: PianoSampleDefinition): number => {
+ const match = /^(?[A-G])(?sharp)?(?\d+)$/.exec(sample.note);
if (!match?.groups) {
- throw new Error(`Invalid piano sample note ${note}`);
+ throw new Error(`Invalid piano sample note ${sample.note}`);
}
const semitoneByName: Record = {
@@ -277,25 +268,4 @@ function getMidiForPianoSampleNote(note: string): number {
const octave = Number(match.groups.octave);
const semitone = semitoneByName[match.groups.name] + (match.groups.accidental ? 1 : 0);
return (octave + 1) * 12 + semitone;
-}
-
-function getMidiForReleaseSample(releaseIndex: number): number {
- const pianoLowestMidi = 21;
- return pianoLowestMidi + releaseIndex - 1;
-}
-
-const sortLoadedPianoSamples = (
- samples: Array
-): LoadedPianoSamples => ({
- releases: samples
- .filter((sample): sample is LoadedPianoReleaseSample => !('velocityLayer' in sample))
- .sort((a, b) => a.midi - b.midi),
- strikes: samples
- .filter((sample): sample is LoadedPianoStrikeSample => 'velocityLayer' in sample)
- .sort((a, b) => a.midi - b.midi || a.velocityLayer - b.velocityLayer),
-});
-
-const cloneLoadedPianoSamples = (samples: LoadedPianoSamples): LoadedPianoSamples => ({
- releases: [...samples.releases],
- strikes: [...samples.strikes],
-});
+};
diff --git a/src/audio/samples/A0v12.m4a b/src/audio/samples/A0v12.m4a
index 71c0564..db06fc3 100644
Binary files a/src/audio/samples/A0v12.m4a and b/src/audio/samples/A0v12.m4a differ
diff --git a/src/audio/samples/A0v16.m4a b/src/audio/samples/A0v16.m4a
deleted file mode 100644
index aae9230..0000000
Binary files a/src/audio/samples/A0v16.m4a and /dev/null differ
diff --git a/src/audio/samples/A0v4.m4a b/src/audio/samples/A0v4.m4a
deleted file mode 100644
index 0679ac0..0000000
Binary files a/src/audio/samples/A0v4.m4a and /dev/null differ
diff --git a/src/audio/samples/A0v8.m4a b/src/audio/samples/A0v8.m4a
deleted file mode 100644
index 99195dd..0000000
Binary files a/src/audio/samples/A0v8.m4a and /dev/null differ
diff --git a/src/audio/samples/A1v12.m4a b/src/audio/samples/A1v12.m4a
index 4d47370..f1ed488 100644
Binary files a/src/audio/samples/A1v12.m4a and b/src/audio/samples/A1v12.m4a differ
diff --git a/src/audio/samples/A1v16.m4a b/src/audio/samples/A1v16.m4a
deleted file mode 100644
index a85872d..0000000
Binary files a/src/audio/samples/A1v16.m4a and /dev/null differ
diff --git a/src/audio/samples/A1v4.m4a b/src/audio/samples/A1v4.m4a
deleted file mode 100644
index 8095af9..0000000
Binary files a/src/audio/samples/A1v4.m4a and /dev/null differ
diff --git a/src/audio/samples/A1v8.m4a b/src/audio/samples/A1v8.m4a
deleted file mode 100644
index de6d334..0000000
Binary files a/src/audio/samples/A1v8.m4a and /dev/null differ
diff --git a/src/audio/samples/A2v12.m4a b/src/audio/samples/A2v12.m4a
index 2ac10d3..52df725 100644
Binary files a/src/audio/samples/A2v12.m4a and b/src/audio/samples/A2v12.m4a differ
diff --git a/src/audio/samples/A2v16.m4a b/src/audio/samples/A2v16.m4a
deleted file mode 100644
index 8aa478c..0000000
Binary files a/src/audio/samples/A2v16.m4a and /dev/null differ
diff --git a/src/audio/samples/A2v4.m4a b/src/audio/samples/A2v4.m4a
deleted file mode 100644
index f241c2e..0000000
Binary files a/src/audio/samples/A2v4.m4a and /dev/null differ
diff --git a/src/audio/samples/A2v8.m4a b/src/audio/samples/A2v8.m4a
deleted file mode 100644
index ceaca7f..0000000
Binary files a/src/audio/samples/A2v8.m4a and /dev/null differ
diff --git a/src/audio/samples/A3v12.m4a b/src/audio/samples/A3v12.m4a
index 10cc72c..707a766 100644
Binary files a/src/audio/samples/A3v12.m4a and b/src/audio/samples/A3v12.m4a differ
diff --git a/src/audio/samples/A3v16.m4a b/src/audio/samples/A3v16.m4a
deleted file mode 100644
index a100c90..0000000
Binary files a/src/audio/samples/A3v16.m4a and /dev/null differ
diff --git a/src/audio/samples/A3v4.m4a b/src/audio/samples/A3v4.m4a
deleted file mode 100644
index 0eae496..0000000
Binary files a/src/audio/samples/A3v4.m4a and /dev/null differ
diff --git a/src/audio/samples/A3v8.m4a b/src/audio/samples/A3v8.m4a
deleted file mode 100644
index 2158608..0000000
Binary files a/src/audio/samples/A3v8.m4a and /dev/null differ
diff --git a/src/audio/samples/A4v12.m4a b/src/audio/samples/A4v12.m4a
index 7fe52f4..679bcff 100644
Binary files a/src/audio/samples/A4v12.m4a and b/src/audio/samples/A4v12.m4a differ
diff --git a/src/audio/samples/A4v16.m4a b/src/audio/samples/A4v16.m4a
deleted file mode 100644
index 4d20781..0000000
Binary files a/src/audio/samples/A4v16.m4a and /dev/null differ
diff --git a/src/audio/samples/A4v4.m4a b/src/audio/samples/A4v4.m4a
deleted file mode 100644
index 4e55c94..0000000
Binary files a/src/audio/samples/A4v4.m4a and /dev/null differ
diff --git a/src/audio/samples/A4v8.m4a b/src/audio/samples/A4v8.m4a
deleted file mode 100644
index 335fa93..0000000
Binary files a/src/audio/samples/A4v8.m4a and /dev/null differ
diff --git a/src/audio/samples/A5v12.m4a b/src/audio/samples/A5v12.m4a
index 9d3d280..4a2c896 100644
Binary files a/src/audio/samples/A5v12.m4a and b/src/audio/samples/A5v12.m4a differ
diff --git a/src/audio/samples/A5v16.m4a b/src/audio/samples/A5v16.m4a
deleted file mode 100644
index cd7c62f..0000000
Binary files a/src/audio/samples/A5v16.m4a and /dev/null differ
diff --git a/src/audio/samples/A5v4.m4a b/src/audio/samples/A5v4.m4a
deleted file mode 100644
index c68176e..0000000
Binary files a/src/audio/samples/A5v4.m4a and /dev/null differ
diff --git a/src/audio/samples/A5v8.m4a b/src/audio/samples/A5v8.m4a
deleted file mode 100644
index 0afb0b9..0000000
Binary files a/src/audio/samples/A5v8.m4a and /dev/null differ
diff --git a/src/audio/samples/A6v12.m4a b/src/audio/samples/A6v12.m4a
index 9b8644d..abbd605 100644
Binary files a/src/audio/samples/A6v12.m4a and b/src/audio/samples/A6v12.m4a differ
diff --git a/src/audio/samples/A6v16.m4a b/src/audio/samples/A6v16.m4a
deleted file mode 100644
index 26bcc55..0000000
Binary files a/src/audio/samples/A6v16.m4a and /dev/null differ
diff --git a/src/audio/samples/A6v4.m4a b/src/audio/samples/A6v4.m4a
deleted file mode 100644
index 6d37b98..0000000
Binary files a/src/audio/samples/A6v4.m4a and /dev/null differ
diff --git a/src/audio/samples/A6v8.m4a b/src/audio/samples/A6v8.m4a
deleted file mode 100644
index bd89797..0000000
Binary files a/src/audio/samples/A6v8.m4a and /dev/null differ
diff --git a/src/audio/samples/A7v12.m4a b/src/audio/samples/A7v12.m4a
index 2a4193b..3fd6829 100644
Binary files a/src/audio/samples/A7v12.m4a and b/src/audio/samples/A7v12.m4a differ
diff --git a/src/audio/samples/A7v16.m4a b/src/audio/samples/A7v16.m4a
deleted file mode 100644
index c0f0e52..0000000
Binary files a/src/audio/samples/A7v16.m4a and /dev/null differ
diff --git a/src/audio/samples/A7v4.m4a b/src/audio/samples/A7v4.m4a
deleted file mode 100644
index 2106892..0000000
Binary files a/src/audio/samples/A7v4.m4a and /dev/null differ
diff --git a/src/audio/samples/A7v8.m4a b/src/audio/samples/A7v8.m4a
deleted file mode 100644
index a25a530..0000000
Binary files a/src/audio/samples/A7v8.m4a and /dev/null differ
diff --git a/src/audio/samples/C1v12.m4a b/src/audio/samples/C1v12.m4a
index d94fd2e..59d5f61 100644
Binary files a/src/audio/samples/C1v12.m4a and b/src/audio/samples/C1v12.m4a differ
diff --git a/src/audio/samples/C1v16.m4a b/src/audio/samples/C1v16.m4a
deleted file mode 100644
index f6c995d..0000000
Binary files a/src/audio/samples/C1v16.m4a and /dev/null differ
diff --git a/src/audio/samples/C1v4.m4a b/src/audio/samples/C1v4.m4a
deleted file mode 100644
index 5c2d043..0000000
Binary files a/src/audio/samples/C1v4.m4a and /dev/null differ
diff --git a/src/audio/samples/C1v8.m4a b/src/audio/samples/C1v8.m4a
deleted file mode 100644
index febaf24..0000000
Binary files a/src/audio/samples/C1v8.m4a and /dev/null differ
diff --git a/src/audio/samples/C2v12.m4a b/src/audio/samples/C2v12.m4a
index 44bda1a..9b636f9 100644
Binary files a/src/audio/samples/C2v12.m4a and b/src/audio/samples/C2v12.m4a differ
diff --git a/src/audio/samples/C2v16.m4a b/src/audio/samples/C2v16.m4a
deleted file mode 100644
index bb729d8..0000000
Binary files a/src/audio/samples/C2v16.m4a and /dev/null differ
diff --git a/src/audio/samples/C2v4.m4a b/src/audio/samples/C2v4.m4a
deleted file mode 100644
index 9ce27c9..0000000
Binary files a/src/audio/samples/C2v4.m4a and /dev/null differ
diff --git a/src/audio/samples/C2v8.m4a b/src/audio/samples/C2v8.m4a
deleted file mode 100644
index 3cd4b1c..0000000
Binary files a/src/audio/samples/C2v8.m4a and /dev/null differ
diff --git a/src/audio/samples/C3v12.m4a b/src/audio/samples/C3v12.m4a
index a7c9af0..e891e16 100644
Binary files a/src/audio/samples/C3v12.m4a and b/src/audio/samples/C3v12.m4a differ
diff --git a/src/audio/samples/C3v16.m4a b/src/audio/samples/C3v16.m4a
deleted file mode 100644
index d6ad787..0000000
Binary files a/src/audio/samples/C3v16.m4a and /dev/null differ
diff --git a/src/audio/samples/C3v4.m4a b/src/audio/samples/C3v4.m4a
deleted file mode 100644
index e850712..0000000
Binary files a/src/audio/samples/C3v4.m4a and /dev/null differ
diff --git a/src/audio/samples/C3v8.m4a b/src/audio/samples/C3v8.m4a
deleted file mode 100644
index e781b6b..0000000
Binary files a/src/audio/samples/C3v8.m4a and /dev/null differ
diff --git a/src/audio/samples/C4v12.m4a b/src/audio/samples/C4v12.m4a
index fb37963..6061dc5 100644
Binary files a/src/audio/samples/C4v12.m4a and b/src/audio/samples/C4v12.m4a differ
diff --git a/src/audio/samples/C4v16.m4a b/src/audio/samples/C4v16.m4a
deleted file mode 100644
index eed90fa..0000000
Binary files a/src/audio/samples/C4v16.m4a and /dev/null differ
diff --git a/src/audio/samples/C4v4.m4a b/src/audio/samples/C4v4.m4a
deleted file mode 100644
index 7f1994f..0000000
Binary files a/src/audio/samples/C4v4.m4a and /dev/null differ
diff --git a/src/audio/samples/C4v8.m4a b/src/audio/samples/C4v8.m4a
deleted file mode 100644
index 9a45ff8..0000000
Binary files a/src/audio/samples/C4v8.m4a and /dev/null differ
diff --git a/src/audio/samples/C5v12.m4a b/src/audio/samples/C5v12.m4a
index 3c08107..a6d8898 100644
Binary files a/src/audio/samples/C5v12.m4a and b/src/audio/samples/C5v12.m4a differ
diff --git a/src/audio/samples/C5v16.m4a b/src/audio/samples/C5v16.m4a
deleted file mode 100644
index 24edb5a..0000000
Binary files a/src/audio/samples/C5v16.m4a and /dev/null differ
diff --git a/src/audio/samples/C5v4.m4a b/src/audio/samples/C5v4.m4a
deleted file mode 100644
index 130ea2e..0000000
Binary files a/src/audio/samples/C5v4.m4a and /dev/null differ
diff --git a/src/audio/samples/C5v8.m4a b/src/audio/samples/C5v8.m4a
deleted file mode 100644
index 403b22d..0000000
Binary files a/src/audio/samples/C5v8.m4a and /dev/null differ
diff --git a/src/audio/samples/C6v12.m4a b/src/audio/samples/C6v12.m4a
index d8268ff..745a4d6 100644
Binary files a/src/audio/samples/C6v12.m4a and b/src/audio/samples/C6v12.m4a differ
diff --git a/src/audio/samples/C6v16.m4a b/src/audio/samples/C6v16.m4a
deleted file mode 100644
index 8305844..0000000
Binary files a/src/audio/samples/C6v16.m4a and /dev/null differ
diff --git a/src/audio/samples/C6v4.m4a b/src/audio/samples/C6v4.m4a
deleted file mode 100644
index 8646b5b..0000000
Binary files a/src/audio/samples/C6v4.m4a and /dev/null differ
diff --git a/src/audio/samples/C6v8.m4a b/src/audio/samples/C6v8.m4a
deleted file mode 100644
index f85e6a1..0000000
Binary files a/src/audio/samples/C6v8.m4a and /dev/null differ
diff --git a/src/audio/samples/C7v12.m4a b/src/audio/samples/C7v12.m4a
index d3bcee5..6470854 100644
Binary files a/src/audio/samples/C7v12.m4a and b/src/audio/samples/C7v12.m4a differ
diff --git a/src/audio/samples/C7v16.m4a b/src/audio/samples/C7v16.m4a
deleted file mode 100644
index 0211360..0000000
Binary files a/src/audio/samples/C7v16.m4a and /dev/null differ
diff --git a/src/audio/samples/C7v4.m4a b/src/audio/samples/C7v4.m4a
deleted file mode 100644
index a2e2d2d..0000000
Binary files a/src/audio/samples/C7v4.m4a and /dev/null differ
diff --git a/src/audio/samples/C7v8.m4a b/src/audio/samples/C7v8.m4a
deleted file mode 100644
index 6113888..0000000
Binary files a/src/audio/samples/C7v8.m4a and /dev/null differ
diff --git a/src/audio/samples/C8v12.m4a b/src/audio/samples/C8v12.m4a
index 584f5ff..dfbbfd1 100644
Binary files a/src/audio/samples/C8v12.m4a and b/src/audio/samples/C8v12.m4a differ
diff --git a/src/audio/samples/C8v16.m4a b/src/audio/samples/C8v16.m4a
deleted file mode 100644
index b8d2aa8..0000000
Binary files a/src/audio/samples/C8v16.m4a and /dev/null differ
diff --git a/src/audio/samples/C8v4.m4a b/src/audio/samples/C8v4.m4a
deleted file mode 100644
index ff91df5..0000000
Binary files a/src/audio/samples/C8v4.m4a and /dev/null differ
diff --git a/src/audio/samples/C8v8.m4a b/src/audio/samples/C8v8.m4a
deleted file mode 100644
index 32e8a04..0000000
Binary files a/src/audio/samples/C8v8.m4a and /dev/null differ
diff --git a/src/audio/samples/Dsharp1v12.m4a b/src/audio/samples/Dsharp1v12.m4a
index 0f7d897..22d0924 100644
Binary files a/src/audio/samples/Dsharp1v12.m4a and b/src/audio/samples/Dsharp1v12.m4a differ
diff --git a/src/audio/samples/Dsharp1v16.m4a b/src/audio/samples/Dsharp1v16.m4a
deleted file mode 100644
index fe422e2..0000000
Binary files a/src/audio/samples/Dsharp1v16.m4a and /dev/null differ
diff --git a/src/audio/samples/Dsharp1v4.m4a b/src/audio/samples/Dsharp1v4.m4a
deleted file mode 100644
index 03e7208..0000000
Binary files a/src/audio/samples/Dsharp1v4.m4a and /dev/null differ
diff --git a/src/audio/samples/Dsharp1v8.m4a b/src/audio/samples/Dsharp1v8.m4a
deleted file mode 100644
index b5de786..0000000
Binary files a/src/audio/samples/Dsharp1v8.m4a and /dev/null differ
diff --git a/src/audio/samples/Dsharp2v12.m4a b/src/audio/samples/Dsharp2v12.m4a
index a2392da..f25db22 100644
Binary files a/src/audio/samples/Dsharp2v12.m4a and b/src/audio/samples/Dsharp2v12.m4a differ
diff --git a/src/audio/samples/Dsharp2v16.m4a b/src/audio/samples/Dsharp2v16.m4a
deleted file mode 100644
index 264716e..0000000
Binary files a/src/audio/samples/Dsharp2v16.m4a and /dev/null differ
diff --git a/src/audio/samples/Dsharp2v4.m4a b/src/audio/samples/Dsharp2v4.m4a
deleted file mode 100644
index 6984fbc..0000000
Binary files a/src/audio/samples/Dsharp2v4.m4a and /dev/null differ
diff --git a/src/audio/samples/Dsharp2v8.m4a b/src/audio/samples/Dsharp2v8.m4a
deleted file mode 100644
index 0461578..0000000
Binary files a/src/audio/samples/Dsharp2v8.m4a and /dev/null differ
diff --git a/src/audio/samples/Dsharp3v12.m4a b/src/audio/samples/Dsharp3v12.m4a
index f4d7398..7e09558 100644
Binary files a/src/audio/samples/Dsharp3v12.m4a and b/src/audio/samples/Dsharp3v12.m4a differ
diff --git a/src/audio/samples/Dsharp3v16.m4a b/src/audio/samples/Dsharp3v16.m4a
deleted file mode 100644
index 3a48ed5..0000000
Binary files a/src/audio/samples/Dsharp3v16.m4a and /dev/null differ
diff --git a/src/audio/samples/Dsharp3v4.m4a b/src/audio/samples/Dsharp3v4.m4a
deleted file mode 100644
index a3d205d..0000000
Binary files a/src/audio/samples/Dsharp3v4.m4a and /dev/null differ
diff --git a/src/audio/samples/Dsharp3v8.m4a b/src/audio/samples/Dsharp3v8.m4a
deleted file mode 100644
index 17b16e1..0000000
Binary files a/src/audio/samples/Dsharp3v8.m4a and /dev/null differ
diff --git a/src/audio/samples/Dsharp4v12.m4a b/src/audio/samples/Dsharp4v12.m4a
index 42fbf3a..d670fbb 100644
Binary files a/src/audio/samples/Dsharp4v12.m4a and b/src/audio/samples/Dsharp4v12.m4a differ
diff --git a/src/audio/samples/Dsharp4v16.m4a b/src/audio/samples/Dsharp4v16.m4a
deleted file mode 100644
index 2c2cd57..0000000
Binary files a/src/audio/samples/Dsharp4v16.m4a and /dev/null differ
diff --git a/src/audio/samples/Dsharp4v4.m4a b/src/audio/samples/Dsharp4v4.m4a
deleted file mode 100644
index 9591c46..0000000
Binary files a/src/audio/samples/Dsharp4v4.m4a and /dev/null differ
diff --git a/src/audio/samples/Dsharp4v8.m4a b/src/audio/samples/Dsharp4v8.m4a
deleted file mode 100644
index 734a779..0000000
Binary files a/src/audio/samples/Dsharp4v8.m4a and /dev/null differ
diff --git a/src/audio/samples/Dsharp5v12.m4a b/src/audio/samples/Dsharp5v12.m4a
index 9083dd0..cdbd7b8 100644
Binary files a/src/audio/samples/Dsharp5v12.m4a and b/src/audio/samples/Dsharp5v12.m4a differ
diff --git a/src/audio/samples/Dsharp5v16.m4a b/src/audio/samples/Dsharp5v16.m4a
deleted file mode 100644
index 33bfab4..0000000
Binary files a/src/audio/samples/Dsharp5v16.m4a and /dev/null differ
diff --git a/src/audio/samples/Dsharp5v4.m4a b/src/audio/samples/Dsharp5v4.m4a
deleted file mode 100644
index fa6c930..0000000
Binary files a/src/audio/samples/Dsharp5v4.m4a and /dev/null differ
diff --git a/src/audio/samples/Dsharp5v8.m4a b/src/audio/samples/Dsharp5v8.m4a
deleted file mode 100644
index 21dce22..0000000
Binary files a/src/audio/samples/Dsharp5v8.m4a and /dev/null differ
diff --git a/src/audio/samples/Dsharp6v12.m4a b/src/audio/samples/Dsharp6v12.m4a
index aaf59ee..b5ff787 100644
Binary files a/src/audio/samples/Dsharp6v12.m4a and b/src/audio/samples/Dsharp6v12.m4a differ
diff --git a/src/audio/samples/Dsharp6v16.m4a b/src/audio/samples/Dsharp6v16.m4a
deleted file mode 100644
index 58762f4..0000000
Binary files a/src/audio/samples/Dsharp6v16.m4a and /dev/null differ
diff --git a/src/audio/samples/Dsharp6v4.m4a b/src/audio/samples/Dsharp6v4.m4a
deleted file mode 100644
index b617ba3..0000000
Binary files a/src/audio/samples/Dsharp6v4.m4a and /dev/null differ
diff --git a/src/audio/samples/Dsharp6v8.m4a b/src/audio/samples/Dsharp6v8.m4a
deleted file mode 100644
index a7704ea..0000000
Binary files a/src/audio/samples/Dsharp6v8.m4a and /dev/null differ
diff --git a/src/audio/samples/Dsharp7v12.m4a b/src/audio/samples/Dsharp7v12.m4a
index 950034f..a9b6cda 100644
Binary files a/src/audio/samples/Dsharp7v12.m4a and b/src/audio/samples/Dsharp7v12.m4a differ
diff --git a/src/audio/samples/Dsharp7v16.m4a b/src/audio/samples/Dsharp7v16.m4a
deleted file mode 100644
index 81667cb..0000000
Binary files a/src/audio/samples/Dsharp7v16.m4a and /dev/null differ
diff --git a/src/audio/samples/Dsharp7v4.m4a b/src/audio/samples/Dsharp7v4.m4a
deleted file mode 100644
index d49a972..0000000
Binary files a/src/audio/samples/Dsharp7v4.m4a and /dev/null differ
diff --git a/src/audio/samples/Dsharp7v8.m4a b/src/audio/samples/Dsharp7v8.m4a
deleted file mode 100644
index c57ebae..0000000
Binary files a/src/audio/samples/Dsharp7v8.m4a and /dev/null differ
diff --git a/src/audio/samples/Fsharp1v12.m4a b/src/audio/samples/Fsharp1v12.m4a
index 3b941c8..752590f 100644
Binary files a/src/audio/samples/Fsharp1v12.m4a and b/src/audio/samples/Fsharp1v12.m4a differ
diff --git a/src/audio/samples/Fsharp1v16.m4a b/src/audio/samples/Fsharp1v16.m4a
deleted file mode 100644
index 71c269b..0000000
Binary files a/src/audio/samples/Fsharp1v16.m4a and /dev/null differ
diff --git a/src/audio/samples/Fsharp1v4.m4a b/src/audio/samples/Fsharp1v4.m4a
deleted file mode 100644
index c12ccb1..0000000
Binary files a/src/audio/samples/Fsharp1v4.m4a and /dev/null differ
diff --git a/src/audio/samples/Fsharp1v8.m4a b/src/audio/samples/Fsharp1v8.m4a
deleted file mode 100644
index 799e5ee..0000000
Binary files a/src/audio/samples/Fsharp1v8.m4a and /dev/null differ
diff --git a/src/audio/samples/Fsharp2v12.m4a b/src/audio/samples/Fsharp2v12.m4a
index 2779ee3..3477cb8 100644
Binary files a/src/audio/samples/Fsharp2v12.m4a and b/src/audio/samples/Fsharp2v12.m4a differ
diff --git a/src/audio/samples/Fsharp2v16.m4a b/src/audio/samples/Fsharp2v16.m4a
deleted file mode 100644
index 219ab97..0000000
Binary files a/src/audio/samples/Fsharp2v16.m4a and /dev/null differ
diff --git a/src/audio/samples/Fsharp2v4.m4a b/src/audio/samples/Fsharp2v4.m4a
deleted file mode 100644
index ca98511..0000000
Binary files a/src/audio/samples/Fsharp2v4.m4a and /dev/null differ
diff --git a/src/audio/samples/Fsharp2v8.m4a b/src/audio/samples/Fsharp2v8.m4a
deleted file mode 100644
index e3a9480..0000000
Binary files a/src/audio/samples/Fsharp2v8.m4a and /dev/null differ
diff --git a/src/audio/samples/Fsharp3v12.m4a b/src/audio/samples/Fsharp3v12.m4a
index 85424f5..d36f8dd 100644
Binary files a/src/audio/samples/Fsharp3v12.m4a and b/src/audio/samples/Fsharp3v12.m4a differ
diff --git a/src/audio/samples/Fsharp3v16.m4a b/src/audio/samples/Fsharp3v16.m4a
deleted file mode 100644
index 939721f..0000000
Binary files a/src/audio/samples/Fsharp3v16.m4a and /dev/null differ
diff --git a/src/audio/samples/Fsharp3v4.m4a b/src/audio/samples/Fsharp3v4.m4a
deleted file mode 100644
index d64957d..0000000
Binary files a/src/audio/samples/Fsharp3v4.m4a and /dev/null differ
diff --git a/src/audio/samples/Fsharp3v8.m4a b/src/audio/samples/Fsharp3v8.m4a
deleted file mode 100644
index 5dd56f5..0000000
Binary files a/src/audio/samples/Fsharp3v8.m4a and /dev/null differ
diff --git a/src/audio/samples/Fsharp4v12.m4a b/src/audio/samples/Fsharp4v12.m4a
index 81309f1..21df1e2 100644
Binary files a/src/audio/samples/Fsharp4v12.m4a and b/src/audio/samples/Fsharp4v12.m4a differ
diff --git a/src/audio/samples/Fsharp4v16.m4a b/src/audio/samples/Fsharp4v16.m4a
deleted file mode 100644
index 2b60946..0000000
Binary files a/src/audio/samples/Fsharp4v16.m4a and /dev/null differ
diff --git a/src/audio/samples/Fsharp4v4.m4a b/src/audio/samples/Fsharp4v4.m4a
deleted file mode 100644
index 553e183..0000000
Binary files a/src/audio/samples/Fsharp4v4.m4a and /dev/null differ
diff --git a/src/audio/samples/Fsharp4v8.m4a b/src/audio/samples/Fsharp4v8.m4a
deleted file mode 100644
index 3d9454b..0000000
Binary files a/src/audio/samples/Fsharp4v8.m4a and /dev/null differ
diff --git a/src/audio/samples/Fsharp5v12.m4a b/src/audio/samples/Fsharp5v12.m4a
index d09214c..1105dfb 100644
Binary files a/src/audio/samples/Fsharp5v12.m4a and b/src/audio/samples/Fsharp5v12.m4a differ
diff --git a/src/audio/samples/Fsharp5v16.m4a b/src/audio/samples/Fsharp5v16.m4a
deleted file mode 100644
index b43b919..0000000
Binary files a/src/audio/samples/Fsharp5v16.m4a and /dev/null differ
diff --git a/src/audio/samples/Fsharp5v4.m4a b/src/audio/samples/Fsharp5v4.m4a
deleted file mode 100644
index c3eaac2..0000000
Binary files a/src/audio/samples/Fsharp5v4.m4a and /dev/null differ
diff --git a/src/audio/samples/Fsharp5v8.m4a b/src/audio/samples/Fsharp5v8.m4a
deleted file mode 100644
index 79fb2f6..0000000
Binary files a/src/audio/samples/Fsharp5v8.m4a and /dev/null differ
diff --git a/src/audio/samples/Fsharp6v12.m4a b/src/audio/samples/Fsharp6v12.m4a
index b92adb7..d141d41 100644
Binary files a/src/audio/samples/Fsharp6v12.m4a and b/src/audio/samples/Fsharp6v12.m4a differ
diff --git a/src/audio/samples/Fsharp6v16.m4a b/src/audio/samples/Fsharp6v16.m4a
deleted file mode 100644
index 9b228c5..0000000
Binary files a/src/audio/samples/Fsharp6v16.m4a and /dev/null differ
diff --git a/src/audio/samples/Fsharp6v4.m4a b/src/audio/samples/Fsharp6v4.m4a
deleted file mode 100644
index c9a1691..0000000
Binary files a/src/audio/samples/Fsharp6v4.m4a and /dev/null differ
diff --git a/src/audio/samples/Fsharp6v8.m4a b/src/audio/samples/Fsharp6v8.m4a
deleted file mode 100644
index 7c122bd..0000000
Binary files a/src/audio/samples/Fsharp6v8.m4a and /dev/null differ
diff --git a/src/audio/samples/Fsharp7v12.m4a b/src/audio/samples/Fsharp7v12.m4a
index 2ad18ba..d69ac59 100644
Binary files a/src/audio/samples/Fsharp7v12.m4a and b/src/audio/samples/Fsharp7v12.m4a differ
diff --git a/src/audio/samples/Fsharp7v16.m4a b/src/audio/samples/Fsharp7v16.m4a
deleted file mode 100644
index e4644a2..0000000
Binary files a/src/audio/samples/Fsharp7v16.m4a and /dev/null differ
diff --git a/src/audio/samples/Fsharp7v4.m4a b/src/audio/samples/Fsharp7v4.m4a
deleted file mode 100644
index f3f9787..0000000
Binary files a/src/audio/samples/Fsharp7v4.m4a and /dev/null differ
diff --git a/src/audio/samples/Fsharp7v8.m4a b/src/audio/samples/Fsharp7v8.m4a
deleted file mode 100644
index d9020b6..0000000
Binary files a/src/audio/samples/Fsharp7v8.m4a and /dev/null differ
diff --git a/src/audio/samples/README.md b/src/audio/samples/README.md
index b37247b..bdde746 100644
--- a/src/audio/samples/README.md
+++ b/src/audio/samples/README.md
@@ -2,25 +2,15 @@ Piano samples are Salamander Grand Piano V3 samples by Alexander Holm,
transcoded from OGG Vorbis to AAC M4A for iOS browser playback and distributed
under CC BY 3.0.
-Source packages:
-
-- @audio-samples/piano-velocity4
-- @audio-samples/piano-velocity8
-- @audio-samples/piano-velocity12
-- @audio-samples/piano-velocity16
-- @audio-samples/piano-release
-
+Source package: @audio-samples/piano-velocity12
Source recording: https://archive.org/details/SalamanderGrandPianoV3
License: https://creativecommons.org/licenses/by/3.0/
-Checked-in subset: velocity layers `v4`, `v8`, `v12`, and `v16` at the
-available Salamander strike anchors, plus all 88 release samples. The strike
-anchors are A, C, Dsharp, and Fsharp for octaves 1-7, plus A0, A7, and C8.
-The app derives strike MIDI values and velocity layers from filenames in
-`piano-samples.ts`; release sample `rel1` maps to A0 and `rel88` maps to C8.
+Checked-in subset: velocity layer `v12`, every minor-third anchor from A0
+through C8: A, C, Dsharp, and Fsharp for octaves 1-7, plus A0, A7, and C8.
+The app derives MIDI values from those note names in `piano-samples.ts`.
-Repro notes: start from the matching OGG files in the source packages and
-transcode each selected sample to AAC/M4A at 192 kbps without renaming the
-note/velocity or release stem. Replace `#` with `sharp` in filenames for URL
-compatibility. Expected output filenames include `v.m4a`, for
-example `C4v16.m4a`, and `rel.m4a`, for example `rel40.m4a`.
+Repro notes: start from the matching `v12` OGG files in the source package and
+transcode each selected sample to AAC/M4A without renaming the note/velocity
+stem. The expected output filenames are `v12.m4a`, for example
+`C4v12.m4a`.
diff --git a/src/audio/samples/rel1.m4a b/src/audio/samples/rel1.m4a
deleted file mode 100644
index 8cf2d5b..0000000
Binary files a/src/audio/samples/rel1.m4a and /dev/null differ
diff --git a/src/audio/samples/rel10.m4a b/src/audio/samples/rel10.m4a
deleted file mode 100644
index 6cad512..0000000
Binary files a/src/audio/samples/rel10.m4a and /dev/null differ
diff --git a/src/audio/samples/rel11.m4a b/src/audio/samples/rel11.m4a
deleted file mode 100644
index 0b386b3..0000000
Binary files a/src/audio/samples/rel11.m4a and /dev/null differ
diff --git a/src/audio/samples/rel12.m4a b/src/audio/samples/rel12.m4a
deleted file mode 100644
index c870544..0000000
Binary files a/src/audio/samples/rel12.m4a and /dev/null differ
diff --git a/src/audio/samples/rel13.m4a b/src/audio/samples/rel13.m4a
deleted file mode 100644
index 5849362..0000000
Binary files a/src/audio/samples/rel13.m4a and /dev/null differ
diff --git a/src/audio/samples/rel14.m4a b/src/audio/samples/rel14.m4a
deleted file mode 100644
index 0dfc5a0..0000000
Binary files a/src/audio/samples/rel14.m4a and /dev/null differ
diff --git a/src/audio/samples/rel15.m4a b/src/audio/samples/rel15.m4a
deleted file mode 100644
index 54b6acf..0000000
Binary files a/src/audio/samples/rel15.m4a and /dev/null differ
diff --git a/src/audio/samples/rel16.m4a b/src/audio/samples/rel16.m4a
deleted file mode 100644
index 863f1ff..0000000
Binary files a/src/audio/samples/rel16.m4a and /dev/null differ
diff --git a/src/audio/samples/rel17.m4a b/src/audio/samples/rel17.m4a
deleted file mode 100644
index 591ee71..0000000
Binary files a/src/audio/samples/rel17.m4a and /dev/null differ
diff --git a/src/audio/samples/rel18.m4a b/src/audio/samples/rel18.m4a
deleted file mode 100644
index 77533bf..0000000
Binary files a/src/audio/samples/rel18.m4a and /dev/null differ
diff --git a/src/audio/samples/rel19.m4a b/src/audio/samples/rel19.m4a
deleted file mode 100644
index 89c7b27..0000000
Binary files a/src/audio/samples/rel19.m4a and /dev/null differ
diff --git a/src/audio/samples/rel2.m4a b/src/audio/samples/rel2.m4a
deleted file mode 100644
index dd5ab64..0000000
Binary files a/src/audio/samples/rel2.m4a and /dev/null differ
diff --git a/src/audio/samples/rel20.m4a b/src/audio/samples/rel20.m4a
deleted file mode 100644
index 874cbfe..0000000
Binary files a/src/audio/samples/rel20.m4a and /dev/null differ
diff --git a/src/audio/samples/rel21.m4a b/src/audio/samples/rel21.m4a
deleted file mode 100644
index daafbef..0000000
Binary files a/src/audio/samples/rel21.m4a and /dev/null differ
diff --git a/src/audio/samples/rel22.m4a b/src/audio/samples/rel22.m4a
deleted file mode 100644
index 0dbe681..0000000
Binary files a/src/audio/samples/rel22.m4a and /dev/null differ
diff --git a/src/audio/samples/rel23.m4a b/src/audio/samples/rel23.m4a
deleted file mode 100644
index ff51cbc..0000000
Binary files a/src/audio/samples/rel23.m4a and /dev/null differ
diff --git a/src/audio/samples/rel24.m4a b/src/audio/samples/rel24.m4a
deleted file mode 100644
index b5515e5..0000000
Binary files a/src/audio/samples/rel24.m4a and /dev/null differ
diff --git a/src/audio/samples/rel25.m4a b/src/audio/samples/rel25.m4a
deleted file mode 100644
index 12ccd06..0000000
Binary files a/src/audio/samples/rel25.m4a and /dev/null differ
diff --git a/src/audio/samples/rel26.m4a b/src/audio/samples/rel26.m4a
deleted file mode 100644
index 81e3075..0000000
Binary files a/src/audio/samples/rel26.m4a and /dev/null differ
diff --git a/src/audio/samples/rel27.m4a b/src/audio/samples/rel27.m4a
deleted file mode 100644
index 25ebe1e..0000000
Binary files a/src/audio/samples/rel27.m4a and /dev/null differ
diff --git a/src/audio/samples/rel28.m4a b/src/audio/samples/rel28.m4a
deleted file mode 100644
index d49226d..0000000
Binary files a/src/audio/samples/rel28.m4a and /dev/null differ
diff --git a/src/audio/samples/rel29.m4a b/src/audio/samples/rel29.m4a
deleted file mode 100644
index 6fd7267..0000000
Binary files a/src/audio/samples/rel29.m4a and /dev/null differ
diff --git a/src/audio/samples/rel3.m4a b/src/audio/samples/rel3.m4a
deleted file mode 100644
index 9d67e09..0000000
Binary files a/src/audio/samples/rel3.m4a and /dev/null differ
diff --git a/src/audio/samples/rel30.m4a b/src/audio/samples/rel30.m4a
deleted file mode 100644
index 3312f64..0000000
Binary files a/src/audio/samples/rel30.m4a and /dev/null differ
diff --git a/src/audio/samples/rel31.m4a b/src/audio/samples/rel31.m4a
deleted file mode 100644
index 2543ce7..0000000
Binary files a/src/audio/samples/rel31.m4a and /dev/null differ
diff --git a/src/audio/samples/rel32.m4a b/src/audio/samples/rel32.m4a
deleted file mode 100644
index b06a0f9..0000000
Binary files a/src/audio/samples/rel32.m4a and /dev/null differ
diff --git a/src/audio/samples/rel33.m4a b/src/audio/samples/rel33.m4a
deleted file mode 100644
index 40de0b5..0000000
Binary files a/src/audio/samples/rel33.m4a and /dev/null differ
diff --git a/src/audio/samples/rel34.m4a b/src/audio/samples/rel34.m4a
deleted file mode 100644
index 52761b1..0000000
Binary files a/src/audio/samples/rel34.m4a and /dev/null differ
diff --git a/src/audio/samples/rel35.m4a b/src/audio/samples/rel35.m4a
deleted file mode 100644
index 3603cf2..0000000
Binary files a/src/audio/samples/rel35.m4a and /dev/null differ
diff --git a/src/audio/samples/rel36.m4a b/src/audio/samples/rel36.m4a
deleted file mode 100644
index e16e5f4..0000000
Binary files a/src/audio/samples/rel36.m4a and /dev/null differ
diff --git a/src/audio/samples/rel37.m4a b/src/audio/samples/rel37.m4a
deleted file mode 100644
index c0c316c..0000000
Binary files a/src/audio/samples/rel37.m4a and /dev/null differ
diff --git a/src/audio/samples/rel38.m4a b/src/audio/samples/rel38.m4a
deleted file mode 100644
index 4b1496f..0000000
Binary files a/src/audio/samples/rel38.m4a and /dev/null differ
diff --git a/src/audio/samples/rel39.m4a b/src/audio/samples/rel39.m4a
deleted file mode 100644
index af13d13..0000000
Binary files a/src/audio/samples/rel39.m4a and /dev/null differ
diff --git a/src/audio/samples/rel4.m4a b/src/audio/samples/rel4.m4a
deleted file mode 100644
index addaa1f..0000000
Binary files a/src/audio/samples/rel4.m4a and /dev/null differ
diff --git a/src/audio/samples/rel40.m4a b/src/audio/samples/rel40.m4a
deleted file mode 100644
index 41fb5b8..0000000
Binary files a/src/audio/samples/rel40.m4a and /dev/null differ
diff --git a/src/audio/samples/rel41.m4a b/src/audio/samples/rel41.m4a
deleted file mode 100644
index bfa11c8..0000000
Binary files a/src/audio/samples/rel41.m4a and /dev/null differ
diff --git a/src/audio/samples/rel42.m4a b/src/audio/samples/rel42.m4a
deleted file mode 100644
index d6439fa..0000000
Binary files a/src/audio/samples/rel42.m4a and /dev/null differ
diff --git a/src/audio/samples/rel43.m4a b/src/audio/samples/rel43.m4a
deleted file mode 100644
index 5b94878..0000000
Binary files a/src/audio/samples/rel43.m4a and /dev/null differ
diff --git a/src/audio/samples/rel44.m4a b/src/audio/samples/rel44.m4a
deleted file mode 100644
index ba6b1d3..0000000
Binary files a/src/audio/samples/rel44.m4a and /dev/null differ
diff --git a/src/audio/samples/rel45.m4a b/src/audio/samples/rel45.m4a
deleted file mode 100644
index 4eab6bc..0000000
Binary files a/src/audio/samples/rel45.m4a and /dev/null differ
diff --git a/src/audio/samples/rel46.m4a b/src/audio/samples/rel46.m4a
deleted file mode 100644
index 42a0fb3..0000000
Binary files a/src/audio/samples/rel46.m4a and /dev/null differ
diff --git a/src/audio/samples/rel47.m4a b/src/audio/samples/rel47.m4a
deleted file mode 100644
index b7fa1a7..0000000
Binary files a/src/audio/samples/rel47.m4a and /dev/null differ
diff --git a/src/audio/samples/rel48.m4a b/src/audio/samples/rel48.m4a
deleted file mode 100644
index 3234c08..0000000
Binary files a/src/audio/samples/rel48.m4a and /dev/null differ
diff --git a/src/audio/samples/rel49.m4a b/src/audio/samples/rel49.m4a
deleted file mode 100644
index 6e49637..0000000
Binary files a/src/audio/samples/rel49.m4a and /dev/null differ
diff --git a/src/audio/samples/rel5.m4a b/src/audio/samples/rel5.m4a
deleted file mode 100644
index 17c9856..0000000
Binary files a/src/audio/samples/rel5.m4a and /dev/null differ
diff --git a/src/audio/samples/rel50.m4a b/src/audio/samples/rel50.m4a
deleted file mode 100644
index dd01182..0000000
Binary files a/src/audio/samples/rel50.m4a and /dev/null differ
diff --git a/src/audio/samples/rel51.m4a b/src/audio/samples/rel51.m4a
deleted file mode 100644
index c875276..0000000
Binary files a/src/audio/samples/rel51.m4a and /dev/null differ
diff --git a/src/audio/samples/rel52.m4a b/src/audio/samples/rel52.m4a
deleted file mode 100644
index d49ebb4..0000000
Binary files a/src/audio/samples/rel52.m4a and /dev/null differ
diff --git a/src/audio/samples/rel53.m4a b/src/audio/samples/rel53.m4a
deleted file mode 100644
index d599e0d..0000000
Binary files a/src/audio/samples/rel53.m4a and /dev/null differ
diff --git a/src/audio/samples/rel54.m4a b/src/audio/samples/rel54.m4a
deleted file mode 100644
index 7dc6bd5..0000000
Binary files a/src/audio/samples/rel54.m4a and /dev/null differ
diff --git a/src/audio/samples/rel55.m4a b/src/audio/samples/rel55.m4a
deleted file mode 100644
index 4534017..0000000
Binary files a/src/audio/samples/rel55.m4a and /dev/null differ
diff --git a/src/audio/samples/rel56.m4a b/src/audio/samples/rel56.m4a
deleted file mode 100644
index aac1106..0000000
Binary files a/src/audio/samples/rel56.m4a and /dev/null differ
diff --git a/src/audio/samples/rel57.m4a b/src/audio/samples/rel57.m4a
deleted file mode 100644
index a6b637b..0000000
Binary files a/src/audio/samples/rel57.m4a and /dev/null differ
diff --git a/src/audio/samples/rel58.m4a b/src/audio/samples/rel58.m4a
deleted file mode 100644
index 311e6bc..0000000
Binary files a/src/audio/samples/rel58.m4a and /dev/null differ
diff --git a/src/audio/samples/rel59.m4a b/src/audio/samples/rel59.m4a
deleted file mode 100644
index 6dc7ddc..0000000
Binary files a/src/audio/samples/rel59.m4a and /dev/null differ
diff --git a/src/audio/samples/rel6.m4a b/src/audio/samples/rel6.m4a
deleted file mode 100644
index e805093..0000000
Binary files a/src/audio/samples/rel6.m4a and /dev/null differ
diff --git a/src/audio/samples/rel60.m4a b/src/audio/samples/rel60.m4a
deleted file mode 100644
index 60568da..0000000
Binary files a/src/audio/samples/rel60.m4a and /dev/null differ
diff --git a/src/audio/samples/rel61.m4a b/src/audio/samples/rel61.m4a
deleted file mode 100644
index 42e57d3..0000000
Binary files a/src/audio/samples/rel61.m4a and /dev/null differ
diff --git a/src/audio/samples/rel62.m4a b/src/audio/samples/rel62.m4a
deleted file mode 100644
index 386bc6f..0000000
Binary files a/src/audio/samples/rel62.m4a and /dev/null differ
diff --git a/src/audio/samples/rel63.m4a b/src/audio/samples/rel63.m4a
deleted file mode 100644
index a2a5fc5..0000000
Binary files a/src/audio/samples/rel63.m4a and /dev/null differ
diff --git a/src/audio/samples/rel64.m4a b/src/audio/samples/rel64.m4a
deleted file mode 100644
index b0795b0..0000000
Binary files a/src/audio/samples/rel64.m4a and /dev/null differ
diff --git a/src/audio/samples/rel65.m4a b/src/audio/samples/rel65.m4a
deleted file mode 100644
index c51e877..0000000
Binary files a/src/audio/samples/rel65.m4a and /dev/null differ
diff --git a/src/audio/samples/rel66.m4a b/src/audio/samples/rel66.m4a
deleted file mode 100644
index 7307df4..0000000
Binary files a/src/audio/samples/rel66.m4a and /dev/null differ
diff --git a/src/audio/samples/rel67.m4a b/src/audio/samples/rel67.m4a
deleted file mode 100644
index ede7dd2..0000000
Binary files a/src/audio/samples/rel67.m4a and /dev/null differ
diff --git a/src/audio/samples/rel68.m4a b/src/audio/samples/rel68.m4a
deleted file mode 100644
index 25d123f..0000000
Binary files a/src/audio/samples/rel68.m4a and /dev/null differ
diff --git a/src/audio/samples/rel69.m4a b/src/audio/samples/rel69.m4a
deleted file mode 100644
index 1102c0e..0000000
Binary files a/src/audio/samples/rel69.m4a and /dev/null differ
diff --git a/src/audio/samples/rel7.m4a b/src/audio/samples/rel7.m4a
deleted file mode 100644
index 91f5ef6..0000000
Binary files a/src/audio/samples/rel7.m4a and /dev/null differ
diff --git a/src/audio/samples/rel70.m4a b/src/audio/samples/rel70.m4a
deleted file mode 100644
index 7771d47..0000000
Binary files a/src/audio/samples/rel70.m4a and /dev/null differ
diff --git a/src/audio/samples/rel71.m4a b/src/audio/samples/rel71.m4a
deleted file mode 100644
index 03fcfd4..0000000
Binary files a/src/audio/samples/rel71.m4a and /dev/null differ
diff --git a/src/audio/samples/rel72.m4a b/src/audio/samples/rel72.m4a
deleted file mode 100644
index f945bb9..0000000
Binary files a/src/audio/samples/rel72.m4a and /dev/null differ
diff --git a/src/audio/samples/rel73.m4a b/src/audio/samples/rel73.m4a
deleted file mode 100644
index 104ba8d..0000000
Binary files a/src/audio/samples/rel73.m4a and /dev/null differ
diff --git a/src/audio/samples/rel74.m4a b/src/audio/samples/rel74.m4a
deleted file mode 100644
index 88c6f8d..0000000
Binary files a/src/audio/samples/rel74.m4a and /dev/null differ
diff --git a/src/audio/samples/rel75.m4a b/src/audio/samples/rel75.m4a
deleted file mode 100644
index 8e32dd2..0000000
Binary files a/src/audio/samples/rel75.m4a and /dev/null differ
diff --git a/src/audio/samples/rel76.m4a b/src/audio/samples/rel76.m4a
deleted file mode 100644
index e95f09d..0000000
Binary files a/src/audio/samples/rel76.m4a and /dev/null differ
diff --git a/src/audio/samples/rel77.m4a b/src/audio/samples/rel77.m4a
deleted file mode 100644
index dab0b10..0000000
Binary files a/src/audio/samples/rel77.m4a and /dev/null differ
diff --git a/src/audio/samples/rel78.m4a b/src/audio/samples/rel78.m4a
deleted file mode 100644
index 0c9808c..0000000
Binary files a/src/audio/samples/rel78.m4a and /dev/null differ
diff --git a/src/audio/samples/rel79.m4a b/src/audio/samples/rel79.m4a
deleted file mode 100644
index 75d2f1e..0000000
Binary files a/src/audio/samples/rel79.m4a and /dev/null differ
diff --git a/src/audio/samples/rel8.m4a b/src/audio/samples/rel8.m4a
deleted file mode 100644
index 69c5036..0000000
Binary files a/src/audio/samples/rel8.m4a and /dev/null differ
diff --git a/src/audio/samples/rel80.m4a b/src/audio/samples/rel80.m4a
deleted file mode 100644
index f43ae99..0000000
Binary files a/src/audio/samples/rel80.m4a and /dev/null differ
diff --git a/src/audio/samples/rel81.m4a b/src/audio/samples/rel81.m4a
deleted file mode 100644
index 7a6198f..0000000
Binary files a/src/audio/samples/rel81.m4a and /dev/null differ
diff --git a/src/audio/samples/rel82.m4a b/src/audio/samples/rel82.m4a
deleted file mode 100644
index 19a1c8e..0000000
Binary files a/src/audio/samples/rel82.m4a and /dev/null differ
diff --git a/src/audio/samples/rel83.m4a b/src/audio/samples/rel83.m4a
deleted file mode 100644
index 78ca8a2..0000000
Binary files a/src/audio/samples/rel83.m4a and /dev/null differ
diff --git a/src/audio/samples/rel84.m4a b/src/audio/samples/rel84.m4a
deleted file mode 100644
index 3f4d8c5..0000000
Binary files a/src/audio/samples/rel84.m4a and /dev/null differ
diff --git a/src/audio/samples/rel85.m4a b/src/audio/samples/rel85.m4a
deleted file mode 100644
index afa6e8e..0000000
Binary files a/src/audio/samples/rel85.m4a and /dev/null differ
diff --git a/src/audio/samples/rel86.m4a b/src/audio/samples/rel86.m4a
deleted file mode 100644
index 26977b1..0000000
Binary files a/src/audio/samples/rel86.m4a and /dev/null differ
diff --git a/src/audio/samples/rel87.m4a b/src/audio/samples/rel87.m4a
deleted file mode 100644
index 666d1d8..0000000
Binary files a/src/audio/samples/rel87.m4a and /dev/null differ
diff --git a/src/audio/samples/rel88.m4a b/src/audio/samples/rel88.m4a
deleted file mode 100644
index 6045663..0000000
Binary files a/src/audio/samples/rel88.m4a and /dev/null differ
diff --git a/src/audio/samples/rel9.m4a b/src/audio/samples/rel9.m4a
deleted file mode 100644
index 1d2be34..0000000
Binary files a/src/audio/samples/rel9.m4a and /dev/null differ
diff --git a/src/config.ts b/src/config.ts
index 37e7f78..e0e6b19 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -1,9 +1,199 @@
-export { defaultSettings } from './config/default-settings';
+import {
+ createGardenAudioConfig,
+ DEFAULT_AUDIO_VOLUME,
+} from './audio/garden-audio-config';
+import { defaultSettings } from './config/default-settings';
+import { runtimeControls } from './config/runtime-controls';
+import type { GardenAppConfig } from './config/types';
+import { defaultVibeId, vibePresets } from './config/vibe-presets';
+
export {
normalizeNumberControlValue,
normalizeRuntimeSettings,
} from './config/normalize-runtime-settings';
-export { runtimeControls } from './config/runtime-controls';
-export { defaultVibeId, vibePresets } from './config/vibe-presets';
-export type { GardenRuntimeSettings, NumberControlConfig } from './config/types';
+export type {
+ GardenAppConfig,
+ GardenRuntimeSettings,
+ NumberControlConfig,
+} from './config/types';
+
+export const appConfig = {
+ audio: createGardenAudioConfig(),
+ analytics: {
+ autoCapturePageviews: true,
+ domain: 'fleeting.garden',
+ endpoint: 'https://stats.schmelczer.dev/status',
+ logging: import.meta.env.DEV,
+ },
+ deltaTime: {
+ maxDeltaTimeSeconds: 1 / 30,
+ minDeltaTimeSeconds: 1 / 240,
+ },
+ exportSnapshot: {
+ bytesPerPixel: 4,
+ filenameExtension: 'png',
+ filenamePrefix: 'fleeting-garden',
+ filenameSuffix: '-snapshot',
+ mimeType: 'image/png',
+ rowAlignmentBytes: 256,
+ },
+ menuHider: {
+ bottomRevealDistancePx: 96,
+ desktopMediaQuery: '(min-width: 600px) and (hover: hover) and (pointer: fine)',
+ hideDelayMs: 3000,
+ },
+ pipelines: {
+ common: {
+ noiseChannelSeeds: [0, 1, 2, 3],
+ noiseClearValue: { r: 1, g: 1, b: 1, a: 1 },
+ noiseDrawInstanceCount: 1,
+ noiseDrawVertexCount: 3,
+ noiseHashMultiplier: 43758.5453123,
+ noiseHashX: 12.9898,
+ noiseHashY: 78.233,
+ noiseTextureFormat: 'r8unorm',
+ noiseTextureSize: 2048,
+ },
+ brush: {
+ maxLineCount: 240,
+ },
+ diffusion: {
+ minDiffusionRate: 0.000001,
+ },
+ eraser: {
+ maxTextureLineCount: 384,
+ },
+ },
+ defaultSettings,
+ runtimeSettings: {
+ controls: runtimeControls,
+ },
+ simulation: {
+ brushEffectFramesPerSecond: 60,
+ clearColor: { r: 0, g: 0, b: 0, a: 0 },
+ initialAgentCount: 180_000,
+ // How long the source map continues to be diffused after a brush stroke ends.
+ // 600 frames at ~60 FPS ≈ 10 seconds.
+ sourceActiveFramesAfterWrite: 600,
+ intro: {
+ angleJitterRadians: Math.PI * 0.08,
+ angleEaseEnd: 1,
+ angleEaseStart: 0.6,
+ circleMaxSideRatio: 0.46,
+ circleMinSideRatio: 0.32,
+ drawHintDelayMs: 3000,
+ durationSeconds: 4,
+ entryJitterSideRatio: 0.035,
+ fontScaleDown: 0.94,
+ fontFamily: '"Open Sans", sans-serif',
+ initialFontHeightRatio: 0.28,
+ initialFontWidthRatio: 0.19,
+ letterSpacingEm: 0.07,
+ maskAlphaThreshold: 32,
+ maskGradientThreshold: 8,
+ maskMaxPixels: 1_000_000,
+ maskSampleDensity: 540,
+ maxHeightRatio: 0.25,
+ maxWidthRatio: 0.76,
+ minEntryJitterPx: 6,
+ minFontSizePx: 18,
+ minTargetJitterPx: 1,
+ pathEasing: 'easeOutQuad',
+ pathProgressEpsilon: 0.001,
+ radialJitterRatio: 0.35,
+ radialStartEpsilon: 0.001,
+ resizeMinimumRemainingSeconds: 1.4,
+ resizeSettleMs: 120,
+ targetDelayDistanceMultiplier: 0.12,
+ targetDelayMax: 0.22,
+ targetDelayRandomMultiplier: 0.06,
+ targetJitterSideRatio: 0.0035,
+ title: 'Fleeting',
+ titleColorCutLetters: [2, 5],
+ titleRadiusMultiplier: 1.55,
+ titleStrokeWidthMinPx: 6,
+ titleStrokeWidthRatio: 0.11,
+ verticalAnchor: 0.47,
+ },
+ introMoveSpeed: 280,
+ stroke: {
+ densityMultiplier: 110,
+ maxAgentCount: 2_400,
+ },
+ },
+ storage: {
+ audioMutedKey: 'fleeting-garden:audio-muted',
+ audioVolumeKey: 'fleeting-garden:audio-volume',
+ vibeKey: 'fleeting-garden:vibe',
+ },
+ toolbar: {
+ eraser: {
+ controlScaleMax: 1.34,
+ controlScaleMin: 0.74,
+ default: 96,
+ max: 480,
+ min: 24,
+ step: 1,
+ },
+ mirror: {
+ default: 8,
+ fallbackSegmentName: 'slices',
+ max: 12,
+ min: 1,
+ names: {
+ 2: 'halves',
+ 3: 'thirds',
+ 4: 'quarters',
+ 5: 'fifths',
+ 6: 'sixths',
+ 7: 'sevenths',
+ 8: 'eighths',
+ 9: 'ninths',
+ 10: 'tenths',
+ 11: 'elevenths',
+ 12: 'twelfths',
+ },
+ offLabel: 'Mirror off',
+ step: 1,
+ },
+ contrast: {
+ backgroundOpacityMax: 0.82,
+ brightLuminanceThreshold: 0.32,
+ brightWeight: 0.65,
+ bytesPerSample: 4,
+ contrastOffset: 0.05,
+ linearChannelBreakpoint: 0.03928,
+ linearChannelDivisor: 12.92,
+ linearChannelGamma: 2.4,
+ linearChannelOffset: 0.055,
+ linearChannelScale: 1.055,
+ lowContrastThreshold: 3,
+ lowContrastWeight: 1.8,
+ luminanceBase: 0.11,
+ luminanceBlueWeight: 0.0722,
+ luminanceGreenWeight: 0.7152,
+ luminanceRange: 0.28,
+ luminanceRedWeight: 0.2126,
+ sampleColumns: 13,
+ sampleIntervalMs: 300,
+ sampleRows: 7,
+ whiteContrastNumerator: 1.05,
+ },
+ volume: {
+ default: DEFAULT_AUDIO_VOLUME,
+ max: 1,
+ min: 0,
+ step: 0.01,
+ },
+ },
+ tuningPane: {
+ showFpsOverlay: import.meta.env.DEV,
+ startHidden: true,
+ title: 'Garden Settings',
+ },
+ vibes: {
+ defaultVibeId,
+ presets: vibePresets,
+ },
+} satisfies GardenAppConfig;
diff --git a/src/config/default-settings.ts b/src/config/default-settings.ts
index ba59a1e..37ce510 100644
--- a/src/config/default-settings.ts
+++ b/src/config/default-settings.ts
@@ -1,5 +1,5 @@
import { INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS } from './runtime-setting-bounds';
-import type { GardenDefaultSettings } from './types';
+import type { GardenAppConfig } from './types';
// Mirrors the historical render-scale cap so the default render area stays
// roughly equivalent to native rendering on high-DPR phones without the
@@ -21,7 +21,7 @@ const computeDefaultInternalRenderAreaMegapixels = (): number => {
);
};
-export const defaultSettings: GardenDefaultSettings = {
+export const defaultSettings: GardenAppConfig['defaultSettings'] = {
selectedColorIndex: 0,
introNearDistanceMin: 28,
diff --git a/src/config/eraser-size.ts b/src/config/eraser-size.ts
deleted file mode 100644
index 008bee7..0000000
--- a/src/config/eraser-size.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-export interface CssPixelSize {
- height: number;
- width: number;
-}
-
-export const ERASER_SIZE_MIN = 24;
-export const ERASER_SIZE_MAX = 480;
-
-const ERASER_MAX_SHORT_SIDE_RATIO = 0.55;
-
-const getNormalizedEraserSizeMax = (maxSize: number): number => {
- const safeMaxSize = Number.isFinite(maxSize) ? Math.floor(maxSize) : ERASER_SIZE_MAX;
- return Math.max(ERASER_SIZE_MIN, Math.min(ERASER_SIZE_MAX, safeMaxSize));
-};
-
-export const getElementCssPixelSize = (element: Element): CssPixelSize => {
- const rect = element.getBoundingClientRect();
- const { clientHeight = 0, clientWidth = 0 } = element as HTMLElement;
- return {
- height: rect.height || clientHeight,
- width: rect.width || clientWidth,
- };
-};
-
-export const getEraserSizeMaxForCssSize = ({ height, width }: CssPixelSize): number => {
- const shortestSide = Math.min(height, width);
- if (!Number.isFinite(shortestSide) || shortestSide <= 0) {
- return ERASER_SIZE_MAX;
- }
-
- return getNormalizedEraserSizeMax(
- Math.floor(shortestSide * ERASER_MAX_SHORT_SIDE_RATIO)
- );
-};
-
-export const clampEraserSize = (
- value: number,
- maxSize = ERASER_SIZE_MAX,
- fallbackSize = ERASER_SIZE_MIN
-): number => {
- const max = getNormalizedEraserSizeMax(maxSize);
- const fallback = Number.isFinite(fallbackSize) ? fallbackSize : ERASER_SIZE_MIN;
- const safeValue = Number.isFinite(value) ? value : fallback;
- return Math.min(max, Math.max(ERASER_SIZE_MIN, Math.round(safeValue)));
-};
-
-export const getEffectiveEraserSize = (
- size: number,
- cssSize: CssPixelSize,
- fallbackSize = ERASER_SIZE_MIN
-): number => clampEraserSize(size, getEraserSizeMaxForCssSize(cssSize), fallbackSize);
-
-export const getEraserSizeRatio = (size: number, maxSize = ERASER_SIZE_MAX): number => {
- const max = getNormalizedEraserSizeMax(maxSize);
- if (max === ERASER_SIZE_MIN) {
- return 0;
- }
-
- return (clampEraserSize(size, max) - ERASER_SIZE_MIN) / (max - ERASER_SIZE_MIN);
-};
-
-const ERASER_SLIDER_MIN = 0;
-const ERASER_SLIDER_MAX = 1;
-
-const clampSliderRatio = (value: number): number => {
- const safeValue = Number.isFinite(value) ? value : ERASER_SLIDER_MIN;
- return Math.min(ERASER_SLIDER_MAX, Math.max(ERASER_SLIDER_MIN, safeValue));
-};
-
-export const getEraserSizeFromSliderRatio = (
- sliderRatio: number,
- maxSize = ERASER_SIZE_MAX
-): number => {
- const max = getNormalizedEraserSizeMax(maxSize);
- return clampEraserSize(
- ERASER_SIZE_MIN + (max - ERASER_SIZE_MIN) * clampSliderRatio(sliderRatio) ** 2,
- max
- );
-};
-
-export const getEraserSliderRatioFromSize = (
- size: number,
- maxSize = ERASER_SIZE_MAX
-): number => Math.sqrt(getEraserSizeRatio(size, maxSize));
diff --git a/src/config/normalize-runtime-settings.ts b/src/config/normalize-runtime-settings.ts
index 60cbf38..ec4dcf5 100644
--- a/src/config/normalize-runtime-settings.ts
+++ b/src/config/normalize-runtime-settings.ts
@@ -1,9 +1,11 @@
import type {
+ GardenAppConfig,
GardenRuntimeSettings,
NumberControlConfig,
- RuntimeSettingControlConfig,
} from './types';
+type RuntimeSettingControls = GardenAppConfig['runtimeSettings']['controls'];
+
export const normalizeNumberControlValue = (
value: number,
config: NumberControlConfig
@@ -26,7 +28,7 @@ export const normalizeNumberControlValue = (
export const normalizeRuntimeSettings = (
settings: GardenRuntimeSettings,
- controls: RuntimeSettingControlConfig
+ controls: RuntimeSettingControls
): GardenRuntimeSettings => {
const normalized = { ...settings };
diff --git a/src/config/runtime-controls.ts b/src/config/runtime-controls.ts
index 463735a..e6b8a70 100644
--- a/src/config/runtime-controls.ts
+++ b/src/config/runtime-controls.ts
@@ -1,6 +1,6 @@
import { colorInteractionControl } from './color-interactions';
import { INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS } from './runtime-setting-bounds';
-import type { RuntimeSettingControlConfig } from './types';
+import type { GardenAppConfig } from './types';
const formatPercent = (value: number): string => `${Math.round(value * 100)}%`;
const formatRadiansAsDegrees = (value: number): string =>
@@ -16,7 +16,7 @@ const formatCompactNumber = (value: number): string => {
return `${value}`;
};
-export const runtimeControls: RuntimeSettingControlConfig = {
+export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
color1ToColor1: colorInteractionControl('Color 1 Follows Color 1'),
color1ToColor2: colorInteractionControl('Color 1 Follows Color 2'),
color1ToColor3: colorInteractionControl('Color 1 Follows Color 3'),
diff --git a/src/config/types.ts b/src/config/types.ts
index cd94c9a..6f9aa29 100644
--- a/src/config/types.ts
+++ b/src/config/types.ts
@@ -1,4 +1,7 @@
-import type { GardenAudioVibeSettings } from '../audio/garden-audio-config';
+import type {
+ GardenAudioConfig,
+ GardenAudioVibeSettings,
+} from '../audio/garden-audio-config';
import type { AgentSettings } from '../pipelines/agents/agent-pipeline';
import type { BrushSettings } from '../pipelines/brush/brush-pipeline';
import type { DiffusionSettings } from '../pipelines/diffusion/diffusion-pipeline';
@@ -46,7 +49,7 @@ export type GardenRuntimeSettings = {
DiffusionSettings &
RenderSettings;
-export type RuntimeSettingControlConfig = Partial<
+type RuntimeSettingControlConfig = Partial<
Record
>;
@@ -76,7 +79,7 @@ export type GardenVibeSettings = Pick<
| 'turnWhenLost'
>;
-export type GardenDefaultSettings = Omit<
+type GardenDefaultSettings = Omit<
GardenRuntimeSettings,
keyof GardenVibeSettings | 'eraserSize' | 'mirrorSegmentCount'
>;
@@ -98,3 +101,169 @@ export interface VibePreset {
settings: GardenVibeSettings;
audio: GardenAudioVibeSettings;
}
+
+export interface GardenAppConfig {
+ audio: GardenAudioConfig;
+ analytics: {
+ autoCapturePageviews: boolean;
+ domain: string;
+ endpoint: string;
+ logging: boolean;
+ };
+ deltaTime: {
+ maxDeltaTimeSeconds: number;
+ minDeltaTimeSeconds: number;
+ };
+ exportSnapshot: {
+ bytesPerPixel: number;
+ filenameExtension: string;
+ filenamePrefix: string;
+ filenameSuffix: string;
+ mimeType: string;
+ rowAlignmentBytes: number;
+ };
+ menuHider: {
+ bottomRevealDistancePx: number;
+ desktopMediaQuery: string;
+ hideDelayMs: number;
+ };
+ pipelines: {
+ common: {
+ noiseChannelSeeds: [number, number, number, number];
+ noiseClearValue: GPUColor;
+ noiseDrawInstanceCount: number;
+ noiseDrawVertexCount: number;
+ noiseHashMultiplier: number;
+ noiseHashX: number;
+ noiseHashY: number;
+ noiseTextureFormat: GPUTextureFormat;
+ noiseTextureSize: number;
+ };
+ brush: {
+ maxLineCount: number;
+ };
+ diffusion: {
+ minDiffusionRate: number;
+ };
+ eraser: {
+ maxTextureLineCount: number;
+ };
+ };
+ defaultSettings: GardenDefaultSettings;
+ runtimeSettings: {
+ controls: RuntimeSettingControlConfig;
+ };
+ simulation: {
+ brushEffectFramesPerSecond: number;
+ clearColor: GPUColor;
+ initialAgentCount: number;
+ sourceActiveFramesAfterWrite: number;
+ intro: {
+ angleJitterRadians: number;
+ angleEaseEnd: number;
+ angleEaseStart: number;
+ circleMaxSideRatio: number;
+ circleMinSideRatio: number;
+ drawHintDelayMs: number;
+ durationSeconds: number;
+ entryJitterSideRatio: number;
+ fontScaleDown: number;
+ fontFamily: string;
+ initialFontHeightRatio: number;
+ initialFontWidthRatio: number;
+ letterSpacingEm: number;
+ maskAlphaThreshold: number;
+ maskGradientThreshold: number;
+ maskMaxPixels: number;
+ maskSampleDensity: number;
+ maxHeightRatio: number;
+ maxWidthRatio: number;
+ minEntryJitterPx: number;
+ minFontSizePx: number;
+ minTargetJitterPx: number;
+ pathEasing: 'easeOutQuad' | 'linear';
+ pathProgressEpsilon: number;
+ radialJitterRatio: number;
+ radialStartEpsilon: number;
+ resizeMinimumRemainingSeconds: number;
+ resizeSettleMs: number;
+ targetDelayDistanceMultiplier: number;
+ targetDelayMax: number;
+ targetDelayRandomMultiplier: number;
+ targetJitterSideRatio: number;
+ title: string;
+ titleColorCutLetters: [number, number];
+ titleRadiusMultiplier: number;
+ titleStrokeWidthMinPx: number;
+ titleStrokeWidthRatio: number;
+ verticalAnchor: number;
+ };
+ introMoveSpeed: number;
+ stroke: {
+ densityMultiplier: number;
+ maxAgentCount: number;
+ };
+ };
+ storage: {
+ audioMutedKey: string;
+ audioVolumeKey: string;
+ vibeKey: string;
+ };
+ toolbar: {
+ eraser: {
+ controlScaleMax: number;
+ controlScaleMin: number;
+ default: number;
+ max: number;
+ min: number;
+ step: number;
+ };
+ mirror: {
+ default: number;
+ fallbackSegmentName: string;
+ max: number;
+ min: number;
+ names: Record;
+ offLabel: string;
+ step: number;
+ };
+ contrast: {
+ backgroundOpacityMax: number;
+ brightLuminanceThreshold: number;
+ brightWeight: number;
+ bytesPerSample: number;
+ contrastOffset: number;
+ linearChannelBreakpoint: number;
+ linearChannelDivisor: number;
+ linearChannelGamma: number;
+ linearChannelOffset: number;
+ linearChannelScale: number;
+ lowContrastThreshold: number;
+ lowContrastWeight: number;
+ luminanceBase: number;
+ luminanceBlueWeight: number;
+ luminanceGreenWeight: number;
+ luminanceRange: number;
+ luminanceRedWeight: number;
+ sampleColumns: number;
+ sampleIntervalMs: number;
+ sampleRows: number;
+ whiteContrastNumerator: number;
+ };
+ volume: {
+ default: number;
+ max: number;
+ min: number;
+ step: number;
+ };
+ };
+ tuningPane: {
+ showFpsOverlay: boolean;
+ startHidden: boolean;
+ title: string;
+ };
+ vibes: {
+ defaultVibeId: VibeId;
+ presets: Array;
+ };
+}
diff --git a/src/game-loop/agent-population.test.ts b/src/game-loop/agent-population.test.ts
index 7232781..2bc0e26 100644
--- a/src/game-loop/agent-population.test.ts
+++ b/src/game-loop/agent-population.test.ts
@@ -1,14 +1,11 @@
import { vec2 } from 'gl-matrix';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { appConfig } from '../config';
import { type AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
import { AGENT_FLOAT_COUNT } from '../pipelines/agents/agent-limits';
import { settings } from '../settings';
-import {
- AgentPopulation,
- STROKE_AGENT_BATCH_CAPACITY,
- STROKE_DENSITY_MULTIPLIER,
-} from './agent-population';
+import { AgentPopulation } from './agent-population';
import { type FramePerformance } from './frame-performance';
const originalSettings = {
@@ -73,7 +70,7 @@ const createPopulation = (): {
};
const setSpawnRate = (agentsPerPixel: number): void => {
- settings.spawnPerPixel = agentsPerPixel / STROKE_DENSITY_MULTIPLIER;
+ settings.spawnPerPixel = agentsPerPixel / appConfig.simulation.stroke.densityMultiplier;
};
describe('AgentPopulation stroke spawning', () => {
@@ -127,7 +124,7 @@ describe('AgentPopulation stroke spawning', () => {
it('chunks long stroke writes without clipping length-linear spawn counts', () => {
const { pipeline, population } = createPopulation();
- const batchCapacity = STROKE_AGENT_BATCH_CAPACITY;
+ const batchCapacity = appConfig.simulation.stroke.maxAgentCount;
const expectedAgentCount = batchCapacity + 10;
population.spawnStrokeAgents(
diff --git a/src/game-loop/agent-population.ts b/src/game-loop/agent-population.ts
index 019a74f..1d0390f 100644
--- a/src/game-loop/agent-population.ts
+++ b/src/game-loop/agent-population.ts
@@ -1,5 +1,6 @@
import { vec2 } from 'gl-matrix';
+import { appConfig } from '../config';
import { getRenderQualityBrushSize } from '../config/brush-size';
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
import { AGENT_FLOAT_COUNT, writeAgentValues } from '../pipelines/agents/agent-limits';
@@ -8,11 +9,6 @@ import { settings } from '../settings';
import type { FramePerformance } from './frame-performance';
import { createIntroTitleAgents } from './intro-title-agents';
-export const STROKE_AGENT_BATCH_CAPACITY = 2_400;
-export const STROKE_DENSITY_MULTIPLIER = 110;
-
-const INITIAL_INTRO_AGENT_COUNT = 180_000;
-
export class AgentPopulation {
private activeCount = 0;
// Current performance-aware limit; new agents above it replace old agents.
@@ -26,7 +22,7 @@ export class AgentPopulation {
private readonly queuedAgentBatches: Array = [];
private pendingStrokeAgentCount = 0;
private readonly strokeAgentData = new Float32Array(
- STROKE_AGENT_BATCH_CAPACITY * AGENT_FLOAT_COUNT
+ appConfig.simulation.stroke.maxAgentCount * AGENT_FLOAT_COUNT
);
public constructor(
@@ -50,7 +46,10 @@ export class AgentPopulation {
public replaceIntroAgents(canvasSize: vec2, progress: number): void {
this.adaptiveCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
- const introAgentCount = Math.min(this.adaptiveCap, INITIAL_INTRO_AGENT_COUNT);
+ const introAgentCount = Math.min(
+ this.adaptiveCap,
+ appConfig.simulation.initialAgentCount
+ );
const data = createIntroTitleAgents({
count: introAgentCount,
width: canvasSize[0],
@@ -333,5 +332,8 @@ const getStrokeSpawnRate = (): number => {
const spawnPerPixel = Number.isFinite(settings.spawnPerPixel)
? settings.spawnPerPixel
: 0;
- return Math.max(0, spawnPerPixel * STROKE_DENSITY_MULTIPLIER);
+ const densityMultiplier = Number.isFinite(appConfig.simulation.stroke.densityMultiplier)
+ ? appConfig.simulation.stroke.densityMultiplier
+ : 0;
+ return Math.max(0, spawnPerPixel * densityMultiplier);
};
diff --git a/src/game-loop/brush-stroke-smoother.ts b/src/game-loop/brush-stroke-smoother.ts
index 918b95c..3d45283 100644
--- a/src/game-loop/brush-stroke-smoother.ts
+++ b/src/game-loop/brush-stroke-smoother.ts
@@ -1,6 +1,6 @@
import { vec2 } from 'gl-matrix';
-import { defaultSettings } from '../config';
+import { appConfig } from '../config';
import { getRenderQualityBrushSize } from '../config/brush-size';
import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline';
import { settings } from '../settings';
@@ -143,13 +143,13 @@ const getQuadraticPoint = (start: vec2, control: vec2, end: vec2, t: number): ve
const getBrushCurveResolution = (): number => {
const resolution = Number.isFinite(settings.brushCurveResolution)
? settings.brushCurveResolution
- : defaultSettings.brushCurveResolution;
+ : appConfig.defaultSettings.brushCurveResolution;
return Math.max(1, Math.floor(resolution));
};
const getBrushSmoothingDistanceSquared = (pixelRatio?: number): number => {
const distance = Number.isFinite(settings.brushSmoothingMinSampleDistance)
? settings.brushSmoothingMinSampleDistance
- : defaultSettings.brushSmoothingMinSampleDistance;
+ : appConfig.defaultSettings.brushSmoothingMinSampleDistance;
return Math.max(0, distance * getSafePixelRatio(pixelRatio)) ** 2;
};
diff --git a/src/game-loop/eraser-preview.ts b/src/game-loop/eraser-preview.ts
index 493d497..fefd93c 100644
--- a/src/game-loop/eraser-preview.ts
+++ b/src/game-loop/eraser-preview.ts
@@ -1,4 +1,3 @@
-import { getEffectiveEraserSize } from '../config/eraser-size';
import { settings } from '../settings';
export class EraserPreview {
@@ -50,15 +49,9 @@ export class EraserPreview {
};
}
- const rect = this.canvas.getBoundingClientRect();
- const size = getEffectiveEraserSize(settings.eraserSize, {
- height: rect.height || this.canvas.clientHeight,
- width: rect.width || this.canvas.clientWidth,
- });
-
- if (this.previousSize !== size) {
- this.element.style.setProperty('--eraser-preview-size', `${size}px`);
- this.previousSize = size;
+ if (this.previousSize !== settings.eraserSize) {
+ this.element.style.setProperty('--eraser-preview-size', `${settings.eraserSize}px`);
+ this.previousSize = settings.eraserSize;
}
if (
@@ -70,6 +63,7 @@ export class EraserPreview {
return;
}
+ const rect = this.canvas.getBoundingClientRect();
const left = `${this.previewClientPosition.x - rect.left}px`;
const top = `${this.previewClientPosition.y - rect.top}px`;
if (this.previousLeft !== left) {
diff --git a/src/game-loop/export-snapshot-renderer.ts b/src/game-loop/export-snapshot-renderer.ts
index db675cc..0be8911 100644
--- a/src/game-loop/export-snapshot-renderer.ts
+++ b/src/game-loop/export-snapshot-renderer.ts
@@ -1,13 +1,7 @@
+import { appConfig } from '../config';
import { RenderPipeline } from '../pipelines/render/render-pipeline';
import type { VibeId } from '../vibes';
-const SNAPSHOT_BYTES_PER_PIXEL = 4;
-const SNAPSHOT_FILENAME_EXTENSION = 'png';
-const SNAPSHOT_FILENAME_PREFIX = 'fleeting-garden';
-const SNAPSHOT_FILENAME_SUFFIX = '-snapshot';
-const SNAPSHOT_MIME_TYPE = 'image/png';
-const SNAPSHOT_ROW_ALIGNMENT_BYTES = 256;
-
interface ExportSnapshotRendererOptions {
device: GPUDevice;
renderPipeline: RenderPipeline;
@@ -127,15 +121,15 @@ export class ExportSnapshotRenderer {
context.putImageData(new ImageData(pixels, width, height), 0, 0);
const blob = await canvas.convertToBlob({
- type: SNAPSHOT_MIME_TYPE,
+ type: appConfig.exportSnapshot.mimeType,
});
const link = document.createElement('a');
const objectUrl = URL.createObjectURL(blob);
try {
link.href = objectUrl;
- link.download = `${SNAPSHOT_FILENAME_PREFIX}_${this.options.getVibeId()}_${
+ link.download = `${appConfig.exportSnapshot.filenamePrefix}_${this.options.getVibeId()}_${
this.options.seed
- }_${width}x${height}${SNAPSHOT_FILENAME_SUFFIX}.${SNAPSHOT_FILENAME_EXTENSION}`;
+ }_${width}x${height}${appConfig.exportSnapshot.filenameSuffix}.${appConfig.exportSnapshot.filenameExtension}`;
link.click();
} finally {
URL.revokeObjectURL(objectUrl);
@@ -160,8 +154,11 @@ const getSnapshotDimension = (value: number): number =>
const getSnapshotLayout = (sourceWidth: number, sourceHeight: number): SnapshotLayout => {
const width = getSnapshotDimension(sourceWidth);
const height = getSnapshotDimension(sourceHeight);
- const unpaddedBytesPerRow = width * SNAPSHOT_BYTES_PER_PIXEL;
- const bytesPerRow = alignTo(unpaddedBytesPerRow, SNAPSHOT_ROW_ALIGNMENT_BYTES);
+ const unpaddedBytesPerRow = width * appConfig.exportSnapshot.bytesPerPixel;
+ const bytesPerRow = alignTo(
+ unpaddedBytesPerRow,
+ appConfig.exportSnapshot.rowAlignmentBytes
+ );
return {
width,
@@ -194,8 +191,8 @@ const readSnapshotPixels = ({
const sourceOffset = y * bytesPerRow;
const targetOffset = y * unpaddedBytesPerRow;
for (let x = 0; x < width; x++) {
- const source = sourceOffset + x * SNAPSHOT_BYTES_PER_PIXEL;
- const target = targetOffset + x * SNAPSHOT_BYTES_PER_PIXEL;
+ const source = sourceOffset + x * appConfig.exportSnapshot.bytesPerPixel;
+ const target = targetOffset + x * appConfig.exportSnapshot.bytesPerPixel;
pixels[target] = isBgra ? mapped[source + 2] : mapped[source];
pixels[target + 1] = mapped[source + 1];
pixels[target + 2] = isBgra ? mapped[source] : mapped[source + 2];
diff --git a/src/game-loop/game-loop-resources.ts b/src/game-loop/game-loop-resources.ts
index a380c23..64c9680 100644
--- a/src/game-loop/game-loop-resources.ts
+++ b/src/game-loop/game-loop-resources.ts
@@ -1,6 +1,6 @@
import { vec2 } from 'gl-matrix';
-import { type GardenRuntimeSettings } from '../config';
+import { appConfig, type GardenRuntimeSettings } from '../config';
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
import { AgentPipeline } from '../pipelines/agents/agent-pipeline';
import { BrushPipeline } from '../pipelines/brush/brush-pipeline';
@@ -12,12 +12,9 @@ import { RenderPipeline } from '../pipelines/render/render-pipeline';
import { initializeContext } from '../utils/graphics/initialize-context';
import { CanvasReadbackRequest, RenderInputs } from './game-loop-types';
import { GpuProfiler } from './gpu-profiler';
-import { perfStatsOverlayState } from './perf-stats-overlay';
import { SimulationFrameRenderer } from './simulation-frame';
import { SimulationTextures } from './simulation-textures';
-const INTRO_MOVE_SPEED = 280;
-
interface FrameParameters extends RenderInputs {
time: number;
deltaTime: number;
@@ -81,7 +78,7 @@ export class GameLoopResources {
this.renderPipeline = new RenderPipeline(context, this.device, this.canvasFormat);
this.gpuProfiler = GpuProfiler.create(
this.device,
- () => perfStatsOverlayState.isVisible
+ () => appConfig.tuningPane.showFpsOverlay
);
this.frameRenderer = new SimulationFrameRenderer(
@@ -137,7 +134,7 @@ export class GameLoopResources {
deltaTime,
time,
agentCount: activeAgentCount,
- introMoveSpeed: INTRO_MOVE_SPEED,
+ introMoveSpeed: appConfig.simulation.introMoveSpeed,
introProgress,
});
this.brushPipeline.setParameters({
diff --git a/src/game-loop/game-loop.ts b/src/game-loop/game-loop.ts
index d53a73c..aed5289 100644
--- a/src/game-loop/game-loop.ts
+++ b/src/game-loop/game-loop.ts
@@ -1,8 +1,7 @@
import { vec2 } from 'gl-matrix';
import { GardenAudio } from '../audio/garden-audio';
-import { createGardenAudioConfig } from '../audio/garden-audio-config';
-import { getEffectiveEraserSize, getElementCssPixelSize } from '../config/eraser-size';
+import { appConfig } from '../config';
import { activeVibe, settings } from '../settings';
import { DeltaTimeCalculator } from '../utils/delta-time-calculator';
import { rgbColorToCss, type RgbColor } from '../utils/rgb-color';
@@ -14,19 +13,14 @@ import { GameLoopResources } from './game-loop-resources';
import { GardenUi } from './game-loop-types';
import { getInternalRenderSize } from './internal-render-size';
import { IntroPrompt } from './intro-prompt';
-import { PerfStatsOverlay, perfStatsOverlayState } from './perf-stats-overlay';
+import { PerfStatsOverlay } from './perf-stats-overlay';
import { GardenPointerInput } from './pointer-input';
-import { MAX_MIRROR_SEGMENT_COUNT, MIN_MIRROR_SEGMENT_COUNT } from './stroke-mirroring';
import { PipelineStrokeOutput } from './stroke-output';
import { ToolbarContrastMonitor } from './toolbar-contrast-monitor';
-const INTRO_RESIZE_SETTLE_MS = 120;
-const INTRO_RESIZE_MINIMUM_REMAINING_SECONDS = 1.4;
-const GARDEN_AUDIO_CONFIG = createGardenAudioConfig();
-
export default class GameLoop {
private readonly resources: GameLoopResources;
- private readonly audio = new GardenAudio(GARDEN_AUDIO_CONFIG);
+ private readonly audio = new GardenAudio(appConfig.audio);
private readonly introPrompt: IntroPrompt;
private readonly eraserPreview: EraserPreview;
private readonly pointerInput: GardenPointerInput;
@@ -210,11 +204,7 @@ export default class GameLoop {
const runtimeSettings = { ...settings };
const introProgress = this.introPrompt.progress;
const canvasPixelRatio = this.canvasPixelRatio;
- const eraserCssSize = getEffectiveEraserSize(
- runtimeSettings.eraserSize,
- getElementCssPixelSize(this.canvas)
- );
- const eraserPixelSize = eraserCssSize * canvasPixelRatio;
+ const eraserPixelSize = runtimeSettings.eraserSize * canvasPixelRatio;
const isErasing = this.pointerInput.isEraseMode;
const accentColor =
channelColors[runtimeSettings.selectedColorIndex] ?? channelColors[0];
@@ -260,7 +250,7 @@ export default class GameLoop {
};
private syncPerfStatsOverlay(): void {
- if (perfStatsOverlayState.isVisible) {
+ if (appConfig.tuningPane.showFpsOverlay) {
this.perfStatsOverlay ??= new PerfStatsOverlay(
this.canvas.parentElement ?? document.body
);
@@ -333,11 +323,13 @@ export default class GameLoop {
return;
}
- if (time - this.pendingIntroResizeAt < INTRO_RESIZE_SETTLE_MS) {
+ if (time - this.pendingIntroResizeAt < appConfig.simulation.intro.resizeSettleMs) {
return;
}
- this.introPrompt.rewindToLeaveRemainingTime(INTRO_RESIZE_MINIMUM_REMAINING_SECONDS);
+ this.introPrompt.rewindToLeaveRemainingTime(
+ appConfig.simulation.intro.resizeMinimumRemainingSeconds
+ );
this.resources.clearSimulation();
this.agentPopulation.replaceIntroAgents(this.canvasSize, this.introPrompt.progress);
this.pendingIntroResizeAt = null;
@@ -359,10 +351,10 @@ export default class GameLoop {
private get mirrorSegmentCount(): number {
const count = Number.isFinite(settings.mirrorSegmentCount)
? settings.mirrorSegmentCount
- : MIN_MIRROR_SEGMENT_COUNT;
+ : appConfig.toolbar.mirror.min;
return Math.min(
- MAX_MIRROR_SEGMENT_COUNT,
- Math.max(MIN_MIRROR_SEGMENT_COUNT, Math.round(count))
+ appConfig.toolbar.mirror.max,
+ Math.max(appConfig.toolbar.mirror.min, Math.round(count))
);
}
diff --git a/src/game-loop/intro-prompt.ts b/src/game-loop/intro-prompt.ts
index 483f7f0..cd9468d 100644
--- a/src/game-loop/intro-prompt.ts
+++ b/src/game-loop/intro-prompt.ts
@@ -1,6 +1,6 @@
+import { appConfig } from '../config';
+
const DRAW_HINT_CLASS = 'draw-hint';
-const INTRO_DURATION_SECONDS = 4;
-const DRAW_HINT_DELAY_MS = 3000;
export class IntroPrompt {
private introComplete = false;
@@ -13,7 +13,10 @@ export class IntroPrompt {
public get progress(): number {
return this.introComplete
? 1
- : Math.min(1, this.introElapsedSeconds / INTRO_DURATION_SECONDS);
+ : Math.min(
+ 1,
+ this.introElapsedSeconds / appConfig.simulation.intro.durationSeconds
+ );
}
public get shouldRegenerateTitleOnResize(): boolean {
@@ -30,7 +33,7 @@ export class IntroPrompt {
: 0;
this.introElapsedSeconds = Math.min(
this.introElapsedSeconds,
- Math.max(0, INTRO_DURATION_SECONDS - safeRemainingSeconds)
+ Math.max(0, appConfig.simulation.intro.durationSeconds - safeRemainingSeconds)
);
}
@@ -42,7 +45,10 @@ export class IntroPrompt {
this.introElapsedSeconds += safeDeltaTime;
}
- if (!this.introComplete && this.introElapsedSeconds >= INTRO_DURATION_SECONDS) {
+ if (
+ !this.introComplete &&
+ this.introElapsedSeconds >= appConfig.simulation.intro.durationSeconds
+ ) {
this.complete(now);
}
@@ -50,7 +56,7 @@ export class IntroPrompt {
!this.introComplete ||
this.hasStartedDrawing ||
this.introCompletedAt === null ||
- now - this.introCompletedAt < DRAW_HINT_DELAY_MS
+ now - this.introCompletedAt < appConfig.simulation.intro.drawHintDelayMs
) {
return;
}
diff --git a/src/game-loop/intro-title-agents.ts b/src/game-loop/intro-title-agents.ts
index 168c106..a34bbf4 100644
--- a/src/game-loop/intro-title-agents.ts
+++ b/src/game-loop/intro-title-agents.ts
@@ -1,3 +1,4 @@
+import { appConfig, type GardenAppConfig } from '../config';
import { AGENT_FLOAT_COUNT, writeAgentValues } from '../pipelines/agents/agent-limits';
import { clamp, easeOutQuad, mix, mixAngle, smoothstep } from '../utils/math';
@@ -17,43 +18,9 @@ interface IntroTitleAgentOptions {
}
type RandomSource = () => number;
-type IntroPathEasing = 'easeOutQuad' | 'linear';
-
-const INTRO_TITLE = 'Fleeting';
-const INTRO_ANGLE_JITTER_RADIANS = Math.PI * 0.08;
-const INTRO_ANGLE_EASE_START = 0.6;
-const INTRO_ANGLE_EASE_END = 1;
-const INTRO_CIRCLE_MIN_SIDE_RATIO = 0.32;
-const INTRO_CIRCLE_MAX_SIDE_RATIO = 0.46;
-const INTRO_ENTRY_JITTER_SIDE_RATIO = 0.035;
-const INTRO_FONT_FAMILY = '"Open Sans", sans-serif';
-const INTRO_FONT_SCALE_DOWN = 0.94;
-const INTRO_INITIAL_FONT_HEIGHT_RATIO = 0.28;
-const INTRO_INITIAL_FONT_WIDTH_RATIO = 0.19;
-const INTRO_LETTER_SPACING_EM = 0.07;
-const INTRO_MASK_ALPHA_THRESHOLD = 32;
-const INTRO_MASK_GRADIENT_THRESHOLD = 8;
-const INTRO_MASK_MAX_PIXELS = 1_000_000;
-const INTRO_MASK_SAMPLE_DENSITY = 540;
-const INTRO_MAX_HEIGHT_RATIO = 0.25;
-const INTRO_MAX_WIDTH_RATIO = 0.76;
-const INTRO_MIN_ENTRY_JITTER_PX = 6;
-const INTRO_MIN_FONT_SIZE_PX = 18;
-const INTRO_MIN_TARGET_JITTER_PX = 1;
-const INTRO_PATH_EASING: IntroPathEasing = 'easeOutQuad';
-const INTRO_PATH_PROGRESS_EPSILON = 0.001;
-const INTRO_RADIAL_JITTER_RATIO = 0.35;
-const INTRO_RADIAL_START_EPSILON = 0.001;
-const INTRO_TARGET_DELAY_DISTANCE_MULTIPLIER = 0.12;
-const INTRO_TARGET_DELAY_MAX = 0.22;
-const INTRO_TARGET_DELAY_RANDOM_MULTIPLIER = 0.06;
-const INTRO_TARGET_JITTER_SIDE_RATIO = 0.0035;
-const INTRO_TITLE_COLOR_CUT_LETTERS = [2, 5] as const;
-const INTRO_TITLE_RADIUS_MULTIPLIER = 1.55;
-const INTRO_TITLE_STROKE_WIDTH_MIN_PX = 6;
-const INTRO_TITLE_STROKE_WIDTH_RATIO = 0.11;
-const INTRO_VERTICAL_ANCHOR = 0.47;
+type IntroPathEasing = GardenAppConfig['simulation']['intro']['pathEasing'];
+const INTRO_TITLE = appConfig.simulation.intro.title;
const isLinearPathEasing = (pathEasing: IntroPathEasing): boolean =>
pathEasing === 'linear';
@@ -80,27 +47,30 @@ export const createIntroTitleAgents = ({
const data = new Float32Array(count * AGENT_FLOAT_COUNT);
const minSide = Math.min(safeWidth, safeHeight);
const targetJitter = Math.max(
- INTRO_MIN_TARGET_JITTER_PX,
- minSide * INTRO_TARGET_JITTER_SIDE_RATIO
+ appConfig.simulation.intro.minTargetJitterPx,
+ minSide * appConfig.simulation.intro.targetJitterSideRatio
);
const entryJitter = Math.max(
- INTRO_MIN_ENTRY_JITTER_PX,
- minSide * INTRO_ENTRY_JITTER_SIDE_RATIO
+ appConfig.simulation.intro.minEntryJitterPx,
+ minSide * appConfig.simulation.intro.entryJitterSideRatio
);
const titleRadius = points.reduce(
(radius, point) =>
Math.max(
radius,
- Math.hypot(point.x - safeWidth / 2, point.y - safeHeight * INTRO_VERTICAL_ANCHOR)
+ Math.hypot(
+ point.x - safeWidth / 2,
+ point.y - safeHeight * appConfig.simulation.intro.verticalAnchor
+ )
),
0
);
const introCircleRadius = Math.min(
Math.max(
- titleRadius * INTRO_TITLE_RADIUS_MULTIPLIER,
- minSide * INTRO_CIRCLE_MIN_SIDE_RATIO
+ titleRadius * appConfig.simulation.intro.titleRadiusMultiplier,
+ minSide * appConfig.simulation.intro.circleMinSideRatio
),
- minSide * INTRO_CIRCLE_MAX_SIDE_RATIO
+ minSide * appConfig.simulation.intro.circleMaxSideRatio
);
for (let i = 0; i < count; i++) {
@@ -131,16 +101,21 @@ export const createIntroTitleAgents = ({
const distanceFraction =
Math.hypot(targetX - startX, targetY - startY) / Math.hypot(safeWidth, safeHeight);
const introDelay = Math.min(
- INTRO_TARGET_DELAY_MAX,
- distanceFraction * INTRO_TARGET_DELAY_DISTANCE_MULTIPLIER +
- random() * INTRO_TARGET_DELAY_RANDOM_MULTIPLIER
+ appConfig.simulation.intro.targetDelayMax,
+ distanceFraction * appConfig.simulation.intro.targetDelayDistanceMultiplier +
+ random() * appConfig.simulation.intro.targetDelayRandomMultiplier
);
const pathProgress = getIntroAgentPathProgress(introProgress, introDelay);
- const initialAngle = approachAngle + (random() - 0.5) * INTRO_ANGLE_JITTER_RADIANS;
+ const initialAngle =
+ approachAngle + (random() - 0.5) * appConfig.simulation.intro.angleJitterRadians;
const currentAngle = mixAngle(
initialAngle,
targetAngle,
- smoothstep(INTRO_ANGLE_EASE_START, INTRO_ANGLE_EASE_END, pathProgress)
+ smoothstep(
+ appConfig.simulation.intro.angleEaseStart,
+ appConfig.simulation.intro.angleEaseEnd,
+ pathProgress
+ )
);
writeAgentValues(data, i, {
positionX: mix(startX, targetX, pathProgress),
@@ -167,12 +142,12 @@ const getIntroRadialStart = (
random: RandomSource
): [number, number] => {
const centerX = width / 2;
- const centerY = height * INTRO_VERTICAL_ANCHOR;
+ const centerY = height * appConfig.simulation.intro.verticalAnchor;
const offsetX = targetX - centerX;
const offsetY = targetY - centerY;
const length = Math.hypot(offsetX, offsetY);
const angle =
- length > INTRO_RADIAL_START_EPSILON
+ length > appConfig.simulation.intro.radialStartEpsilon
? Math.atan2(offsetY, offsetX)
: random() * Math.PI * 2;
const directionX = Math.cos(angle);
@@ -180,7 +155,8 @@ const getIntroRadialStart = (
const tangentX = -directionY;
const tangentY = directionX;
const tangentJitter = (random() - 0.5) * jitter;
- const radialJitter = (random() - 0.5) * jitter * INTRO_RADIAL_JITTER_RATIO;
+ const radialJitter =
+ (random() - 0.5) * jitter * appConfig.simulation.intro.radialJitterRatio;
const startX =
centerX + directionX * (radius + radialJitter) + tangentX * tangentJitter;
const startY =
@@ -196,7 +172,7 @@ const createIntroTitlePoints = (
width: number,
height: number
): Array => {
- const safeMaxPixels = Math.max(1, INTRO_MASK_MAX_PIXELS);
+ const safeMaxPixels = Math.max(1, appConfig.simulation.intro.maskMaxPixels);
const maskScale = Math.min(1, Math.sqrt(safeMaxPixels / Math.max(1, width * height)));
const maskWidth = Math.max(1, Math.round(width * maskScale));
const maskHeight = Math.max(1, Math.round(height * maskScale));
@@ -212,28 +188,28 @@ const createIntroTitlePoints = (
const fontSize = getIntroTitleFontSize(context, maskWidth, maskHeight);
context.clearRect(0, 0, maskWidth, maskHeight);
- context.font = `${fontSize}px ${INTRO_FONT_FAMILY}`;
+ context.font = `${fontSize}px ${appConfig.simulation.intro.fontFamily}`;
context.textAlign = 'center';
context.textBaseline = 'middle';
context.fillStyle = '#fff';
context.strokeStyle = '#fff';
context.lineJoin = 'round';
context.lineWidth = Math.max(
- INTRO_TITLE_STROKE_WIDTH_MIN_PX,
- fontSize * INTRO_TITLE_STROKE_WIDTH_RATIO
+ appConfig.simulation.intro.titleStrokeWidthMinPx,
+ fontSize * appConfig.simulation.intro.titleStrokeWidthRatio
);
- const letterSpacing = fontSize * INTRO_LETTER_SPACING_EM;
+ const letterSpacing = fontSize * appConfig.simulation.intro.letterSpacingEm;
drawIntroTitleText(
context,
maskWidth / 2,
- maskHeight * INTRO_VERTICAL_ANCHOR,
+ maskHeight * appConfig.simulation.intro.verticalAnchor,
letterSpacing,
'stroke'
);
drawIntroTitleText(
context,
maskWidth / 2,
- maskHeight * INTRO_VERTICAL_ANCHOR,
+ maskHeight * appConfig.simulation.intro.verticalAnchor,
letterSpacing,
'fill'
);
@@ -241,7 +217,9 @@ const createIntroTitlePoints = (
const { data } = context.getImageData(0, 0, maskWidth, maskHeight);
const step = Math.max(
1,
- Math.floor(Math.min(maskWidth, maskHeight) / INTRO_MASK_SAMPLE_DENSITY)
+ Math.floor(
+ Math.min(maskWidth, maskHeight) / appConfig.simulation.intro.maskSampleDensity
+ )
);
const points: Array = [];
const characterColorBoundaries = getIntroTitleColorBoundaries(
@@ -253,7 +231,7 @@ const createIntroTitlePoints = (
for (let y = 0; y < maskHeight; y += step) {
for (let x = 0; x < maskWidth; x += step) {
const alpha = getMaskAlpha(data, maskWidth, maskHeight, x, y);
- if (alpha < INTRO_MASK_ALPHA_THRESHOLD) {
+ if (alpha < appConfig.simulation.intro.maskAlphaThreshold) {
continue;
}
@@ -277,9 +255,9 @@ const getIntroTitleColorBoundaries = (
const letters = Array.from(INTRO_TITLE);
const totalWidth = measureIntroTitleText(context, letters, letterSpacing);
let x = width / 2 - totalWidth / 2;
- const cutLetters = INTRO_TITLE_COLOR_CUT_LETTERS.map((cutLetter) =>
- Math.min(letters.length - 1, Math.max(1, Math.round(cutLetter)))
- ).sort((a, b) => a - b);
+ const cutLetters = appConfig.simulation.intro.titleColorCutLetters
+ .map((cutLetter) => Math.min(letters.length - 1, Math.max(1, Math.round(cutLetter))))
+ .sort((a, b) => a - b);
const [firstCutLetter, secondCutLetter] = cutLetters;
const letterBoxes = letters.map((letter, index) => {
const letterWidth = context.measureText(letter).width;
@@ -352,17 +330,17 @@ const getIntroTitleFontSize = (
width: number,
height: number
): number => {
- const maxWidth = width * INTRO_MAX_WIDTH_RATIO;
- const maxHeight = height * INTRO_MAX_HEIGHT_RATIO;
+ const maxWidth = width * appConfig.simulation.intro.maxWidthRatio;
+ const maxHeight = height * appConfig.simulation.intro.maxHeightRatio;
let fontSize = Math.floor(
Math.min(
- height * INTRO_INITIAL_FONT_HEIGHT_RATIO,
- width * INTRO_INITIAL_FONT_WIDTH_RATIO
+ height * appConfig.simulation.intro.initialFontHeightRatio,
+ width * appConfig.simulation.intro.initialFontWidthRatio
)
);
- while (fontSize > INTRO_MIN_FONT_SIZE_PX) {
- context.font = `${fontSize}px ${INTRO_FONT_FAMILY}`;
+ while (fontSize > appConfig.simulation.intro.minFontSizePx) {
+ context.font = `${fontSize}px ${appConfig.simulation.intro.fontFamily}`;
const metrics = context.measureText(INTRO_TITLE);
const measuredHeight =
metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent || fontSize;
@@ -371,7 +349,7 @@ const getIntroTitleFontSize = (
return fontSize;
}
- fontSize = Math.floor(fontSize * INTRO_FONT_SCALE_DOWN);
+ fontSize = Math.floor(fontSize * appConfig.simulation.intro.fontScaleDown);
}
return fontSize;
@@ -391,7 +369,10 @@ const estimateMaskTangent = (
getMaskAlpha(data, width, height, x, y + 1) -
getMaskAlpha(data, width, height, x, y - 1);
- if (Math.abs(gradientX) + Math.abs(gradientY) < INTRO_MASK_GRADIENT_THRESHOLD) {
+ if (
+ Math.abs(gradientX) + Math.abs(gradientY) <
+ appConfig.simulation.intro.maskGradientThreshold
+ ) {
return null;
}
@@ -416,7 +397,8 @@ const getIntroAgentPathProgress = (introProgress: number, introDelay: number): n
}
const activeProgress =
- (introProgress - introDelay) / Math.max(INTRO_PATH_PROGRESS_EPSILON, 1 - introDelay);
+ (introProgress - introDelay) /
+ Math.max(appConfig.simulation.intro.pathProgressEpsilon, 1 - introDelay);
return easePathProgress(clamp(activeProgress, 0, 1));
};
@@ -432,7 +414,7 @@ const createSeededRandom = (seed: number): RandomSource => {
};
const easePathProgress = (amount: number): number => {
- if (isLinearPathEasing(INTRO_PATH_EASING)) {
+ if (isLinearPathEasing(appConfig.simulation.intro.pathEasing)) {
return amount;
}
diff --git a/src/game-loop/perf-stats-overlay.ts b/src/game-loop/perf-stats-overlay.ts
index e9853d9..9e6717f 100644
--- a/src/game-loop/perf-stats-overlay.ts
+++ b/src/game-loop/perf-stats-overlay.ts
@@ -4,10 +4,6 @@ const ZERO_STAT_TEXT = '0';
const ZERO_FRAME_TIME_TEXT = '0ms';
const ZERO_RESOLUTION_TEXT = '0x0';
-export const perfStatsOverlayState = {
- isVisible: import.meta.env.DEV,
-};
-
interface PerfStatsSnapshot {
time: DOMHighResTimeStamp;
fps: number;
diff --git a/src/game-loop/pointer-input.ts b/src/game-loop/pointer-input.ts
index c04196b..bee83ef 100644
--- a/src/game-loop/pointer-input.ts
+++ b/src/game-loop/pointer-input.ts
@@ -1,9 +1,9 @@
import { vec2 } from 'gl-matrix';
import { GardenAudio } from '../audio/garden-audio';
+import { appConfig } from '../config';
import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline';
import { activeVibe } from '../settings';
-import { MIN_DELTA_TIME_SECONDS } from '../utils/delta-time-calculator';
import { BrushStrokeSmoother } from './brush-stroke-smoother';
import { type StrokeSegment } from './game-loop-types';
import { getMirroredStrokeSegments } from './stroke-mirroring';
@@ -155,7 +155,7 @@ export class GardenPointerInput {
const previousPosition = this.lastPointerPosition ?? position;
const previousTimeMs = this.lastPointerEventTimeMs ?? event.timeStamp;
const elapsedSeconds = Math.max(
- MIN_DELTA_TIME_SECONDS,
+ appConfig.deltaTime.minDeltaTimeSeconds,
(event.timeStamp - previousTimeMs) / 1000
);
diff --git a/src/game-loop/simulation-frame.ts b/src/game-loop/simulation-frame.ts
index 9064af2..36629e8 100644
--- a/src/game-loop/simulation-frame.ts
+++ b/src/game-loop/simulation-frame.ts
@@ -1,3 +1,4 @@
+import { appConfig } from '../config';
import { AgentPipeline } from '../pipelines/agents/agent-pipeline';
import { BrushPipeline } from '../pipelines/brush/brush-pipeline';
import { DiffusionPipeline } from '../pipelines/diffusion/diffusion-pipeline';
@@ -9,11 +10,6 @@ import { CanvasReadbackRequest } from './game-loop-types';
import { GpuProfiler } from './gpu-profiler';
import { SimulationTextures } from './simulation-textures';
-const BRUSH_EFFECT_FRAMES_PER_SECOND = 60;
-// How long the source map continues to be diffused after a brush stroke ends.
-// 600 frames at ~60 FPS is roughly 10 seconds.
-const SOURCE_ACTIVE_FRAMES_AFTER_WRITE = 600;
-
interface SimulationFramePipelines {
agentPipeline: AgentPipeline;
brushPipeline: BrushPipeline;
@@ -139,9 +135,10 @@ export class SimulationFrameRenderer {
}
const getSourceActiveFrameCount = (): number => {
- const frameCount = settings.brushEffectDuration * BRUSH_EFFECT_FRAMES_PER_SECOND;
+ const frameCount =
+ settings.brushEffectDuration * appConfig.simulation.brushEffectFramesPerSecond;
if (Number.isFinite(frameCount) && frameCount > 0) {
return Math.ceil(frameCount);
}
- return Math.max(1, SOURCE_ACTIVE_FRAMES_AFTER_WRITE);
+ return Math.max(1, appConfig.simulation.sourceActiveFramesAfterWrite);
};
diff --git a/src/game-loop/simulation-textures.ts b/src/game-loop/simulation-textures.ts
index bf62766..7166595 100644
--- a/src/game-loop/simulation-textures.ts
+++ b/src/game-loop/simulation-textures.ts
@@ -1,13 +1,12 @@
import { vec2 } from 'gl-matrix';
+import { appConfig } from '../config';
import { ERASER_MASK_TEXTURE_FORMAT } from '../pipelines/texture-formats';
import {
ResizableTexture,
type PendingTextureResize,
} from '../utils/graphics/resizable-texture';
-const SIMULATION_CLEAR_COLOR = { r: 0, g: 0, b: 0, a: 0 };
-
export class SimulationTextures {
// trailMapA holds the current trail (read by agent and diffuse). trailMapB
// receives the diffuse output; the two swap each frame so the freshly
@@ -84,7 +83,7 @@ export class SimulationTextures {
colorAttachments: [
{
view: texture.getTextureView(),
- clearValue: SIMULATION_CLEAR_COLOR,
+ clearValue: appConfig.simulation.clearColor,
loadOp: 'clear',
storeOp: 'store',
},
@@ -123,7 +122,7 @@ export class SimulationTextures {
colorAttachments: [
{
view: this.sourceMapA.getTextureView(),
- clearValue: SIMULATION_CLEAR_COLOR,
+ clearValue: appConfig.simulation.clearColor,
loadOp: 'clear',
storeOp: 'store',
},
diff --git a/src/game-loop/stroke-mirroring.ts b/src/game-loop/stroke-mirroring.ts
index ffa9412..9bfb8d4 100644
--- a/src/game-loop/stroke-mirroring.ts
+++ b/src/game-loop/stroke-mirroring.ts
@@ -2,9 +2,6 @@ import { vec2 } from 'gl-matrix';
import { type StrokeSegment } from './game-loop-types';
-export const MIN_MIRROR_SEGMENT_COUNT = 1;
-export const MAX_MIRROR_SEGMENT_COUNT = 12;
-
export const getMirroredStrokeSegments = (
from: vec2,
to: vec2,
diff --git a/src/game-loop/toolbar-contrast-monitor.ts b/src/game-loop/toolbar-contrast-monitor.ts
index df074d4..8898123 100644
--- a/src/game-loop/toolbar-contrast-monitor.ts
+++ b/src/game-loop/toolbar-contrast-monitor.ts
@@ -1,3 +1,4 @@
+import { appConfig } from '../config';
import { clamp01 } from '../utils/math';
import type { CanvasReadbackRequest } from './game-loop-types';
@@ -23,41 +24,21 @@ interface ToolbarContrastMetrics {
const TOOLBAR_BACKGROUND_OPACITY_PROPERTY = '--toolbar-background-opacity';
const TOOLBAR_BACKGROUND_STRENGTH_PROPERTY = '--toolbar-background-strength';
-const BACKGROUND_OPACITY_MAX = 0.82;
-const BRIGHT_LUMINANCE_THRESHOLD = 0.32;
-const BRIGHT_WEIGHT = 0.65;
-const BYTES_PER_SAMPLE = 4;
-const CONTRAST_OFFSET = 0.05;
const GPU_COPY_BYTES_PER_ROW_ALIGNMENT = 256;
-const LINEAR_CHANNEL_BREAKPOINT = 0.03928;
-const LINEAR_CHANNEL_DIVISOR = 12.92;
-const LINEAR_CHANNEL_GAMMA = 2.4;
-const LINEAR_CHANNEL_OFFSET = 0.055;
-const LINEAR_CHANNEL_SCALE = 1.055;
-const LOW_CONTRAST_THRESHOLD = 3;
-const LOW_CONTRAST_WEIGHT = 1.8;
-const LUMINANCE_BASE = 0.11;
-const LUMINANCE_BLUE_WEIGHT = 0.0722;
-const LUMINANCE_GREEN_WEIGHT = 0.7152;
-const LUMINANCE_RANGE = 0.28;
-const LUMINANCE_RED_WEIGHT = 0.2126;
-const SAMPLE_COLUMNS = 13;
-const SAMPLE_INTERVAL_MS = 300;
-const SAMPLE_ROWS = 7;
-const WHITE_CONTRAST_NUMERATOR = 1.05;
const getLinearChannel = (channel: number): number => {
const normalized = channel / 255;
- return normalized <= LINEAR_CHANNEL_BREAKPOINT
- ? normalized / LINEAR_CHANNEL_DIVISOR
- : ((normalized + LINEAR_CHANNEL_OFFSET) / LINEAR_CHANNEL_SCALE) **
- LINEAR_CHANNEL_GAMMA;
+ return normalized <= appConfig.toolbar.contrast.linearChannelBreakpoint
+ ? normalized / appConfig.toolbar.contrast.linearChannelDivisor
+ : ((normalized + appConfig.toolbar.contrast.linearChannelOffset) /
+ appConfig.toolbar.contrast.linearChannelScale) **
+ appConfig.toolbar.contrast.linearChannelGamma;
};
const getRelativeLuminance = (red: number, green: number, blue: number): number =>
- LUMINANCE_RED_WEIGHT * getLinearChannel(red) +
- LUMINANCE_GREEN_WEIGHT * getLinearChannel(green) +
- LUMINANCE_BLUE_WEIGHT * getLinearChannel(blue);
+ appConfig.toolbar.contrast.luminanceRedWeight * getLinearChannel(red) +
+ appConfig.toolbar.contrast.luminanceGreenWeight * getLinearChannel(green) +
+ appConfig.toolbar.contrast.luminanceBlueWeight * getLinearChannel(blue);
const getToolbarContrastMetrics = (
pixels: Uint8Array,
@@ -65,7 +46,8 @@ const getToolbarContrastMetrics = (
isBgra: boolean
): ToolbarContrastMetrics => {
const count = sampleOffsets.filter(
- (offset) => offset >= 0 && offset + BYTES_PER_SAMPLE <= pixels.length
+ (offset) =>
+ offset >= 0 && offset + appConfig.toolbar.contrast.bytesPerSample <= pixels.length
).length;
if (count === 0) {
return {
@@ -81,7 +63,10 @@ const getToolbarContrastMetrics = (
let lowContrastCount = 0;
sampleOffsets.forEach((offset) => {
- if (offset < 0 || offset + BYTES_PER_SAMPLE > pixels.length) {
+ if (
+ offset < 0 ||
+ offset + appConfig.toolbar.contrast.bytesPerSample > pixels.length
+ ) {
return;
}
@@ -89,13 +74,15 @@ const getToolbarContrastMetrics = (
const green = pixels[offset + 1];
const blue = pixels[offset + (isBgra ? 0 : 2)];
const luminance = getRelativeLuminance(red, green, blue);
- const contrastWithWhite = WHITE_CONTRAST_NUMERATOR / (luminance + CONTRAST_OFFSET);
+ const contrastWithWhite =
+ appConfig.toolbar.contrast.whiteContrastNumerator /
+ (luminance + appConfig.toolbar.contrast.contrastOffset);
luminanceTotal += luminance;
- if (luminance > BRIGHT_LUMINANCE_THRESHOLD) {
+ if (luminance > appConfig.toolbar.contrast.brightLuminanceThreshold) {
brightCount++;
}
- if (contrastWithWhite < LOW_CONTRAST_THRESHOLD) {
+ if (contrastWithWhite < appConfig.toolbar.contrast.lowContrastThreshold) {
lowContrastCount++;
}
});
@@ -104,11 +91,13 @@ const getToolbarContrastMetrics = (
const brightRatio = brightCount / count;
const lowContrastRatio = lowContrastCount / count;
const backgroundStrength = clamp01(
- Math.max(0, averageLuminance - LUMINANCE_BASE) / LUMINANCE_RANGE +
- brightRatio * BRIGHT_WEIGHT +
- lowContrastRatio * LOW_CONTRAST_WEIGHT
+ Math.max(0, averageLuminance - appConfig.toolbar.contrast.luminanceBase) /
+ appConfig.toolbar.contrast.luminanceRange +
+ brightRatio * appConfig.toolbar.contrast.brightWeight +
+ lowContrastRatio * appConfig.toolbar.contrast.lowContrastWeight
);
- const backgroundOpacity = backgroundStrength * BACKGROUND_OPACITY_MAX;
+ const backgroundOpacity =
+ backgroundStrength * appConfig.toolbar.contrast.backgroundOpacityMax;
return {
averageLuminance,
@@ -139,7 +128,7 @@ export class ToolbarContrastMonitor {
if (
this.isDestroyed ||
this.isReadbackPending ||
- time - this.lastSampleAt < SAMPLE_INTERVAL_MS
+ time - this.lastSampleAt < appConfig.toolbar.contrast.sampleIntervalMs
) {
return null;
}
@@ -222,12 +211,12 @@ export class ToolbarContrastMonitor {
private setToolbarBackgroundOpacity(backgroundOpacity: number): void {
const safeBackgroundOpacity = Math.min(
- BACKGROUND_OPACITY_MAX,
+ appConfig.toolbar.contrast.backgroundOpacityMax,
Math.max(0, backgroundOpacity)
);
const backgroundStrength =
- BACKGROUND_OPACITY_MAX > 0
- ? clamp01(safeBackgroundOpacity / BACKGROUND_OPACITY_MAX)
+ appConfig.toolbar.contrast.backgroundOpacityMax > 0
+ ? clamp01(safeBackgroundOpacity / appConfig.toolbar.contrast.backgroundOpacityMax)
: 0;
this.toolbar.style.setProperty(
@@ -290,20 +279,22 @@ export class ToolbarContrastMonitor {
}
const bytesPerRow = alignTo(
- width * BYTES_PER_SAMPLE,
+ width * appConfig.toolbar.contrast.bytesPerSample,
GPU_COPY_BYTES_PER_ROW_ALIGNMENT
);
const points = new Map();
- for (let row = 0; row < SAMPLE_ROWS; row++) {
- const cssY = top + ((row + 0.5) / SAMPLE_ROWS) * cssHeight;
+ for (let row = 0; row < appConfig.toolbar.contrast.sampleRows; row++) {
+ const cssY =
+ top + ((row + 0.5) / appConfig.toolbar.contrast.sampleRows) * cssHeight;
const y = Math.min(
this.canvas.height - 1,
Math.max(0, Math.floor((cssY - canvasRect.top) * yScale))
);
- for (let column = 0; column < SAMPLE_COLUMNS; column++) {
- const cssX = left + ((column + 0.5) / SAMPLE_COLUMNS) * cssWidth;
+ for (let column = 0; column < appConfig.toolbar.contrast.sampleColumns; column++) {
+ const cssX =
+ left + ((column + 0.5) / appConfig.toolbar.contrast.sampleColumns) * cssWidth;
const x = Math.min(
this.canvas.width - 1,
Math.max(0, Math.floor((cssX - canvasRect.left) * xScale))
@@ -318,7 +309,8 @@ export class ToolbarContrastMonitor {
origin,
sampleOffsets: [...points.values()].map(
(point) =>
- (point.y - origin.y) * bytesPerRow + (point.x - origin.x) * BYTES_PER_SAMPLE
+ (point.y - origin.y) * bytesPerRow +
+ (point.x - origin.x) * appConfig.toolbar.contrast.bytesPerSample
),
width,
};
diff --git a/src/index.ts b/src/index.ts
index 79b5251..74b98d7 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -71,10 +71,6 @@ const main = async () => {
HTMLButtonElement
);
const infoButton = queryRequiredElement('[data-control="info"]', HTMLButtonElement);
- const infoCloseButton = queryRequiredElement(
- '[data-control="info-close"]',
- HTMLButtonElement
- );
const infoElement = queryRequiredElement('.info-page', HTMLElement);
const fullScreenButton = queryRequiredElement(
'[data-control="full-screen"]',
@@ -117,7 +113,6 @@ const main = async () => {
};
const infoPageHandler = new CollapsiblePanelAnimator(infoButton, infoElement, aside);
- infoCloseButton.addEventListener('click', () => infoPageHandler.close());
new MenuHider(
aside,
() =>
diff --git a/src/page/audio-control.ts b/src/page/audio-control.ts
index 0918eda..7677bad 100644
--- a/src/page/audio-control.ts
+++ b/src/page/audio-control.ts
@@ -1,23 +1,19 @@
-import { DEFAULT_AUDIO_VOLUME, MAX_AUDIO_VOLUME } from '../audio/garden-audio-config';
+import { appConfig } from '../config';
import type GameLoop from '../game-loop/game-loop';
import { readBrowserStorage, writeBrowserStorage } from '../utils/browser-storage';
import { queryRequiredElement } from '../utils/dom';
-
-const AUDIO_MUTED_STORAGE_KEY = 'fleeting-garden:audio-muted';
-const AUDIO_VOLUME_STORAGE_KEY = 'fleeting-garden:audio-volume';
-const AUDIO_VOLUME_MIN = 0;
-const AUDIO_VOLUME_MAX = MAX_AUDIO_VOLUME;
-const AUDIO_VOLUME_STEP = 0.01;
+import { clamp01 } from '../utils/math';
const clampAudioVolume = (value: number): number => {
- const safeValue = Number.isFinite(value) ? value : DEFAULT_AUDIO_VOLUME;
- return Math.min(AUDIO_VOLUME_MAX, Math.max(AUDIO_VOLUME_MIN, safeValue));
+ const { default: defaultVolume, max, min } = appConfig.toolbar.volume;
+ const safeValue = Number.isFinite(value) ? value : defaultVolume;
+ return Math.min(max, Math.max(min, clamp01(safeValue)));
};
const readInitialAudioVolume = (): number => {
- const storedVolume = readBrowserStorage(AUDIO_VOLUME_STORAGE_KEY);
+ const storedVolume = readBrowserStorage(appConfig.storage.audioVolumeKey);
return storedVolume === null
- ? DEFAULT_AUDIO_VOLUME
+ ? appConfig.toolbar.volume.default
: clampAudioVolume(Number(storedVolume));
};
@@ -49,7 +45,7 @@ export class AudioControl {
private audioVolume = readInitialAudioVolume();
private isMutedState =
- readBrowserStorage(AUDIO_MUTED_STORAGE_KEY) === STORED_MUTED_TRUE ||
+ readBrowserStorage(appConfig.storage.audioMutedKey) === STORED_MUTED_TRUE ||
this.audioVolume <= 0;
public constructor(private readonly options: AudioControlOptions) {
@@ -82,7 +78,6 @@ export class AudioControl {
this.audioVolume = clampAudioVolume(this.audioVolume);
const isEffectivelyMuted = this.isMuted;
const volumePercent = Math.round(this.audioVolume * 100);
- const volumeProgressPercent = Math.round((this.audioVolume / AUDIO_VOLUME_MAX) * 100);
this.soundButton.classList.toggle('muted', isEffectivelyMuted);
this.soundButton.setAttribute('aria-pressed', String(isEffectivelyMuted));
@@ -90,9 +85,9 @@ export class AudioControl {
this.soundButton.setAttribute('aria-label', muteLabel);
this.soundButton.title = muteLabel;
- this.volumeSlider.min = AUDIO_VOLUME_MIN.toString();
- this.volumeSlider.max = AUDIO_VOLUME_MAX.toString();
- this.volumeSlider.step = AUDIO_VOLUME_STEP.toString();
+ this.volumeSlider.min = appConfig.toolbar.volume.min.toString();
+ this.volumeSlider.max = appConfig.toolbar.volume.max.toString();
+ this.volumeSlider.step = appConfig.toolbar.volume.step.toString();
this.volumeSlider.value = formatStoredAudioVolume(this.audioVolume);
this.volumeSlider.setAttribute(
'aria-valuetext',
@@ -102,10 +97,7 @@ export class AudioControl {
this.volumeControl.title = isEffectivelyMuted
? `Muted, ${volumePercent}% volume`
: `${volumePercent}% volume`;
- this.volumeControl.style.setProperty(
- '--volume-progress',
- `${volumeProgressPercent}%`
- );
+ this.volumeControl.style.setProperty('--volume-progress', `${volumePercent}%`);
const game = this.options.getGame();
game?.setAudioVolume(this.audioVolume);
@@ -115,7 +107,7 @@ export class AudioControl {
private readonly onToggleMute = () => {
const shouldUnmute = this.isMutedState || this.audioVolume <= 0;
if (shouldUnmute && this.audioVolume <= 0) {
- this.audioVolume = DEFAULT_AUDIO_VOLUME;
+ this.audioVolume = appConfig.toolbar.volume.default;
}
this.isMutedState = !shouldUnmute;
this.persist();
@@ -149,11 +141,11 @@ export class AudioControl {
private persist(): void {
writeBrowserStorage(
- AUDIO_MUTED_STORAGE_KEY,
+ appConfig.storage.audioMutedKey,
this.isMutedState ? STORED_MUTED_TRUE : STORED_MUTED_FALSE
);
writeBrowserStorage(
- AUDIO_VOLUME_STORAGE_KEY,
+ appConfig.storage.audioVolumeKey,
formatStoredAudioVolume(this.audioVolume)
);
}
diff --git a/src/page/color-reaction-matrix-control.ts b/src/page/color-reaction-matrix-control.ts
index 756fd63..ca78cbb 100644
--- a/src/page/color-reaction-matrix-control.ts
+++ b/src/page/color-reaction-matrix-control.ts
@@ -1,6 +1,6 @@
import type { FolderApi } from '@tweakpane/core';
-import { normalizeNumberControlValue, runtimeControls } from '../config';
+import { appConfig, normalizeNumberControlValue } from '../config';
import { activeVibe, settings } from '../settings';
import { rgbColorToCss } from '../utils/rgb-color';
@@ -128,7 +128,7 @@ export class ColorReactionMatrixControl {
const cell = document.createElement('div');
cell.className = 'color-reaction-matrix__cell';
- const config = runtimeControls[key];
+ const config = appConfig.runtimeSettings.controls[key];
if (!config) {
return cell;
}
@@ -165,7 +165,7 @@ export class ColorReactionMatrixControl {
sourceColorIndex: number,
targetColorIndex: number
): void {
- const config = runtimeControls[key];
+ const config = appConfig.runtimeSettings.controls[key];
if (!config) {
return;
}
diff --git a/src/page/config-pane.ts b/src/page/config-pane.ts
index bdae8f7..8bb7f28 100644
--- a/src/page/config-pane.ts
+++ b/src/page/config-pane.ts
@@ -3,12 +3,11 @@ import { Pane } from 'tweakpane';
import type { GardenAudioVibeSettings } from '../audio/garden-audio-config';
import {
+ appConfig,
normalizeNumberControlValue,
- runtimeControls,
type GardenRuntimeSettings,
type NumberControlConfig,
} from '../config';
-import { perfStatsOverlayState } from '../game-loop/perf-stats-overlay';
import { activeVibe, settings } from '../settings';
import { hexColorToRgbColor, rgbColorToHex, type RgbColor } from '../utils/rgb-color';
import { ColorReactionMatrixControl } from './color-reaction-matrix-control';
@@ -30,8 +29,6 @@ interface PaneState extends GardenAudioVibeSettings {
}
const runtimeFolderOrder = ['Brush', 'Movement', 'Look', 'Performance'] as const;
-const CONFIG_PANE_TITLE = 'Garden Settings';
-const CONFIG_PANE_START_HIDDEN = true;
const MUSIC_CONTROLS: ReadonlyArray<{
key: VibeNumberKey;
@@ -59,7 +56,7 @@ interface ConfigPaneOptions {
const getRuntimeControlKeys = (folder: string): Array =>
(
- Object.entries(runtimeControls) as Array<
+ Object.entries(appConfig.runtimeSettings.controls) as Array<
[RuntimeControlKey, NumberControlConfig | undefined]
>
)
@@ -126,10 +123,10 @@ export class ConfigPane {
this.pane = new Pane({
container: this.container,
- title: CONFIG_PANE_TITLE,
+ title: appConfig.tuningPane.title,
expanded: true,
});
- this.pane.hidden = CONFIG_PANE_START_HIDDEN;
+ this.pane.hidden = appConfig.tuningPane.startHidden;
this.pane.element.classList.add('config-pane');
this.pane.element.id = 'config-pane';
@@ -313,7 +310,7 @@ export class ConfigPane {
private getRuntimeControlConfig(
key: RuntimeControlKey
): NumberControlConfig | undefined {
- const config = runtimeControls[key];
+ const config = appConfig.runtimeSettings.controls[key];
if (!config || key !== 'maxAgentCount') {
return config;
}
@@ -326,7 +323,7 @@ export class ConfigPane {
private addFpsOverlayBinding(container: PaneContainer): void {
container
- .addBinding(perfStatsOverlayState, 'isVisible', {
+ .addBinding(appConfig.tuningPane, 'showFpsOverlay', {
label: 'Show FPS',
})
.on('change', () => this.options.onConfigChange());
diff --git a/src/page/eraser-size-control.test.ts b/src/page/eraser-size-control.test.ts
index 372c63e..6b2bbe8 100644
--- a/src/page/eraser-size-control.test.ts
+++ b/src/page/eraser-size-control.test.ts
@@ -1,39 +1,26 @@
import { describe, expect, it } from 'vitest';
+import { appConfig } from '../config';
import {
- ERASER_SIZE_MAX,
- ERASER_SIZE_MIN,
- getEffectiveEraserSize,
getEraserSizeFromSliderRatio,
- getEraserSizeMaxForCssSize,
getEraserSliderRatioFromSize,
-} from '../config/eraser-size';
+} from './eraser-size-control';
describe('eraser size slider mapping', () => {
it('maps slider position quadratically to eraser size', () => {
- expect(getEraserSizeFromSliderRatio(0)).toBe(ERASER_SIZE_MIN);
- expect(getEraserSizeFromSliderRatio(0.5)).toBe(
- ERASER_SIZE_MIN + (ERASER_SIZE_MAX - ERASER_SIZE_MIN) * 0.25
- );
- expect(getEraserSizeFromSliderRatio(1)).toBe(ERASER_SIZE_MAX);
+ const { max, min } = appConfig.toolbar.eraser;
+
+ expect(getEraserSizeFromSliderRatio(0)).toBe(min);
+ expect(getEraserSizeFromSliderRatio(0.5)).toBe(min + (max - min) * 0.25);
+ expect(getEraserSizeFromSliderRatio(1)).toBe(max);
});
it('maps eraser size back to the inverse slider position', () => {
- const quarterRangeSize = ERASER_SIZE_MIN + (ERASER_SIZE_MAX - ERASER_SIZE_MIN) * 0.25;
+ const { max, min } = appConfig.toolbar.eraser;
+ const quarterRangeSize = min + (max - min) * 0.25;
- expect(getEraserSliderRatioFromSize(ERASER_SIZE_MIN)).toBe(0);
+ expect(getEraserSliderRatioFromSize(min)).toBe(0);
expect(getEraserSliderRatioFromSize(quarterRangeSize)).toBe(0.5);
- expect(getEraserSliderRatioFromSize(ERASER_SIZE_MAX)).toBe(1);
- });
-
- it('uses a responsive max size on small canvases', () => {
- const mobileMax = getEraserSizeMaxForCssSize({ height: 640, width: 390 });
-
- expect(mobileMax).toBeLessThan(ERASER_SIZE_MAX);
- expect(getEraserSizeFromSliderRatio(1, mobileMax)).toBe(mobileMax);
- expect(getEraserSliderRatioFromSize(ERASER_SIZE_MAX, mobileMax)).toBe(1);
- expect(getEffectiveEraserSize(ERASER_SIZE_MAX, { height: 640, width: 390 })).toBe(
- mobileMax
- );
+ expect(getEraserSliderRatioFromSize(max)).toBe(1);
});
});
diff --git a/src/page/eraser-size-control.ts b/src/page/eraser-size-control.ts
index beb2d0f..2c0e8a8 100644
--- a/src/page/eraser-size-control.ts
+++ b/src/page/eraser-size-control.ts
@@ -1,25 +1,35 @@
-import {
- clampEraserSize,
- ERASER_SIZE_MAX,
- getElementCssPixelSize,
- getEraserSizeFromSliderRatio,
- getEraserSizeMaxForCssSize,
- getEraserSizeRatio,
- getEraserSliderRatioFromSize,
-} from '../config/eraser-size';
+import { appConfig } from '../config';
import type GameLoop from '../game-loop/game-loop';
-import { DEFAULT_ERASER_SIZE, settings } from '../settings';
+import { settings } from '../settings';
import { queryRequiredElement } from '../utils/dom';
-const ERASER_CONTROL_SCALE_MIN = 0.74;
-const ERASER_CONTROL_SCALE_MAX = 1.34;
+const clampEraserSize = (value: number): number => {
+ const { default: defaultSize, max, min } = appConfig.toolbar.eraser;
+ const safeValue = Number.isFinite(value) ? value : defaultSize;
+ return Math.min(max, Math.max(min, Math.round(safeValue)));
+};
const ERASER_SLIDER_MIN = 0;
const ERASER_SLIDER_MAX = 1;
const ERASER_SLIDER_STEP = 0.001;
-const clampStoredEraserSize = (value: number): number =>
- clampEraserSize(value, ERASER_SIZE_MAX, DEFAULT_ERASER_SIZE);
+const clampSliderRatio = (value: number): number => {
+ const safeValue = Number.isFinite(value) ? value : ERASER_SLIDER_MIN;
+ return Math.min(ERASER_SLIDER_MAX, Math.max(ERASER_SLIDER_MIN, safeValue));
+};
+
+const getEraserSizeRatio = (size: number): number => {
+ const { max, min } = appConfig.toolbar.eraser;
+ return (clampEraserSize(size) - min) / (max - min);
+};
+
+export const getEraserSizeFromSliderRatio = (sliderRatio: number): number => {
+ const { max, min } = appConfig.toolbar.eraser;
+ return clampEraserSize(min + (max - min) * clampSliderRatio(sliderRatio) ** 2);
+};
+
+export const getEraserSliderRatioFromSize = (size: number): number =>
+ Math.sqrt(getEraserSizeRatio(size));
interface EraserSizeControlOptions {
getGame: () => GameLoop | null;
@@ -33,7 +43,6 @@ export class EraserSizeControl {
HTMLLabelElement
);
private readonly slider = queryRequiredElement('.eraser-size-slider', HTMLInputElement);
- private readonly canvas = queryRequiredElement('canvas', HTMLCanvasElement);
private isActive = false;
public constructor(private readonly options: EraserSizeControlOptions) {
@@ -41,10 +50,7 @@ export class EraserSizeControl {
this.control.addEventListener('click', this.activate);
this.slider.addEventListener('focus', this.activate);
this.slider.addEventListener('input', () => {
- settings.eraserSize = getEraserSizeFromSliderRatio(
- Number(this.slider.value),
- this.getResponsiveMaxSize()
- );
+ settings.eraserSize = getEraserSizeFromSliderRatio(Number(this.slider.value));
this.activate();
this.render();
this.options.onChange();
@@ -52,24 +58,24 @@ export class EraserSizeControl {
}
public render(): void {
- const maxSize = this.getResponsiveMaxSize();
- const storedSize = clampStoredEraserSize(settings.eraserSize);
- if (settings.eraserSize !== storedSize) {
- settings.eraserSize = storedSize;
+ const size = clampEraserSize(settings.eraserSize);
+ if (settings.eraserSize !== size) {
+ settings.eraserSize = size;
}
- const size = clampEraserSize(storedSize, maxSize, DEFAULT_ERASER_SIZE);
- const sliderRatio = getEraserSliderRatioFromSize(size, maxSize);
+ const sliderRatio = getEraserSliderRatioFromSize(size);
this.slider.min = ERASER_SLIDER_MIN.toString();
this.slider.max = ERASER_SLIDER_MAX.toString();
this.slider.step = ERASER_SLIDER_STEP.toString();
this.slider.value = sliderRatio.toString();
this.slider.setAttribute('aria-valuetext', `${size}px`);
- const sizeRatio = getEraserSizeRatio(size, maxSize);
+ const sizeRatio = getEraserSizeRatio(size);
const scale =
- ERASER_CONTROL_SCALE_MIN +
- (ERASER_CONTROL_SCALE_MAX - ERASER_CONTROL_SCALE_MIN) * sizeRatio;
+ appConfig.toolbar.eraser.controlScaleMin +
+ (appConfig.toolbar.eraser.controlScaleMax -
+ appConfig.toolbar.eraser.controlScaleMin) *
+ sizeRatio;
this.control.style.setProperty('--eraser-progress', `${sliderRatio * 100}%`);
this.control.style.setProperty('--eraser-control-scale', scale.toFixed(3));
this.syncActiveState();
@@ -86,10 +92,6 @@ export class EraserSizeControl {
this.options.onActivate();
};
- private getResponsiveMaxSize(): number {
- return getEraserSizeMaxForCssSize(getElementCssPixelSize(this.canvas));
- }
-
private syncActiveState(): void {
this.control.classList.toggle('active', this.isActive);
this.slider.setAttribute(
diff --git a/src/page/menu-hider.ts b/src/page/menu-hider.ts
index 4f371c6..ed8aa59 100644
--- a/src/page/menu-hider.ts
+++ b/src/page/menu-hider.ts
@@ -1,10 +1,9 @@
-const DESKTOP_AUTO_HIDE_MEDIA_QUERY =
- '(min-width: 600px) and (hover: hover) and (pointer: fine)';
-const HIDE_DELAY_MS = 3000;
-const BOTTOM_REVEAL_DISTANCE_PX = 96;
+import { appConfig } from '../config';
export class MenuHider {
- private readonly desktopMediaQuery = window.matchMedia(DESKTOP_AUTO_HIDE_MEDIA_QUERY);
+ private readonly desktopMediaQuery = window.matchMedia(
+ appConfig.menuHider.desktopMediaQuery
+ );
private hideTimeout: number | undefined;
private isHidden = false;
private pointerInside = false;
@@ -96,7 +95,7 @@ export class MenuHider {
if (this.canAutoHide) {
this.hide();
}
- }, HIDE_DELAY_MS);
+ }, appConfig.menuHider.hideDelayMs);
}
private reveal(): void {
@@ -135,6 +134,6 @@ export class MenuHider {
private isNearViewportBottom(clientY: number): boolean {
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
- return clientY >= viewportHeight - BOTTOM_REVEAL_DISTANCE_PX;
+ return clientY >= viewportHeight - appConfig.menuHider.bottomRevealDistancePx;
}
}
diff --git a/src/page/mirror-segment-control.ts b/src/page/mirror-segment-control.ts
index f058429..1ea8ddc 100644
--- a/src/page/mirror-segment-control.ts
+++ b/src/page/mirror-segment-control.ts
@@ -1,48 +1,25 @@
-import {
- MAX_MIRROR_SEGMENT_COUNT,
- MIN_MIRROR_SEGMENT_COUNT,
-} from '../game-loop/stroke-mirroring';
-import { DEFAULT_MIRROR_SEGMENT_COUNT, settings } from '../settings';
+import { appConfig } from '../config';
+import { settings } from '../settings';
import { queryRequiredElement } from '../utils/dom';
-const MIRROR_SEGMENT_STEP = 1;
-const MIRROR_SEGMENT_OFF_LABEL = 'Mirror off';
-const MIRROR_SEGMENT_FALLBACK_NAME = 'slices';
-const MIRROR_SEGMENT_NAMES = {
- 2: 'halves',
- 3: 'thirds',
- 4: 'quarters',
- 5: 'fifths',
- 6: 'sixths',
- 7: 'sevenths',
- 8: 'eighths',
- 9: 'ninths',
- 10: 'tenths',
- 11: 'elevenths',
- 12: 'twelfths',
-} satisfies Record;
-
const clampMirrorSegmentCount = (value: number): number => {
- const safeValue = Number.isFinite(value) ? value : DEFAULT_MIRROR_SEGMENT_COUNT;
- return Math.min(
- MAX_MIRROR_SEGMENT_COUNT,
- Math.max(MIN_MIRROR_SEGMENT_COUNT, Math.round(safeValue))
- );
+ const { default: defaultCount, max, min } = appConfig.toolbar.mirror;
+ const safeValue = Number.isFinite(value) ? value : defaultCount;
+ return Math.min(max, Math.max(min, Math.round(safeValue)));
};
const getMirrorSegmentRatio = (count: number): number => {
- return (
- (count - MIN_MIRROR_SEGMENT_COUNT) /
- (MAX_MIRROR_SEGMENT_COUNT - MIN_MIRROR_SEGMENT_COUNT)
- );
+ const { max, min } = appConfig.toolbar.mirror;
+ return (count - min) / (max - min);
};
const formatMirrorSegmentCount = (count: number): string =>
count <= 1
- ? MIRROR_SEGMENT_OFF_LABEL
+ ? appConfig.toolbar.mirror.offLabel
: `${count} ${
- MIRROR_SEGMENT_NAMES[count as keyof typeof MIRROR_SEGMENT_NAMES] ??
- MIRROR_SEGMENT_FALLBACK_NAME
+ appConfig.toolbar.mirror.names[
+ count as keyof typeof appConfig.toolbar.mirror.names
+ ] ?? appConfig.toolbar.mirror.fallbackSegmentName
}`;
interface MirrorSegmentControlOptions {
@@ -73,9 +50,9 @@ export class MirrorSegmentControl {
settings.mirrorSegmentCount = count;
}
- this.slider.min = MIN_MIRROR_SEGMENT_COUNT.toString();
- this.slider.max = MAX_MIRROR_SEGMENT_COUNT.toString();
- this.slider.step = MIRROR_SEGMENT_STEP.toString();
+ this.slider.min = appConfig.toolbar.mirror.min.toString();
+ this.slider.max = appConfig.toolbar.mirror.max.toString();
+ this.slider.step = appConfig.toolbar.mirror.step.toString();
this.slider.value = count.toString();
const label = formatMirrorSegmentCount(count);
diff --git a/src/pipelines/brush/brush-pipeline.ts b/src/pipelines/brush/brush-pipeline.ts
index 4897020..b759c4f 100644
--- a/src/pipelines/brush/brush-pipeline.ts
+++ b/src/pipelines/brush/brush-pipeline.ts
@@ -1,5 +1,6 @@
import { vec2 } from 'gl-matrix';
+import { appConfig } from '../../config';
import { getRenderQualityBrushSize } from '../../config/brush-size';
import {
createCachedBufferWrite,
@@ -39,7 +40,6 @@ export const getSafePixelRatio = (pixelRatio: number | undefined): number =>
: 1;
const UNIFORM_COUNT = 16;
-const MAX_BRUSH_LINE_COUNT = 240;
const setBrushUniformValues = (
target: Float32Array,
@@ -93,7 +93,7 @@ export class BrushPipeline {
private readonly device: GPUDevice,
private readonly commonState: CommonState
) {
- this.segments = new LineSegmentBuffer(device, MAX_BRUSH_LINE_COUNT);
+ this.segments = new LineSegmentBuffer(device, appConfig.pipelines.brush.maxLineCount);
this.bindGroupLayout = device.createBindGroupLayout({
entries: [
diff --git a/src/pipelines/common-state/common-state.ts b/src/pipelines/common-state/common-state.ts
index deeaa8a..5157459 100644
--- a/src/pipelines/common-state/common-state.ts
+++ b/src/pipelines/common-state/common-state.ts
@@ -1,10 +1,11 @@
import { vec2 } from 'gl-matrix';
+import { appConfig } from '../../config';
import {
createCachedBufferWrite,
writeBufferIfChanged,
} from '../../utils/graphics/cached-buffer-write';
-import { generateNoise, NOISE_TEXTURE_SIZE } from '../../utils/graphics/noise';
+import { generateNoise } from '../../utils/graphics/noise';
export class CommonState {
private static readonly UNIFORM_COUNT = 4;
@@ -38,8 +39,8 @@ export class CommonState {
const noise = generateNoise({
device,
- width: NOISE_TEXTURE_SIZE,
- height: NOISE_TEXTURE_SIZE,
+ width: appConfig.pipelines.common.noiseTextureSize,
+ height: appConfig.pipelines.common.noiseTextureSize,
});
this.noise = noise.texture;
diff --git a/src/pipelines/diffusion/diffusion-pipeline.ts b/src/pipelines/diffusion/diffusion-pipeline.ts
index ef9bfef..b1c763d 100644
--- a/src/pipelines/diffusion/diffusion-pipeline.ts
+++ b/src/pipelines/diffusion/diffusion-pipeline.ts
@@ -1,5 +1,6 @@
import { vec2 } from 'gl-matrix';
+import { appConfig } from '../../config';
import { createBindGroupCache } from '../../utils/graphics/bind-group-cache';
import {
createCachedBufferWrite,
@@ -28,13 +29,12 @@ type DiffusionUniformSettings = Pick<
| 'brushDecayAlphaOffset'
>;
-const MIN_DIFFUSION_RATE = 0.000001;
-
const getSafeInverseDiffusionRate = (diffusionRate: number): number =>
1 /
- (Number.isFinite(diffusionRate) && diffusionRate > MIN_DIFFUSION_RATE
+ (Number.isFinite(diffusionRate) &&
+ diffusionRate > appConfig.pipelines.diffusion.minDiffusionRate
? diffusionRate
- : MIN_DIFFUSION_RATE);
+ : appConfig.pipelines.diffusion.minDiffusionRate);
const setDiffusionUniformValues = (
target: Float32Array,
diff --git a/src/pipelines/eraser/eraser-texture-pipeline.ts b/src/pipelines/eraser/eraser-texture-pipeline.ts
index cf3e84a..694777f 100644
--- a/src/pipelines/eraser/eraser-texture-pipeline.ts
+++ b/src/pipelines/eraser/eraser-texture-pipeline.ts
@@ -1,5 +1,6 @@
import { vec2 } from 'gl-matrix';
+import { appConfig } from '../../config';
import {
createCachedBufferWrite,
writeBufferIfChanged,
@@ -28,7 +29,6 @@ interface EraserTextureParameters {
}
const UNIFORM_COUNT = 8;
-const MAX_ERASER_TEXTURE_LINE_COUNT = 384;
const TARGET_FORMATS: Array = [
ERASER_MASK_TEXTURE_FORMAT,
TRAIL_SOURCE_TEXTURE_FORMAT,
@@ -50,7 +50,10 @@ export class EraserTexturePipeline {
private readonly device: GPUDevice,
private readonly commonState: CommonState
) {
- this.segments = new LineSegmentBuffer(device, MAX_ERASER_TEXTURE_LINE_COUNT);
+ this.segments = new LineSegmentBuffer(
+ device,
+ appConfig.pipelines.eraser.maxTextureLineCount
+ );
this.bindGroupLayout = device.createBindGroupLayout({
entries: [
diff --git a/src/settings.ts b/src/settings.ts
index bceadd9..c91ac83 100644
--- a/src/settings.ts
+++ b/src/settings.ts
@@ -1,14 +1,10 @@
import {
- defaultSettings,
+ appConfig,
normalizeRuntimeSettings,
- runtimeControls,
type GardenRuntimeSettings,
} from './config';
import { writeBrowserStorage } from './utils/browser-storage';
-import { getInitialVibe, VIBE_STORAGE_KEY, type VibePreset } from './vibes';
-
-export const DEFAULT_ERASER_SIZE = 96;
-export const DEFAULT_MIRROR_SEGMENT_COUNT = 8;
+import { getInitialVibe, type VibePreset } from './vibes';
const preservedRuntimeSettingKeys = [
'eraserSize',
@@ -41,12 +37,12 @@ const cloneVibePreset = (vibe: VibePreset): VibePreset => ({
const buildSettings = (vibe: VibePreset): GardenRuntimeSettings =>
normalizeRuntimeSettings(
{
- ...defaultSettings,
- eraserSize: DEFAULT_ERASER_SIZE,
- mirrorSegmentCount: DEFAULT_MIRROR_SEGMENT_COUNT,
+ ...appConfig.defaultSettings,
+ eraserSize: appConfig.toolbar.eraser.default,
+ mirrorSegmentCount: appConfig.toolbar.mirror.default,
...vibe.settings,
},
- runtimeControls
+ appConfig.runtimeSettings.controls
);
export let activeVibe = cloneVibePreset(getInitialVibe());
@@ -56,7 +52,7 @@ export const settings: GardenRuntimeSettings = {
};
export const rememberActiveVibeSelection = (): void => {
- writeBrowserStorage(VIBE_STORAGE_KEY, activeVibe.id);
+ writeBrowserStorage(appConfig.storage.vibeKey, activeVibe.id);
};
export const applyVibeSettings = (vibe: VibePreset) => {
@@ -70,7 +66,10 @@ export const applyVibeSettings = (vibe: VibePreset) => {
activeVibe.colors.length - 1
);
- Object.assign(settings, normalizeRuntimeSettings(nextSettings, runtimeControls));
+ Object.assign(
+ settings,
+ normalizeRuntimeSettings(nextSettings, appConfig.runtimeSettings.controls)
+ );
rememberActiveVibeSelection();
diff --git a/src/style/_loading.scss b/src/style/_loading.scss
index d9a519b..925f915 100644
--- a/src/style/_loading.scss
+++ b/src/style/_loading.scss
@@ -52,7 +52,7 @@
> .splash-description {
margin: 0;
- max-width: 70ch;
+ max-width: 28ch;
color: rgb(255 255 255 / 80%);
font-size: 15px;
font-weight: 400;
diff --git a/src/style/_panels.scss b/src/style/_panels.scss
index 029a044..28717bb 100644
--- a/src/style/_panels.scss
+++ b/src/style/_panels.scss
@@ -1,182 +1,76 @@
@use 'mixins' as *;
html > body > aside.control-dock > .info-page {
- width: min(100%, 520px);
- max-height: 200vh;
- max-height: 200dvh;
+ width: min(calc(100vw - 1rem), 560px);
+ max-height: min(58vh, 520px);
+ max-height: min(58dvh, 520px);
margin: 0 auto 10px;
- overflow: hidden;
- border: 1px solid rgb(255 255 255 / 46%);
+ overflow-x: hidden;
+ overflow-y: auto;
+ border: 1px solid rgb(255 255 255 / 78%);
border-radius: 8px;
background:
- linear-gradient(180deg, rgb(252 255 249 / 94%), rgb(234 241 232 / 91%)),
- rgb(249 252 247 / 92%);
- color: rgb(18 28 24);
+ linear-gradient(180deg, rgb(255 255 255 / 97%), rgb(243 247 239 / 96%)),
+ rgb(255 255 255);
+ color: rgb(24 30 27);
box-shadow:
- inset 0 1px 0 rgb(255 255 255 / 58%),
- 0 16px 42px rgb(0 0 0 / 30%),
- 0 2px 10px rgb(0 0 0 / 18%);
- backdrop-filter: blur(16px) saturate(118%);
+ 0 20px 54px rgb(0 0 0 / 38%),
+ 0 2px 12px rgb(0 0 0 / 22%);
+ backdrop-filter: blur(12px);
+ scrollbar-width: thin;
+ scrollbar-color: var(--main-color) transparent;
transition:
max-height var(--transition-time-long),
opacity var(--transition-time-long),
transform var(--transition-time-long),
margin-bottom var(--transition-time-long);
- &:focus-visible {
- outline: 2px solid rgb(17 56 45);
- outline-offset: 3px;
- box-shadow:
- 0 0 0 5px rgb(255 255 255 / 68%),
- inset 0 1px 0 rgb(255 255 255 / 58%),
- 0 16px 42px rgb(0 0 0 / 30%),
- 0 2px 10px rgb(0 0 0 / 18%);
+ &::-webkit-scrollbar-track,
+ &::-webkit-scrollbar {
+ background-color: transparent;
+ width: 6px;
}
- .info-page__content {
+ &::-webkit-scrollbar-thumb {
+ background-color: var(--main-color);
+ border-radius: 8px;
+ }
+
+ &:focus-visible {
+ outline: 2px solid white;
+ outline-offset: 3px;
+ }
+
+ > section {
display: flex;
flex-direction: column;
gap: 0.85rem;
- padding: 18px 20px 16px;
- }
+ padding: var(--normal-margin);
- .info-page__header {
- display: flex;
- align-items: center;
- gap: 0.7rem;
- padding-bottom: 0.1rem;
- }
-
- .info-page__heading {
- min-width: 0;
- }
-
- .info-page__mark {
- display: grid;
- flex: 0 0 auto;
- place-items: center;
- width: 1.8rem;
- height: 1.8rem;
- border: 1px solid rgb(42 74 65 / 24%);
- border-radius: 8px;
- background:
- linear-gradient(180deg, rgb(255 255 255 / 74%), rgb(217 231 222 / 70%)),
- rgb(232 240 235);
- box-shadow:
- inset 0 1px 0 rgb(255 255 255 / 76%),
- 0 5px 14px rgb(21 44 39 / 12%);
-
- &::before {
- content: '';
- width: 1rem;
- height: 1rem;
- background: rgb(19 57 48);
- mask: url('../../assets/icons/info.svg') center / contain no-repeat;
- }
- }
-
- .info-page__close {
- position: relative;
- flex: 0 0 auto;
- width: 2rem;
- height: 2rem;
- margin-left: auto;
- border: 1px solid rgb(34 57 50 / 18%);
- border-radius: 8px;
- background: rgb(255 255 255 / 36%);
- cursor: pointer;
- transition:
- background-color var(--transition-time),
- border-color var(--transition-time),
- transform var(--transition-time);
-
- &::before {
- content: '';
- position: absolute;
- inset: 0;
- width: 0.9rem;
- height: 0.9rem;
- margin: auto;
- background: rgb(28 45 39);
- mask: url('../../assets/icons/close.svg') center / contain no-repeat;
+ h1 {
+ margin-bottom: 0;
+ color: rgb(16 24 20);
+ font-size: 2rem;
+ line-height: 1.1;
}
- &:hover {
- border-color: rgb(34 57 50 / 30%);
- background: rgb(255 255 255 / 64%);
- transform: translateY(-1px);
+ p {
+ max-width: 54ch;
+ margin-bottom: 0;
+ color: rgb(42 48 45);
+ font-size: 1.1rem;
+ line-height: 1.65;
+ hyphens: auto;
}
- &:focus-visible {
- outline: 2px solid rgb(17 56 45);
- outline-offset: 2px;
- box-shadow: 0 0 0 4px rgb(255 255 255 / 72%);
- }
- }
+ a {
+ color: rgb(0 84 120);
+ font-weight: 400;
- .info-page__eyebrow {
- margin-bottom: 0.12rem;
- color: rgb(73 91 85);
- font-size: 0.68rem;
- font-weight: 700;
- line-height: 1.2;
- letter-spacing: 0;
- text-transform: uppercase;
- }
-
- h2 {
- overflow-wrap: break-word;
- margin-bottom: 0;
- color: rgb(9 21 17);
- font-size: 1.12rem;
- font-weight: 700;
- line-height: 1.18;
- }
-
- .info-page__main,
- .info-page__notes {
- max-width: 56ch;
- overflow-wrap: break-word;
- color: rgb(25 35 32);
- font-size: 0.95rem;
- line-height: 1.56;
- }
-
- .info-page__main {
- margin-bottom: 0;
- hyphens: auto;
- }
-
- .info-page__notes {
- display: grid;
- gap: 0.45rem;
- margin: 0.1rem 0;
- padding-left: 1.1rem;
- list-style: disc;
-
- li {
- padding-left: 0.1rem;
- }
-
- li::marker {
- color: rgb(25 108 82);
- font-size: 0.85em;
- }
- }
-
- a {
- color: rgb(0 83 105);
- font-weight: 700;
- text-decoration-color: rgb(0 83 105 / 34%);
- text-underline-offset: 0.18em;
-
- &:hover {
- text-decoration-color: currentColor;
- }
-
- &:focus-visible {
- outline: 2px solid currentColor;
- outline-offset: 3px;
+ &:focus-visible {
+ outline: 2px solid currentColor;
+ outline-offset: 3px;
+ }
}
}
@@ -187,22 +81,16 @@ html > body > aside.control-dock > .info-page {
opacity: 0;
pointer-events: none;
box-shadow: none;
- transform: translateY(6px) scale(0.985);
+ transform: translateY(8px);
visibility: hidden;
}
@include on-small-screen {
- width: min(100%, 520px);
+ max-height: min(54vh, 500px);
+ max-height: min(54dvh, 500px);
- .info-page__content {
- gap: 0.75rem;
- padding: 14px;
- }
-
- .info-page__main,
- .info-page__notes {
- font-size: 0.95rem;
- line-height: 1.52;
+ > section {
+ padding: var(--small-margin);
}
}
}
diff --git a/src/style/common.scss b/src/style/common.scss
index 9996bc5..23c82aa 100644
--- a/src/style/common.scss
+++ b/src/style/common.scss
@@ -16,24 +16,15 @@
html {
height: 100%;
- overscroll-behavior: none;
touch-action: manipulation;
- user-select: none;
-webkit-font-smoothing: antialiased;
- -webkit-touch-callout: none;
- -webkit-user-select: none;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
body {
font-family: 'Open Sans', sans-serif;
- overscroll-behavior: none;
touch-action: manipulation;
- user-select: none;
- -webkit-tap-highlight-color: transparent;
- -webkit-touch-callout: none;
- -webkit-user-select: none;
}
.visually-hidden {
diff --git a/src/utils/delta-time-calculator.ts b/src/utils/delta-time-calculator.ts
index bff0468..78489c2 100644
--- a/src/utils/delta-time-calculator.ts
+++ b/src/utils/delta-time-calculator.ts
@@ -1,9 +1,6 @@
+import { appConfig } from '../config';
import { clamp } from './math';
-export const MIN_DELTA_TIME_SECONDS = 1 / 240;
-
-const MAX_DELTA_TIME_SECONDS = 1 / 30;
-
export class DeltaTimeCalculator {
private previousTime: DOMHighResTimeStamp | null = null;
private readonly visibilityChangeListener = () => this.handleVisibilityChange();
@@ -19,7 +16,11 @@ export class DeltaTimeCalculator {
const delta = currentTime - this.previousTime;
this.previousTime = currentTime;
- return clamp(delta / 1000, MIN_DELTA_TIME_SECONDS, MAX_DELTA_TIME_SECONDS);
+ return clamp(
+ delta / 1000,
+ appConfig.deltaTime.minDeltaTimeSeconds,
+ appConfig.deltaTime.maxDeltaTimeSeconds
+ );
}
private handleVisibilityChange() {
diff --git a/src/utils/graphics/noise.ts b/src/utils/graphics/noise.ts
index 4e171fd..c7cdc96 100644
--- a/src/utils/graphics/noise.ts
+++ b/src/utils/graphics/noise.ts
@@ -1,17 +1,7 @@
+import { appConfig } from '../../config';
import { setUpFullScreenQuad } from './full-screen-quad';
import { smartCompile } from './smart-compile';
-export const NOISE_TEXTURE_SIZE = 2048;
-
-const NOISE_CHANNEL_SEEDS = [0, 1, 2, 3] as const;
-const NOISE_CLEAR_VALUE = { r: 1, g: 1, b: 1, a: 1 };
-const NOISE_DRAW_INSTANCE_COUNT = 1;
-const NOISE_DRAW_VERTEX_COUNT = 3;
-const NOISE_HASH_MULTIPLIER = 43758.5453123;
-const NOISE_HASH_X = 12.9898;
-const NOISE_HASH_Y = 78.233;
-const NOISE_TEXTURE_FORMAT = 'r8unorm';
-
export interface GeneratedNoiseTexture {
texture: GPUTexture;
view: GPUTextureView;
@@ -39,16 +29,16 @@ export const generateNoise = ({
return fract(sin(dot(
uv,
vec2(
- ${NOISE_HASH_X} + seed,
- ${NOISE_HASH_Y} + seed
+ ${appConfig.pipelines.common.noiseHashX} + seed,
+ ${appConfig.pipelines.common.noiseHashY} + seed
)
- )) * ${NOISE_HASH_MULTIPLIER} + seed);
+ )) * ${appConfig.pipelines.common.noiseHashMultiplier} + seed);
}
@fragment
fn fragment(@location(0) uv: vec2) -> @location(0) vec4 {
return vec4(
- random_with_seed(uv, ${NOISE_CHANNEL_SEEDS[0]}),
+ random_with_seed(uv, ${appConfig.pipelines.common.noiseChannelSeeds[0]}),
0.0,
0.0,
1.0,
@@ -58,7 +48,7 @@ export const generateNoise = ({
entryPoint: 'fragment',
targets: [
{
- format: NOISE_TEXTURE_FORMAT,
+ format: appConfig.pipelines.common.noiseTextureFormat,
},
],
},
@@ -73,7 +63,7 @@ export const generateNoise = ({
height,
depthOrArrayLayers: 1,
},
- format: NOISE_TEXTURE_FORMAT,
+ format: appConfig.pipelines.common.noiseTextureFormat,
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
});
@@ -81,7 +71,7 @@ export const generateNoise = ({
colorAttachments: [
{
view: colorTexture.createView(),
- clearValue: NOISE_CLEAR_VALUE,
+ clearValue: appConfig.pipelines.common.noiseClearValue,
loadOp: 'clear',
storeOp: 'store',
},
@@ -92,7 +82,10 @@ export const generateNoise = ({
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
passEncoder.setPipeline(pipeline);
- passEncoder.draw(NOISE_DRAW_VERTEX_COUNT, NOISE_DRAW_INSTANCE_COUNT);
+ passEncoder.draw(
+ appConfig.pipelines.common.noiseDrawVertexCount,
+ appConfig.pipelines.common.noiseDrawInstanceCount
+ );
passEncoder.end();
device.queue.submit([commandEncoder.finish()]);
diff --git a/src/vibe-registry.ts b/src/vibe-registry.ts
index ad16554..0b0ae54 100644
--- a/src/vibe-registry.ts
+++ b/src/vibe-registry.ts
@@ -1,7 +1,7 @@
-import { vibePresets } from './config';
+import { appConfig } from './config';
import type { VibeId, VibePreset } from './config/types';
-export const VIBE_PRESETS: Array = vibePresets;
+export const VIBE_PRESETS: Array = appConfig.vibes.presets;
export const getVibeById = (vibeId: VibeId): VibePreset | undefined =>
VIBE_PRESETS.find((vibe) => vibe.id === vibeId);
diff --git a/src/vibes.ts b/src/vibes.ts
index 28aed48..95d1f7c 100644
--- a/src/vibes.ts
+++ b/src/vibes.ts
@@ -1,4 +1,4 @@
-import { defaultVibeId } from './config';
+import { appConfig } from './config';
import { VibeId, type VibePreset } from './config/types';
import { readBrowserStorage } from './utils/browser-storage';
import { getVibeById, VIBE_PRESETS } from './vibe-registry';
@@ -8,8 +8,6 @@ export { VibeId };
export { getVibeById, VIBE_PRESETS };
export type { VibePreset };
-export const VIBE_STORAGE_KEY = 'fleeting-garden:vibe';
-
const VIBE_IDS = new Set(VIBE_PRESETS.map((vibe) => vibe.id));
const isVibeId = (value: unknown): value is VibeId =>
@@ -17,11 +15,12 @@ const isVibeId = (value: unknown): value is VibeId =>
export const getInitialVibe = (): VibePreset => {
const uriVibeId = getCurrentUriVibeId();
- const storedVibeId = readBrowserStorage(VIBE_STORAGE_KEY);
+ const storedVibeId = readBrowserStorage(appConfig.storage.vibeKey);
const storedOrLegacyVibeId = isVibeId(storedVibeId)
? storedVibeId
: getVibeIdFromUri(`?vibe=${encodeURIComponent(storedVibeId ?? '')}`);
- const initialVibeId = uriVibeId ?? storedOrLegacyVibeId ?? defaultVibeId;
+ const initialVibeId =
+ uriVibeId ?? storedOrLegacyVibeId ?? appConfig.vibes.defaultVibeId;
return getVibeById(initialVibeId) ?? VIBE_PRESETS[0];
};