diff --git a/assets/icons/close.svg b/assets/icons/close.svg
new file mode 100644
index 0000000..ae3fbc7
--- /dev/null
+++ b/assets/icons/close.svg
@@ -0,0 +1,5 @@
+
+
+
diff --git a/index.html b/index.html
index fadc28f..53428ea 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": "Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser.",
+ "description": "Draw colour into a canvas that keeps moving. Your strokes become paths for life that splits, drifts, and redraws the surface over time.",
"image": "https://schmelczer.dev/fleeting/og-image.jpg",
"applicationCategory": "DesignApplication",
"operatingSystem": "Any",
@@ -91,7 +91,8 @@
Fleeting Garden
- Tend it while you can. The garden returns to weather either way.
+ Draw colour into a canvas that keeps moving. Your strokes become paths for
+ life that splits, drifts, and redraws the surface over time.
Start
@@ -118,35 +119,54 @@
id="info-panel"
class="hidden info-page"
role="region"
- aria-label="About panel"
+ aria-labelledby="info-panel-title"
aria-hidden="true"
tabindex="-1"
inert
>
-
- 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.
+
+
+
+
+ Draw into a field of particles and watch the simulation fold your marks back
+ into motion.
-
- 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
+
+
+ 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
schmelczer.dev .
+ >
-
+
diff --git a/public/og-image.jpg b/public/og-image.jpg
index 0ced8fc..00d22a1 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 b19958a..427f871 100644
--- a/src/analytics.ts
+++ b/src/analytics.ts
@@ -4,9 +4,13 @@ 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 = {}) => {
@@ -24,10 +28,10 @@ export const initAnalytics = () => {
try {
plausibleInit({
- domain: appConfig.analytics.domain,
- endpoint: appConfig.analytics.endpoint,
- autoCapturePageviews: appConfig.analytics.autoCapturePageviews,
- logging: appConfig.analytics.logging,
+ domain: ANALYTICS_DOMAIN,
+ endpoint: ANALYTICS_ENDPOINT,
+ autoCapturePageviews: ANALYTICS_AUTO_CAPTURE_PAGEVIEWS,
+ logging: ANALYTICS_LOGGING,
});
isInitialized = true;
} catch (error) {
diff --git a/src/audio/garden-audio-config.ts b/src/audio/garden-audio-config.ts
index b5fbc1d..37e5ace 100644
--- a/src/audio/garden-audio-config.ts
+++ b/src/audio/garden-audio-config.ts
@@ -1,6 +1,7 @@
import type { PianoNoteRole } from './garden-audio-types';
-export const DEFAULT_AUDIO_VOLUME = 0.5;
+export const DEFAULT_AUDIO_VOLUME = 0.65;
+export const MAX_AUDIO_VOLUME = 1.5;
export const SILENT_AUDIO_GAIN = 0.0001;
type GardenAudioChordQuality = 'major' | 'minor' | 'sus2' | 'sus4';
@@ -58,17 +59,33 @@ export const createGardenAudioConfig = () => ({
timeRampSeconds: 0.12,
},
piano: {
- maxVoices: 24,
- gain: 0.48,
+ maxVoices: 48,
+ gain: 0.78,
sustainSeconds: 0.42,
sustainLevel: 0.26,
- releaseSeconds: 0.34,
- lowpassHz: 7000,
- gainAttackSeconds: 0.006,
- lowpassMaxHz: 12000,
- lowpassMinHz: 1400,
+ releaseSeconds: 0.62,
+ lowpassHz: 9500,
+ gainAttackSeconds: 0.003,
+ lowpassMaxHz: 16000,
+ lowpassMinHz: 900,
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 a288465..a08ac58 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: -18,
+ thresholdDb: -17,
kneeDb: 18,
- ratio: 2.1,
- attackSeconds: 0.018,
- releaseSeconds: 0.18,
+ ratio: 2.2,
+ attackSeconds: 0.014,
+ releaseSeconds: 0.28,
},
} as const;
const delayFilterTuning = {
@@ -45,6 +45,7 @@ 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;
@@ -87,10 +88,12 @@ 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;
@@ -100,15 +103,18 @@ export class GardenAudioGraph {
compressor.attack.value = graphTuning.compressor.attackSeconds;
compressor.release.value = graphTuning.compressor.releaseSeconds;
- masterGain.connect(highPass);
+ // Keep peak control independent from the user's volume slider.
+ outputBus.connect(highPass);
highPass.connect(compressor);
- compressor.connect(context.destination);
+ compressor.connect(masterGain);
+ masterGain.connect(context.destination);
this.context = context;
this.masterGain = masterGain;
this.noiseBuffer = this.createNoiseBuffer(context);
- this.createDelay(context, masterGain);
- this.createBuses(context, masterGain);
+ this.createDelay(context, outputBus);
+ this.createRoom(context, outputBus);
+ this.createBuses(context, outputBus);
return context;
}
@@ -224,7 +230,7 @@ export class GardenAudioGraph {
}
}
- private createDelay(context: AudioContext, masterGain: GainNode): void {
+ private createDelay(context: AudioContext, outputBus: GainNode): void {
const delayInput = context.createGain();
const delayNode = context.createDelay(graphTuning.delayMaxSeconds);
const delayFeedback = context.createGain();
@@ -250,7 +256,7 @@ export class GardenAudioGraph {
delayFeedback.connect(delayNode);
delayNode.connect(returnLowPass);
returnLowPass.connect(delayOutput);
- delayOutput.connect(masterGain);
+ delayOutput.connect(outputBus);
this.delayInput = delayInput;
this.delayNode = delayNode;
@@ -258,10 +264,37 @@ export class GardenAudioGraph {
this.delayOutput = delayOutput;
}
- private createBuses(context: AudioContext, masterGain: GainNode): void {
+ 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 {
const eventBus = context.createGain();
eventBus.gain.value = graphTuning.eventBusGain;
- eventBus.connect(masterGain);
+ eventBus.connect(outputBus);
this.eventBus = eventBus;
this.pianoBuses.clear();
@@ -328,10 +361,34 @@ 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 fecbcf8..ade78a4 100644
--- a/src/audio/garden-audio-types.ts
+++ b/src/audio/garden-audio-types.ts
@@ -14,11 +14,22 @@ export interface GardenAudioStroke {
elapsedSeconds: number;
}
-export interface LoadedPianoSample {
+export interface LoadedPianoStrikeSample {
+ midi: number;
+ velocityLayer: number;
+ buffer: AudioBuffer;
+}
+
+export interface LoadedPianoReleaseSample {
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 3aa4c88..d2178cc 100644
--- a/src/audio/garden-audio.ts
+++ b/src/audio/garden-audio.ts
@@ -1,7 +1,8 @@
import { ErrorHandler, Severity } from '../utils/error-handler';
-import { clamp01 } from '../utils/math';
+import { clamp } from '../utils/math';
import type { VibeId, VibePreset } from '../vibes';
import {
+ MAX_AUDIO_VOLUME,
SILENT_AUDIO_GAIN,
type GardenAudioConfig,
type GardenAudioVibeProfile,
@@ -49,7 +50,7 @@ export class GardenAudio {
private hasLoadedPiano = false;
public constructor(private readonly config: GardenAudioConfig) {
- this.masterVolume = clamp01(config.masterVolume);
+ this.masterVolume = clamp(config.masterVolume, 0, MAX_AUDIO_VOLUME);
this.graph = new GardenAudioGraph(config);
this.piano = new PianoSampler(config, this.graph);
this.noise = new NoiseBurstPlayer(this.graph);
@@ -228,7 +229,7 @@ export class GardenAudio {
}
public setMasterVolume(masterVolume: number): void {
- this.masterVolume = clamp01(masterVolume);
+ this.masterVolume = clamp(masterVolume, 0, MAX_AUDIO_VOLUME);
if (!this.isMuted) {
this.graph.setMasterGain(this.masterVolume, this.config.updateRampSeconds);
}
@@ -396,7 +397,7 @@ export class GardenAudio {
return;
}
- const distanceActivity = clamp01(activity);
+ const distanceActivity = clamp(activity, 0, 1);
if (distanceActivity <= 0) {
return;
}
diff --git a/src/audio/piano-sampler.ts b/src/audio/piano-sampler.ts
index 1a0c77c..5e0b771 100644
--- a/src/audio/piano-sampler.ts
+++ b/src/audio/piano-sampler.ts
@@ -2,32 +2,56 @@ 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 { LoadedPianoSample, PianoNote } from './garden-audio-types';
+import type {
+ LoadedPianoReleaseSample,
+ LoadedPianoSamples,
+ LoadedPianoStrikeSample,
+ PianoNote,
+} from './garden-audio-types';
import { getLoadedPianoSamples, loadPianoSamples } from './piano-samples';
export const PIANO_SCHEDULE_AHEAD_SECONDS = 0.002;
interface ActivePianoVoice {
gain: GainNode;
- source: AudioScheduledSourceNode;
+ peakGain: number;
+ releaseAt: number;
+ sources: Array;
+ startedAt: number;
+ stopAt: number;
+}
+
+interface SelectedPianoStrikeSample {
+ gainScale: number;
+ sample: LoadedPianoStrikeSample;
+}
+
+interface ScheduledReleaseSample {
+ gainValue: number;
+ source: AudioBufferSourceNode;
+ startTime: number;
stopAt: number;
}
const pianoSamplerTuning = {
filterType: 'lowpass',
- filterQ: 0.7,
+ filterQ: 0.45,
minDurationSeconds: 0.08,
minFadeSeconds: 0.08,
minGain: 0.0001,
- releaseTimeConstantCount: 5,
- tailStopExtraSeconds: 0.05,
- voiceStealFadeSeconds: 0.025,
- voiceStealStopSeconds: 0.05,
+ releaseSampleAttackSeconds: 0.006,
+ releaseSampleDecaySeconds: 0.18,
+ releaseTimeConstantCount: 6,
+ tailStopExtraSeconds: 0.08,
+ voiceStealFadeSeconds: 0.045,
+ voiceStealStopSeconds: 0.09,
} 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,
@@ -35,7 +59,7 @@ export class PianoSampler {
) {}
public load(context: BaseAudioContext): Promise {
- if (this.samples.length > 0) {
+ if (this.strikeSamples.length > 0) {
return Promise.resolve();
}
@@ -67,8 +91,9 @@ export class PianoSampler {
return;
}
- const sample = this.findNearestSample(midi);
- if (!sample) {
+ const noteVelocity = clamp01(velocity);
+ const selectedSamples = this.selectStrikeSamples(midi, noteVelocity);
+ if (selectedSamples.length === 0) {
return;
}
@@ -76,7 +101,6 @@ export class PianoSampler {
context.currentTime + PIANO_SCHEDULE_AHEAD_SECONDS,
startTime
);
- const noteVelocity = clamp01(velocity);
const noteGainValue = this.computeNoteGain(noteVelocity);
const sustainSeconds =
profileSustainSeconds *
@@ -88,45 +112,36 @@ export class PianoSampler {
const stopAt =
releaseAt +
this.config.piano.releaseSeconds * pianoSamplerTuning.releaseTimeConstantCount;
- const source = context.createBufferSource();
-
- source.buffer = sample.buffer;
- source.playbackRate.setValueAtTime(
- Math.pow(2, (midi - sample.midi) / PITCH_SEMITONES_PER_OCTAVE),
- scheduledStart
- );
+ 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,
+ });
this.scheduleVoice({
- source,
- scheduledStart,
- stopAt,
- pan,
- lowpassHz,
delaySend,
eventBus,
- 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
- );
- },
+ lowpassHz,
+ noteGainValue,
+ pan,
+ releaseAt,
+ releaseSample,
+ scheduledStart,
+ stopAt: releaseSample ? Math.max(stopAt, releaseSample.stopAt) : stopAt,
+ strikeSources,
+ sustainAt,
+ sustainSeconds,
});
}
@@ -146,30 +161,40 @@ export class PianoSampler {
}
public reset(): void {
- this.samples = [];
+ this.releaseSamples = [];
+ this.strikeSamples = [];
+ this.velocityLayers = [];
this.activeVoices = [];
}
private scheduleVoice({
- source,
+ strikeSources,
+ releaseSample,
scheduledStart,
+ sustainAt,
+ sustainSeconds,
+ releaseAt,
stopAt,
pan,
lowpassHz,
delaySend,
eventBus,
- configureGainEnvelope,
+ noteGainValue,
}: {
- source: AudioScheduledSourceNode;
- scheduledStart: number;
- stopAt: number;
- pan: number;
- lowpassHz: number;
delaySend: number;
eventBus: GainNode;
- configureGainEnvelope: (gain: GainNode) => void;
+ lowpassHz: number;
+ noteGainValue: number;
+ pan: number;
+ releaseAt: number;
+ releaseSample: ScheduledReleaseSample | null;
+ scheduledStart: number;
+ stopAt: number;
+ strikeSources: Array<{ gainScale: number; source: AudioBufferSourceNode }>;
+ sustainAt: number;
+ sustainSeconds: number;
}): void {
- const { context, delayInput } = this.graph;
+ const { context, delayInput, roomInput } = this.graph;
if (!context) {
return;
}
@@ -177,15 +202,18 @@ export class PianoSampler {
const filter = context.createBiquadFilter();
const gain = context.createGain();
const panner = context.createStereoPanner();
- let sendGain: GainNode | null = null;
+ 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;
this.trimActiveVoices(scheduledStart);
while (this.activeVoices.length >= this.config.piano.maxVoices) {
- const oldest = this.activeVoices.shift();
- if (!oldest) {
- break;
- }
- this.stopVoice(oldest, scheduledStart);
+ this.stealQuietestVoice(scheduledStart);
}
filter.type = pianoSamplerTuning.filterType;
@@ -195,48 +223,352 @@ export class PianoSampler {
);
filter.Q.value = pianoSamplerTuning.filterQ;
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
- configureGainEnvelope(gain);
+ this.configureGainEnvelope({
+ gain,
+ noteGainValue,
+ releaseAt,
+ scheduledStart,
+ sustainAt,
+ sustainSeconds,
+ });
- source.connect(filter);
+ strikeSources.forEach(({ source }, index) => {
+ source.connect(sourceGains[index]);
+ sourceGains[index].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) {
- sendGain = context.createGain();
- sendGain.gain.value = delaySend;
- panner.connect(sendGain);
- sendGain.connect(delayInput);
+ delaySendGain = context.createGain();
+ delaySendGain.gain.value = delaySend;
+ panner.connect(delaySendGain);
+ delaySendGain.connect(delayInput);
}
- source.start(scheduledStart);
- source.stop(stopAt + pianoSamplerTuning.tailStopExtraSeconds);
- this.activeVoices.push({ gain, source, stopAt });
+ if (roomInput && this.config.piano.roomSend > 0) {
+ roomSendGain = context.createGain();
+ roomSendGain.gain.value = this.config.piano.roomSend;
+ panner.connect(roomSendGain);
+ roomSendGain.connect(roomInput);
+ }
- source.addEventListener(
- 'ended',
- () => {
- source.disconnect();
- filter.disconnect();
- gain.disconnect();
- panner.disconnect();
- sendGain?.disconnect();
- this.activeVoices = this.activeVoices.filter((voice) => voice.gain !== gain);
- },
- { once: true }
+ 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.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 });
+ });
}
private computeNoteGain(velocity: number): number {
return Math.max(pianoSamplerTuning.minGain, this.config.piano.gain * velocity);
}
- private findNearestSample(midi: number): LoadedPianoSample | null {
- if (this.samples.length === 0) {
+ 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) {
return null;
}
- return this.samples.reduce((nearest, sample) =>
+ 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) =>
Math.abs(sample.midi - midi) < Math.abs(nearest.midi - midi) ? sample : nearest
);
}
@@ -245,6 +577,33 @@ 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;
@@ -254,11 +613,23 @@ 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: Array): void {
- this.samples = samples.slice().sort((a, b) => a.midi - b.midi);
+ 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);
}
}
diff --git a/src/audio/piano-samples.ts b/src/audio/piano-samples.ts
index 569eca4..2d2bc25 100644
--- a/src/audio/piano-samples.ts
+++ b/src/audio/piano-samples.ts
@@ -1,40 +1,26 @@
-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';
+import type {
+ LoadedPianoReleaseSample,
+ LoadedPianoSamples,
+ LoadedPianoStrikeSample,
+} from './garden-audio-types';
-interface PianoSampleDefinition {
- note: string;
+interface PianoStrikeSampleDefinition {
+ kind: 'strike';
+ midi: number;
+ path: string;
+ url: string;
+ velocityLayer: number;
+}
+
+interface PianoReleaseSampleDefinition {
+ kind: 'release';
+ midi: number;
+ path: string;
url: string;
}
+type PianoSampleDefinition = PianoStrikeSampleDefinition | PianoReleaseSampleDefinition;
+
export interface PianoSampleLoadProgress {
failedCount: number;
loadedCount: number;
@@ -42,54 +28,28 @@ export interface PianoSampleLoadProgress {
totalCount: number;
}
-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' },
-];
+const pianoSampleModules = import.meta.glob('./samples/*.m4a', {
+ eager: true,
+ import: 'default',
+ query: '?url&no-inline',
+}) as Record;
+const pianoSampleDefinitions = getPianoSampleDefinitions(pianoSampleModules);
-let loadedPianoSamples: Array | null = null;
-let pianoSampleLoadPromise: Promise> | null = null;
+let loadedPianoSamples: LoadedPianoSamples | null = null;
+let pianoSampleLoadPromise: Promise | null = null;
let lastPianoSampleProgress: PianoSampleLoadProgress | null = null;
const pianoSampleProgressListeners = new Set<
(progress: PianoSampleLoadProgress) => void
>();
const sampleLoadTuning = {
- concurrency: 4,
+ concurrency: 6,
sampleTimeoutMs: 15_000,
};
export const preloadPianoSamples = (
onProgress?: (progress: PianoSampleLoadProgress) => void
-): Promise> => {
+): Promise => {
const OfflineAudioContextConstructor = globalThis.OfflineAudioContext;
if (!OfflineAudioContextConstructor) {
@@ -106,18 +66,19 @@ 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.length,
- settledCount: loadedPianoSamples.length,
+ loadedCount: loadedPianoSamples.strikes.length + loadedPianoSamples.releases.length,
+ settledCount:
+ loadedPianoSamples.strikes.length + loadedPianoSamples.releases.length,
totalCount: pianoSampleDefinitions.length,
});
unsubscribeProgress();
- return Promise.resolve([...loadedPianoSamples]);
+ return Promise.resolve(cloneLoadedPianoSamples(loadedPianoSamples));
}
if (pianoSampleLoadPromise) {
@@ -151,13 +112,15 @@ export const loadPianoSamples = (
)
.then(
(samples) => {
- loadedPianoSamples = samples.sort((a, b) => a.midi - b.midi);
- if (loadedPianoSamples.length !== pianoSampleDefinitions.length) {
+ loadedPianoSamples = sortLoadedPianoSamples(samples);
+ const loadedCount =
+ loadedPianoSamples.strikes.length + loadedPianoSamples.releases.length;
+ if (loadedCount !== pianoSampleDefinitions.length) {
throw new Error(
- `Loaded ${loadedPianoSamples.length}/${pianoSampleDefinitions.length} piano samples.`
+ `Loaded ${loadedCount}/${pianoSampleDefinitions.length} piano samples.`
);
}
- return [...loadedPianoSamples];
+ return cloneLoadedPianoSamples(loadedPianoSamples);
},
(error: unknown) => {
pianoSampleLoadPromise = null;
@@ -170,29 +133,38 @@ export const loadPianoSamples = (
return pianoSampleLoadPromise;
};
-export const getLoadedPianoSamples = (): Array | null =>
- loadedPianoSamples ? [...loadedPianoSamples] : null;
+export const getLoadedPianoSamples = (): LoadedPianoSamples | null =>
+ loadedPianoSamples ? cloneLoadedPianoSamples(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 ${getPianoSamplePath(sample)}`);
+ throw new Error(`Unable to load piano sample ${sample.path}`);
}
const audioData = await response.arrayBuffer();
const buffer = await decodeContext.decodeAudioData(audioData);
- return { midi: getMidiForPianoSample(sample), buffer };
+ if (sample.kind === 'strike') {
+ return {
+ buffer,
+ midi: sample.midi,
+ velocityLayer: sample.velocityLayer,
+ };
+ }
+ return { buffer, midi: sample.midi };
};
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);
@@ -247,13 +219,50 @@ const emitPianoSampleProgress = (progress: PianoSampleLoadProgress): void => {
pianoSampleProgressListeners.forEach((listener) => listener(progress));
};
-const getPianoSamplePath = (sample: PianoSampleDefinition): string =>
- `./samples/${sample.note}v12.m4a`;
+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 getMidiForPianoSample = (sample: PianoSampleDefinition): number => {
- const match = /^(?[A-G])(?sharp)?(?\d+)$/.exec(sample.note);
+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);
if (!match?.groups) {
- throw new Error(`Invalid piano sample note ${sample.note}`);
+ throw new Error(`Invalid piano sample note ${note}`);
}
const semitoneByName: Record = {
@@ -268,4 +277,25 @@ const getMidiForPianoSample = (sample: PianoSampleDefinition): 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 db06fc3..71c0564 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
new file mode 100644
index 0000000..aae9230
Binary files /dev/null and b/src/audio/samples/A0v16.m4a differ
diff --git a/src/audio/samples/A0v4.m4a b/src/audio/samples/A0v4.m4a
new file mode 100644
index 0000000..0679ac0
Binary files /dev/null and b/src/audio/samples/A0v4.m4a differ
diff --git a/src/audio/samples/A0v8.m4a b/src/audio/samples/A0v8.m4a
new file mode 100644
index 0000000..99195dd
Binary files /dev/null and b/src/audio/samples/A0v8.m4a differ
diff --git a/src/audio/samples/A1v12.m4a b/src/audio/samples/A1v12.m4a
index f1ed488..4d47370 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
new file mode 100644
index 0000000..a85872d
Binary files /dev/null and b/src/audio/samples/A1v16.m4a differ
diff --git a/src/audio/samples/A1v4.m4a b/src/audio/samples/A1v4.m4a
new file mode 100644
index 0000000..8095af9
Binary files /dev/null and b/src/audio/samples/A1v4.m4a differ
diff --git a/src/audio/samples/A1v8.m4a b/src/audio/samples/A1v8.m4a
new file mode 100644
index 0000000..de6d334
Binary files /dev/null and b/src/audio/samples/A1v8.m4a differ
diff --git a/src/audio/samples/A2v12.m4a b/src/audio/samples/A2v12.m4a
index 52df725..2ac10d3 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
new file mode 100644
index 0000000..8aa478c
Binary files /dev/null and b/src/audio/samples/A2v16.m4a differ
diff --git a/src/audio/samples/A2v4.m4a b/src/audio/samples/A2v4.m4a
new file mode 100644
index 0000000..f241c2e
Binary files /dev/null and b/src/audio/samples/A2v4.m4a differ
diff --git a/src/audio/samples/A2v8.m4a b/src/audio/samples/A2v8.m4a
new file mode 100644
index 0000000..ceaca7f
Binary files /dev/null and b/src/audio/samples/A2v8.m4a differ
diff --git a/src/audio/samples/A3v12.m4a b/src/audio/samples/A3v12.m4a
index 707a766..10cc72c 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
new file mode 100644
index 0000000..a100c90
Binary files /dev/null and b/src/audio/samples/A3v16.m4a differ
diff --git a/src/audio/samples/A3v4.m4a b/src/audio/samples/A3v4.m4a
new file mode 100644
index 0000000..0eae496
Binary files /dev/null and b/src/audio/samples/A3v4.m4a differ
diff --git a/src/audio/samples/A3v8.m4a b/src/audio/samples/A3v8.m4a
new file mode 100644
index 0000000..2158608
Binary files /dev/null and b/src/audio/samples/A3v8.m4a differ
diff --git a/src/audio/samples/A4v12.m4a b/src/audio/samples/A4v12.m4a
index 679bcff..7fe52f4 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
new file mode 100644
index 0000000..4d20781
Binary files /dev/null and b/src/audio/samples/A4v16.m4a differ
diff --git a/src/audio/samples/A4v4.m4a b/src/audio/samples/A4v4.m4a
new file mode 100644
index 0000000..4e55c94
Binary files /dev/null and b/src/audio/samples/A4v4.m4a differ
diff --git a/src/audio/samples/A4v8.m4a b/src/audio/samples/A4v8.m4a
new file mode 100644
index 0000000..335fa93
Binary files /dev/null and b/src/audio/samples/A4v8.m4a differ
diff --git a/src/audio/samples/A5v12.m4a b/src/audio/samples/A5v12.m4a
index 4a2c896..9d3d280 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
new file mode 100644
index 0000000..cd7c62f
Binary files /dev/null and b/src/audio/samples/A5v16.m4a differ
diff --git a/src/audio/samples/A5v4.m4a b/src/audio/samples/A5v4.m4a
new file mode 100644
index 0000000..c68176e
Binary files /dev/null and b/src/audio/samples/A5v4.m4a differ
diff --git a/src/audio/samples/A5v8.m4a b/src/audio/samples/A5v8.m4a
new file mode 100644
index 0000000..0afb0b9
Binary files /dev/null and b/src/audio/samples/A5v8.m4a differ
diff --git a/src/audio/samples/A6v12.m4a b/src/audio/samples/A6v12.m4a
index abbd605..9b8644d 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
new file mode 100644
index 0000000..26bcc55
Binary files /dev/null and b/src/audio/samples/A6v16.m4a differ
diff --git a/src/audio/samples/A6v4.m4a b/src/audio/samples/A6v4.m4a
new file mode 100644
index 0000000..6d37b98
Binary files /dev/null and b/src/audio/samples/A6v4.m4a differ
diff --git a/src/audio/samples/A6v8.m4a b/src/audio/samples/A6v8.m4a
new file mode 100644
index 0000000..bd89797
Binary files /dev/null and b/src/audio/samples/A6v8.m4a differ
diff --git a/src/audio/samples/A7v12.m4a b/src/audio/samples/A7v12.m4a
index 3fd6829..2a4193b 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
new file mode 100644
index 0000000..c0f0e52
Binary files /dev/null and b/src/audio/samples/A7v16.m4a differ
diff --git a/src/audio/samples/A7v4.m4a b/src/audio/samples/A7v4.m4a
new file mode 100644
index 0000000..2106892
Binary files /dev/null and b/src/audio/samples/A7v4.m4a differ
diff --git a/src/audio/samples/A7v8.m4a b/src/audio/samples/A7v8.m4a
new file mode 100644
index 0000000..a25a530
Binary files /dev/null and b/src/audio/samples/A7v8.m4a differ
diff --git a/src/audio/samples/C1v12.m4a b/src/audio/samples/C1v12.m4a
index 59d5f61..d94fd2e 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
new file mode 100644
index 0000000..f6c995d
Binary files /dev/null and b/src/audio/samples/C1v16.m4a differ
diff --git a/src/audio/samples/C1v4.m4a b/src/audio/samples/C1v4.m4a
new file mode 100644
index 0000000..5c2d043
Binary files /dev/null and b/src/audio/samples/C1v4.m4a differ
diff --git a/src/audio/samples/C1v8.m4a b/src/audio/samples/C1v8.m4a
new file mode 100644
index 0000000..febaf24
Binary files /dev/null and b/src/audio/samples/C1v8.m4a differ
diff --git a/src/audio/samples/C2v12.m4a b/src/audio/samples/C2v12.m4a
index 9b636f9..44bda1a 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
new file mode 100644
index 0000000..bb729d8
Binary files /dev/null and b/src/audio/samples/C2v16.m4a differ
diff --git a/src/audio/samples/C2v4.m4a b/src/audio/samples/C2v4.m4a
new file mode 100644
index 0000000..9ce27c9
Binary files /dev/null and b/src/audio/samples/C2v4.m4a differ
diff --git a/src/audio/samples/C2v8.m4a b/src/audio/samples/C2v8.m4a
new file mode 100644
index 0000000..3cd4b1c
Binary files /dev/null and b/src/audio/samples/C2v8.m4a differ
diff --git a/src/audio/samples/C3v12.m4a b/src/audio/samples/C3v12.m4a
index e891e16..a7c9af0 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
new file mode 100644
index 0000000..d6ad787
Binary files /dev/null and b/src/audio/samples/C3v16.m4a differ
diff --git a/src/audio/samples/C3v4.m4a b/src/audio/samples/C3v4.m4a
new file mode 100644
index 0000000..e850712
Binary files /dev/null and b/src/audio/samples/C3v4.m4a differ
diff --git a/src/audio/samples/C3v8.m4a b/src/audio/samples/C3v8.m4a
new file mode 100644
index 0000000..e781b6b
Binary files /dev/null and b/src/audio/samples/C3v8.m4a differ
diff --git a/src/audio/samples/C4v12.m4a b/src/audio/samples/C4v12.m4a
index 6061dc5..fb37963 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
new file mode 100644
index 0000000..eed90fa
Binary files /dev/null and b/src/audio/samples/C4v16.m4a differ
diff --git a/src/audio/samples/C4v4.m4a b/src/audio/samples/C4v4.m4a
new file mode 100644
index 0000000..7f1994f
Binary files /dev/null and b/src/audio/samples/C4v4.m4a differ
diff --git a/src/audio/samples/C4v8.m4a b/src/audio/samples/C4v8.m4a
new file mode 100644
index 0000000..9a45ff8
Binary files /dev/null and b/src/audio/samples/C4v8.m4a differ
diff --git a/src/audio/samples/C5v12.m4a b/src/audio/samples/C5v12.m4a
index a6d8898..3c08107 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
new file mode 100644
index 0000000..24edb5a
Binary files /dev/null and b/src/audio/samples/C5v16.m4a differ
diff --git a/src/audio/samples/C5v4.m4a b/src/audio/samples/C5v4.m4a
new file mode 100644
index 0000000..130ea2e
Binary files /dev/null and b/src/audio/samples/C5v4.m4a differ
diff --git a/src/audio/samples/C5v8.m4a b/src/audio/samples/C5v8.m4a
new file mode 100644
index 0000000..403b22d
Binary files /dev/null and b/src/audio/samples/C5v8.m4a differ
diff --git a/src/audio/samples/C6v12.m4a b/src/audio/samples/C6v12.m4a
index 745a4d6..d8268ff 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
new file mode 100644
index 0000000..8305844
Binary files /dev/null and b/src/audio/samples/C6v16.m4a differ
diff --git a/src/audio/samples/C6v4.m4a b/src/audio/samples/C6v4.m4a
new file mode 100644
index 0000000..8646b5b
Binary files /dev/null and b/src/audio/samples/C6v4.m4a differ
diff --git a/src/audio/samples/C6v8.m4a b/src/audio/samples/C6v8.m4a
new file mode 100644
index 0000000..f85e6a1
Binary files /dev/null and b/src/audio/samples/C6v8.m4a differ
diff --git a/src/audio/samples/C7v12.m4a b/src/audio/samples/C7v12.m4a
index 6470854..d3bcee5 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
new file mode 100644
index 0000000..0211360
Binary files /dev/null and b/src/audio/samples/C7v16.m4a differ
diff --git a/src/audio/samples/C7v4.m4a b/src/audio/samples/C7v4.m4a
new file mode 100644
index 0000000..a2e2d2d
Binary files /dev/null and b/src/audio/samples/C7v4.m4a differ
diff --git a/src/audio/samples/C7v8.m4a b/src/audio/samples/C7v8.m4a
new file mode 100644
index 0000000..6113888
Binary files /dev/null and b/src/audio/samples/C7v8.m4a differ
diff --git a/src/audio/samples/C8v12.m4a b/src/audio/samples/C8v12.m4a
index dfbbfd1..584f5ff 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
new file mode 100644
index 0000000..b8d2aa8
Binary files /dev/null and b/src/audio/samples/C8v16.m4a differ
diff --git a/src/audio/samples/C8v4.m4a b/src/audio/samples/C8v4.m4a
new file mode 100644
index 0000000..ff91df5
Binary files /dev/null and b/src/audio/samples/C8v4.m4a differ
diff --git a/src/audio/samples/C8v8.m4a b/src/audio/samples/C8v8.m4a
new file mode 100644
index 0000000..32e8a04
Binary files /dev/null and b/src/audio/samples/C8v8.m4a differ
diff --git a/src/audio/samples/Dsharp1v12.m4a b/src/audio/samples/Dsharp1v12.m4a
index 22d0924..0f7d897 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
new file mode 100644
index 0000000..fe422e2
Binary files /dev/null and b/src/audio/samples/Dsharp1v16.m4a differ
diff --git a/src/audio/samples/Dsharp1v4.m4a b/src/audio/samples/Dsharp1v4.m4a
new file mode 100644
index 0000000..03e7208
Binary files /dev/null and b/src/audio/samples/Dsharp1v4.m4a differ
diff --git a/src/audio/samples/Dsharp1v8.m4a b/src/audio/samples/Dsharp1v8.m4a
new file mode 100644
index 0000000..b5de786
Binary files /dev/null and b/src/audio/samples/Dsharp1v8.m4a differ
diff --git a/src/audio/samples/Dsharp2v12.m4a b/src/audio/samples/Dsharp2v12.m4a
index f25db22..a2392da 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
new file mode 100644
index 0000000..264716e
Binary files /dev/null and b/src/audio/samples/Dsharp2v16.m4a differ
diff --git a/src/audio/samples/Dsharp2v4.m4a b/src/audio/samples/Dsharp2v4.m4a
new file mode 100644
index 0000000..6984fbc
Binary files /dev/null and b/src/audio/samples/Dsharp2v4.m4a differ
diff --git a/src/audio/samples/Dsharp2v8.m4a b/src/audio/samples/Dsharp2v8.m4a
new file mode 100644
index 0000000..0461578
Binary files /dev/null and b/src/audio/samples/Dsharp2v8.m4a differ
diff --git a/src/audio/samples/Dsharp3v12.m4a b/src/audio/samples/Dsharp3v12.m4a
index 7e09558..f4d7398 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
new file mode 100644
index 0000000..3a48ed5
Binary files /dev/null and b/src/audio/samples/Dsharp3v16.m4a differ
diff --git a/src/audio/samples/Dsharp3v4.m4a b/src/audio/samples/Dsharp3v4.m4a
new file mode 100644
index 0000000..a3d205d
Binary files /dev/null and b/src/audio/samples/Dsharp3v4.m4a differ
diff --git a/src/audio/samples/Dsharp3v8.m4a b/src/audio/samples/Dsharp3v8.m4a
new file mode 100644
index 0000000..17b16e1
Binary files /dev/null and b/src/audio/samples/Dsharp3v8.m4a differ
diff --git a/src/audio/samples/Dsharp4v12.m4a b/src/audio/samples/Dsharp4v12.m4a
index d670fbb..42fbf3a 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
new file mode 100644
index 0000000..2c2cd57
Binary files /dev/null and b/src/audio/samples/Dsharp4v16.m4a differ
diff --git a/src/audio/samples/Dsharp4v4.m4a b/src/audio/samples/Dsharp4v4.m4a
new file mode 100644
index 0000000..9591c46
Binary files /dev/null and b/src/audio/samples/Dsharp4v4.m4a differ
diff --git a/src/audio/samples/Dsharp4v8.m4a b/src/audio/samples/Dsharp4v8.m4a
new file mode 100644
index 0000000..734a779
Binary files /dev/null and b/src/audio/samples/Dsharp4v8.m4a differ
diff --git a/src/audio/samples/Dsharp5v12.m4a b/src/audio/samples/Dsharp5v12.m4a
index cdbd7b8..9083dd0 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
new file mode 100644
index 0000000..33bfab4
Binary files /dev/null and b/src/audio/samples/Dsharp5v16.m4a differ
diff --git a/src/audio/samples/Dsharp5v4.m4a b/src/audio/samples/Dsharp5v4.m4a
new file mode 100644
index 0000000..fa6c930
Binary files /dev/null and b/src/audio/samples/Dsharp5v4.m4a differ
diff --git a/src/audio/samples/Dsharp5v8.m4a b/src/audio/samples/Dsharp5v8.m4a
new file mode 100644
index 0000000..21dce22
Binary files /dev/null and b/src/audio/samples/Dsharp5v8.m4a differ
diff --git a/src/audio/samples/Dsharp6v12.m4a b/src/audio/samples/Dsharp6v12.m4a
index b5ff787..aaf59ee 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
new file mode 100644
index 0000000..58762f4
Binary files /dev/null and b/src/audio/samples/Dsharp6v16.m4a differ
diff --git a/src/audio/samples/Dsharp6v4.m4a b/src/audio/samples/Dsharp6v4.m4a
new file mode 100644
index 0000000..b617ba3
Binary files /dev/null and b/src/audio/samples/Dsharp6v4.m4a differ
diff --git a/src/audio/samples/Dsharp6v8.m4a b/src/audio/samples/Dsharp6v8.m4a
new file mode 100644
index 0000000..a7704ea
Binary files /dev/null and b/src/audio/samples/Dsharp6v8.m4a differ
diff --git a/src/audio/samples/Dsharp7v12.m4a b/src/audio/samples/Dsharp7v12.m4a
index a9b6cda..950034f 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
new file mode 100644
index 0000000..81667cb
Binary files /dev/null and b/src/audio/samples/Dsharp7v16.m4a differ
diff --git a/src/audio/samples/Dsharp7v4.m4a b/src/audio/samples/Dsharp7v4.m4a
new file mode 100644
index 0000000..d49a972
Binary files /dev/null and b/src/audio/samples/Dsharp7v4.m4a differ
diff --git a/src/audio/samples/Dsharp7v8.m4a b/src/audio/samples/Dsharp7v8.m4a
new file mode 100644
index 0000000..c57ebae
Binary files /dev/null and b/src/audio/samples/Dsharp7v8.m4a differ
diff --git a/src/audio/samples/Fsharp1v12.m4a b/src/audio/samples/Fsharp1v12.m4a
index 752590f..3b941c8 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
new file mode 100644
index 0000000..71c269b
Binary files /dev/null and b/src/audio/samples/Fsharp1v16.m4a differ
diff --git a/src/audio/samples/Fsharp1v4.m4a b/src/audio/samples/Fsharp1v4.m4a
new file mode 100644
index 0000000..c12ccb1
Binary files /dev/null and b/src/audio/samples/Fsharp1v4.m4a differ
diff --git a/src/audio/samples/Fsharp1v8.m4a b/src/audio/samples/Fsharp1v8.m4a
new file mode 100644
index 0000000..799e5ee
Binary files /dev/null and b/src/audio/samples/Fsharp1v8.m4a differ
diff --git a/src/audio/samples/Fsharp2v12.m4a b/src/audio/samples/Fsharp2v12.m4a
index 3477cb8..2779ee3 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
new file mode 100644
index 0000000..219ab97
Binary files /dev/null and b/src/audio/samples/Fsharp2v16.m4a differ
diff --git a/src/audio/samples/Fsharp2v4.m4a b/src/audio/samples/Fsharp2v4.m4a
new file mode 100644
index 0000000..ca98511
Binary files /dev/null and b/src/audio/samples/Fsharp2v4.m4a differ
diff --git a/src/audio/samples/Fsharp2v8.m4a b/src/audio/samples/Fsharp2v8.m4a
new file mode 100644
index 0000000..e3a9480
Binary files /dev/null and b/src/audio/samples/Fsharp2v8.m4a differ
diff --git a/src/audio/samples/Fsharp3v12.m4a b/src/audio/samples/Fsharp3v12.m4a
index d36f8dd..85424f5 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
new file mode 100644
index 0000000..939721f
Binary files /dev/null and b/src/audio/samples/Fsharp3v16.m4a differ
diff --git a/src/audio/samples/Fsharp3v4.m4a b/src/audio/samples/Fsharp3v4.m4a
new file mode 100644
index 0000000..d64957d
Binary files /dev/null and b/src/audio/samples/Fsharp3v4.m4a differ
diff --git a/src/audio/samples/Fsharp3v8.m4a b/src/audio/samples/Fsharp3v8.m4a
new file mode 100644
index 0000000..5dd56f5
Binary files /dev/null and b/src/audio/samples/Fsharp3v8.m4a differ
diff --git a/src/audio/samples/Fsharp4v12.m4a b/src/audio/samples/Fsharp4v12.m4a
index 21df1e2..81309f1 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
new file mode 100644
index 0000000..2b60946
Binary files /dev/null and b/src/audio/samples/Fsharp4v16.m4a differ
diff --git a/src/audio/samples/Fsharp4v4.m4a b/src/audio/samples/Fsharp4v4.m4a
new file mode 100644
index 0000000..553e183
Binary files /dev/null and b/src/audio/samples/Fsharp4v4.m4a differ
diff --git a/src/audio/samples/Fsharp4v8.m4a b/src/audio/samples/Fsharp4v8.m4a
new file mode 100644
index 0000000..3d9454b
Binary files /dev/null and b/src/audio/samples/Fsharp4v8.m4a differ
diff --git a/src/audio/samples/Fsharp5v12.m4a b/src/audio/samples/Fsharp5v12.m4a
index 1105dfb..d09214c 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
new file mode 100644
index 0000000..b43b919
Binary files /dev/null and b/src/audio/samples/Fsharp5v16.m4a differ
diff --git a/src/audio/samples/Fsharp5v4.m4a b/src/audio/samples/Fsharp5v4.m4a
new file mode 100644
index 0000000..c3eaac2
Binary files /dev/null and b/src/audio/samples/Fsharp5v4.m4a differ
diff --git a/src/audio/samples/Fsharp5v8.m4a b/src/audio/samples/Fsharp5v8.m4a
new file mode 100644
index 0000000..79fb2f6
Binary files /dev/null and b/src/audio/samples/Fsharp5v8.m4a differ
diff --git a/src/audio/samples/Fsharp6v12.m4a b/src/audio/samples/Fsharp6v12.m4a
index d141d41..b92adb7 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
new file mode 100644
index 0000000..9b228c5
Binary files /dev/null and b/src/audio/samples/Fsharp6v16.m4a differ
diff --git a/src/audio/samples/Fsharp6v4.m4a b/src/audio/samples/Fsharp6v4.m4a
new file mode 100644
index 0000000..c9a1691
Binary files /dev/null and b/src/audio/samples/Fsharp6v4.m4a differ
diff --git a/src/audio/samples/Fsharp6v8.m4a b/src/audio/samples/Fsharp6v8.m4a
new file mode 100644
index 0000000..7c122bd
Binary files /dev/null and b/src/audio/samples/Fsharp6v8.m4a differ
diff --git a/src/audio/samples/Fsharp7v12.m4a b/src/audio/samples/Fsharp7v12.m4a
index d69ac59..2ad18ba 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
new file mode 100644
index 0000000..e4644a2
Binary files /dev/null and b/src/audio/samples/Fsharp7v16.m4a differ
diff --git a/src/audio/samples/Fsharp7v4.m4a b/src/audio/samples/Fsharp7v4.m4a
new file mode 100644
index 0000000..f3f9787
Binary files /dev/null and b/src/audio/samples/Fsharp7v4.m4a differ
diff --git a/src/audio/samples/Fsharp7v8.m4a b/src/audio/samples/Fsharp7v8.m4a
new file mode 100644
index 0000000..d9020b6
Binary files /dev/null and b/src/audio/samples/Fsharp7v8.m4a differ
diff --git a/src/audio/samples/README.md b/src/audio/samples/README.md
index bdde746..b37247b 100644
--- a/src/audio/samples/README.md
+++ b/src/audio/samples/README.md
@@ -2,15 +2,25 @@ 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 package: @audio-samples/piano-velocity12
+Source packages:
+
+- @audio-samples/piano-velocity4
+- @audio-samples/piano-velocity8
+- @audio-samples/piano-velocity12
+- @audio-samples/piano-velocity16
+- @audio-samples/piano-release
+
Source recording: https://archive.org/details/SalamanderGrandPianoV3
License: https://creativecommons.org/licenses/by/3.0/
-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`.
+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.
-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`.
+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`.
diff --git a/src/audio/samples/rel1.m4a b/src/audio/samples/rel1.m4a
new file mode 100644
index 0000000..8cf2d5b
Binary files /dev/null and b/src/audio/samples/rel1.m4a differ
diff --git a/src/audio/samples/rel10.m4a b/src/audio/samples/rel10.m4a
new file mode 100644
index 0000000..6cad512
Binary files /dev/null and b/src/audio/samples/rel10.m4a differ
diff --git a/src/audio/samples/rel11.m4a b/src/audio/samples/rel11.m4a
new file mode 100644
index 0000000..0b386b3
Binary files /dev/null and b/src/audio/samples/rel11.m4a differ
diff --git a/src/audio/samples/rel12.m4a b/src/audio/samples/rel12.m4a
new file mode 100644
index 0000000..c870544
Binary files /dev/null and b/src/audio/samples/rel12.m4a differ
diff --git a/src/audio/samples/rel13.m4a b/src/audio/samples/rel13.m4a
new file mode 100644
index 0000000..5849362
Binary files /dev/null and b/src/audio/samples/rel13.m4a differ
diff --git a/src/audio/samples/rel14.m4a b/src/audio/samples/rel14.m4a
new file mode 100644
index 0000000..0dfc5a0
Binary files /dev/null and b/src/audio/samples/rel14.m4a differ
diff --git a/src/audio/samples/rel15.m4a b/src/audio/samples/rel15.m4a
new file mode 100644
index 0000000..54b6acf
Binary files /dev/null and b/src/audio/samples/rel15.m4a differ
diff --git a/src/audio/samples/rel16.m4a b/src/audio/samples/rel16.m4a
new file mode 100644
index 0000000..863f1ff
Binary files /dev/null and b/src/audio/samples/rel16.m4a differ
diff --git a/src/audio/samples/rel17.m4a b/src/audio/samples/rel17.m4a
new file mode 100644
index 0000000..591ee71
Binary files /dev/null and b/src/audio/samples/rel17.m4a differ
diff --git a/src/audio/samples/rel18.m4a b/src/audio/samples/rel18.m4a
new file mode 100644
index 0000000..77533bf
Binary files /dev/null and b/src/audio/samples/rel18.m4a differ
diff --git a/src/audio/samples/rel19.m4a b/src/audio/samples/rel19.m4a
new file mode 100644
index 0000000..89c7b27
Binary files /dev/null and b/src/audio/samples/rel19.m4a differ
diff --git a/src/audio/samples/rel2.m4a b/src/audio/samples/rel2.m4a
new file mode 100644
index 0000000..dd5ab64
Binary files /dev/null and b/src/audio/samples/rel2.m4a differ
diff --git a/src/audio/samples/rel20.m4a b/src/audio/samples/rel20.m4a
new file mode 100644
index 0000000..874cbfe
Binary files /dev/null and b/src/audio/samples/rel20.m4a differ
diff --git a/src/audio/samples/rel21.m4a b/src/audio/samples/rel21.m4a
new file mode 100644
index 0000000..daafbef
Binary files /dev/null and b/src/audio/samples/rel21.m4a differ
diff --git a/src/audio/samples/rel22.m4a b/src/audio/samples/rel22.m4a
new file mode 100644
index 0000000..0dbe681
Binary files /dev/null and b/src/audio/samples/rel22.m4a differ
diff --git a/src/audio/samples/rel23.m4a b/src/audio/samples/rel23.m4a
new file mode 100644
index 0000000..ff51cbc
Binary files /dev/null and b/src/audio/samples/rel23.m4a differ
diff --git a/src/audio/samples/rel24.m4a b/src/audio/samples/rel24.m4a
new file mode 100644
index 0000000..b5515e5
Binary files /dev/null and b/src/audio/samples/rel24.m4a differ
diff --git a/src/audio/samples/rel25.m4a b/src/audio/samples/rel25.m4a
new file mode 100644
index 0000000..12ccd06
Binary files /dev/null and b/src/audio/samples/rel25.m4a differ
diff --git a/src/audio/samples/rel26.m4a b/src/audio/samples/rel26.m4a
new file mode 100644
index 0000000..81e3075
Binary files /dev/null and b/src/audio/samples/rel26.m4a differ
diff --git a/src/audio/samples/rel27.m4a b/src/audio/samples/rel27.m4a
new file mode 100644
index 0000000..25ebe1e
Binary files /dev/null and b/src/audio/samples/rel27.m4a differ
diff --git a/src/audio/samples/rel28.m4a b/src/audio/samples/rel28.m4a
new file mode 100644
index 0000000..d49226d
Binary files /dev/null and b/src/audio/samples/rel28.m4a differ
diff --git a/src/audio/samples/rel29.m4a b/src/audio/samples/rel29.m4a
new file mode 100644
index 0000000..6fd7267
Binary files /dev/null and b/src/audio/samples/rel29.m4a differ
diff --git a/src/audio/samples/rel3.m4a b/src/audio/samples/rel3.m4a
new file mode 100644
index 0000000..9d67e09
Binary files /dev/null and b/src/audio/samples/rel3.m4a differ
diff --git a/src/audio/samples/rel30.m4a b/src/audio/samples/rel30.m4a
new file mode 100644
index 0000000..3312f64
Binary files /dev/null and b/src/audio/samples/rel30.m4a differ
diff --git a/src/audio/samples/rel31.m4a b/src/audio/samples/rel31.m4a
new file mode 100644
index 0000000..2543ce7
Binary files /dev/null and b/src/audio/samples/rel31.m4a differ
diff --git a/src/audio/samples/rel32.m4a b/src/audio/samples/rel32.m4a
new file mode 100644
index 0000000..b06a0f9
Binary files /dev/null and b/src/audio/samples/rel32.m4a differ
diff --git a/src/audio/samples/rel33.m4a b/src/audio/samples/rel33.m4a
new file mode 100644
index 0000000..40de0b5
Binary files /dev/null and b/src/audio/samples/rel33.m4a differ
diff --git a/src/audio/samples/rel34.m4a b/src/audio/samples/rel34.m4a
new file mode 100644
index 0000000..52761b1
Binary files /dev/null and b/src/audio/samples/rel34.m4a differ
diff --git a/src/audio/samples/rel35.m4a b/src/audio/samples/rel35.m4a
new file mode 100644
index 0000000..3603cf2
Binary files /dev/null and b/src/audio/samples/rel35.m4a differ
diff --git a/src/audio/samples/rel36.m4a b/src/audio/samples/rel36.m4a
new file mode 100644
index 0000000..e16e5f4
Binary files /dev/null and b/src/audio/samples/rel36.m4a differ
diff --git a/src/audio/samples/rel37.m4a b/src/audio/samples/rel37.m4a
new file mode 100644
index 0000000..c0c316c
Binary files /dev/null and b/src/audio/samples/rel37.m4a differ
diff --git a/src/audio/samples/rel38.m4a b/src/audio/samples/rel38.m4a
new file mode 100644
index 0000000..4b1496f
Binary files /dev/null and b/src/audio/samples/rel38.m4a differ
diff --git a/src/audio/samples/rel39.m4a b/src/audio/samples/rel39.m4a
new file mode 100644
index 0000000..af13d13
Binary files /dev/null and b/src/audio/samples/rel39.m4a differ
diff --git a/src/audio/samples/rel4.m4a b/src/audio/samples/rel4.m4a
new file mode 100644
index 0000000..addaa1f
Binary files /dev/null and b/src/audio/samples/rel4.m4a differ
diff --git a/src/audio/samples/rel40.m4a b/src/audio/samples/rel40.m4a
new file mode 100644
index 0000000..41fb5b8
Binary files /dev/null and b/src/audio/samples/rel40.m4a differ
diff --git a/src/audio/samples/rel41.m4a b/src/audio/samples/rel41.m4a
new file mode 100644
index 0000000..bfa11c8
Binary files /dev/null and b/src/audio/samples/rel41.m4a differ
diff --git a/src/audio/samples/rel42.m4a b/src/audio/samples/rel42.m4a
new file mode 100644
index 0000000..d6439fa
Binary files /dev/null and b/src/audio/samples/rel42.m4a differ
diff --git a/src/audio/samples/rel43.m4a b/src/audio/samples/rel43.m4a
new file mode 100644
index 0000000..5b94878
Binary files /dev/null and b/src/audio/samples/rel43.m4a differ
diff --git a/src/audio/samples/rel44.m4a b/src/audio/samples/rel44.m4a
new file mode 100644
index 0000000..ba6b1d3
Binary files /dev/null and b/src/audio/samples/rel44.m4a differ
diff --git a/src/audio/samples/rel45.m4a b/src/audio/samples/rel45.m4a
new file mode 100644
index 0000000..4eab6bc
Binary files /dev/null and b/src/audio/samples/rel45.m4a differ
diff --git a/src/audio/samples/rel46.m4a b/src/audio/samples/rel46.m4a
new file mode 100644
index 0000000..42a0fb3
Binary files /dev/null and b/src/audio/samples/rel46.m4a differ
diff --git a/src/audio/samples/rel47.m4a b/src/audio/samples/rel47.m4a
new file mode 100644
index 0000000..b7fa1a7
Binary files /dev/null and b/src/audio/samples/rel47.m4a differ
diff --git a/src/audio/samples/rel48.m4a b/src/audio/samples/rel48.m4a
new file mode 100644
index 0000000..3234c08
Binary files /dev/null and b/src/audio/samples/rel48.m4a differ
diff --git a/src/audio/samples/rel49.m4a b/src/audio/samples/rel49.m4a
new file mode 100644
index 0000000..6e49637
Binary files /dev/null and b/src/audio/samples/rel49.m4a differ
diff --git a/src/audio/samples/rel5.m4a b/src/audio/samples/rel5.m4a
new file mode 100644
index 0000000..17c9856
Binary files /dev/null and b/src/audio/samples/rel5.m4a differ
diff --git a/src/audio/samples/rel50.m4a b/src/audio/samples/rel50.m4a
new file mode 100644
index 0000000..dd01182
Binary files /dev/null and b/src/audio/samples/rel50.m4a differ
diff --git a/src/audio/samples/rel51.m4a b/src/audio/samples/rel51.m4a
new file mode 100644
index 0000000..c875276
Binary files /dev/null and b/src/audio/samples/rel51.m4a differ
diff --git a/src/audio/samples/rel52.m4a b/src/audio/samples/rel52.m4a
new file mode 100644
index 0000000..d49ebb4
Binary files /dev/null and b/src/audio/samples/rel52.m4a differ
diff --git a/src/audio/samples/rel53.m4a b/src/audio/samples/rel53.m4a
new file mode 100644
index 0000000..d599e0d
Binary files /dev/null and b/src/audio/samples/rel53.m4a differ
diff --git a/src/audio/samples/rel54.m4a b/src/audio/samples/rel54.m4a
new file mode 100644
index 0000000..7dc6bd5
Binary files /dev/null and b/src/audio/samples/rel54.m4a differ
diff --git a/src/audio/samples/rel55.m4a b/src/audio/samples/rel55.m4a
new file mode 100644
index 0000000..4534017
Binary files /dev/null and b/src/audio/samples/rel55.m4a differ
diff --git a/src/audio/samples/rel56.m4a b/src/audio/samples/rel56.m4a
new file mode 100644
index 0000000..aac1106
Binary files /dev/null and b/src/audio/samples/rel56.m4a differ
diff --git a/src/audio/samples/rel57.m4a b/src/audio/samples/rel57.m4a
new file mode 100644
index 0000000..a6b637b
Binary files /dev/null and b/src/audio/samples/rel57.m4a differ
diff --git a/src/audio/samples/rel58.m4a b/src/audio/samples/rel58.m4a
new file mode 100644
index 0000000..311e6bc
Binary files /dev/null and b/src/audio/samples/rel58.m4a differ
diff --git a/src/audio/samples/rel59.m4a b/src/audio/samples/rel59.m4a
new file mode 100644
index 0000000..6dc7ddc
Binary files /dev/null and b/src/audio/samples/rel59.m4a differ
diff --git a/src/audio/samples/rel6.m4a b/src/audio/samples/rel6.m4a
new file mode 100644
index 0000000..e805093
Binary files /dev/null and b/src/audio/samples/rel6.m4a differ
diff --git a/src/audio/samples/rel60.m4a b/src/audio/samples/rel60.m4a
new file mode 100644
index 0000000..60568da
Binary files /dev/null and b/src/audio/samples/rel60.m4a differ
diff --git a/src/audio/samples/rel61.m4a b/src/audio/samples/rel61.m4a
new file mode 100644
index 0000000..42e57d3
Binary files /dev/null and b/src/audio/samples/rel61.m4a differ
diff --git a/src/audio/samples/rel62.m4a b/src/audio/samples/rel62.m4a
new file mode 100644
index 0000000..386bc6f
Binary files /dev/null and b/src/audio/samples/rel62.m4a differ
diff --git a/src/audio/samples/rel63.m4a b/src/audio/samples/rel63.m4a
new file mode 100644
index 0000000..a2a5fc5
Binary files /dev/null and b/src/audio/samples/rel63.m4a differ
diff --git a/src/audio/samples/rel64.m4a b/src/audio/samples/rel64.m4a
new file mode 100644
index 0000000..b0795b0
Binary files /dev/null and b/src/audio/samples/rel64.m4a differ
diff --git a/src/audio/samples/rel65.m4a b/src/audio/samples/rel65.m4a
new file mode 100644
index 0000000..c51e877
Binary files /dev/null and b/src/audio/samples/rel65.m4a differ
diff --git a/src/audio/samples/rel66.m4a b/src/audio/samples/rel66.m4a
new file mode 100644
index 0000000..7307df4
Binary files /dev/null and b/src/audio/samples/rel66.m4a differ
diff --git a/src/audio/samples/rel67.m4a b/src/audio/samples/rel67.m4a
new file mode 100644
index 0000000..ede7dd2
Binary files /dev/null and b/src/audio/samples/rel67.m4a differ
diff --git a/src/audio/samples/rel68.m4a b/src/audio/samples/rel68.m4a
new file mode 100644
index 0000000..25d123f
Binary files /dev/null and b/src/audio/samples/rel68.m4a differ
diff --git a/src/audio/samples/rel69.m4a b/src/audio/samples/rel69.m4a
new file mode 100644
index 0000000..1102c0e
Binary files /dev/null and b/src/audio/samples/rel69.m4a differ
diff --git a/src/audio/samples/rel7.m4a b/src/audio/samples/rel7.m4a
new file mode 100644
index 0000000..91f5ef6
Binary files /dev/null and b/src/audio/samples/rel7.m4a differ
diff --git a/src/audio/samples/rel70.m4a b/src/audio/samples/rel70.m4a
new file mode 100644
index 0000000..7771d47
Binary files /dev/null and b/src/audio/samples/rel70.m4a differ
diff --git a/src/audio/samples/rel71.m4a b/src/audio/samples/rel71.m4a
new file mode 100644
index 0000000..03fcfd4
Binary files /dev/null and b/src/audio/samples/rel71.m4a differ
diff --git a/src/audio/samples/rel72.m4a b/src/audio/samples/rel72.m4a
new file mode 100644
index 0000000..f945bb9
Binary files /dev/null and b/src/audio/samples/rel72.m4a differ
diff --git a/src/audio/samples/rel73.m4a b/src/audio/samples/rel73.m4a
new file mode 100644
index 0000000..104ba8d
Binary files /dev/null and b/src/audio/samples/rel73.m4a differ
diff --git a/src/audio/samples/rel74.m4a b/src/audio/samples/rel74.m4a
new file mode 100644
index 0000000..88c6f8d
Binary files /dev/null and b/src/audio/samples/rel74.m4a differ
diff --git a/src/audio/samples/rel75.m4a b/src/audio/samples/rel75.m4a
new file mode 100644
index 0000000..8e32dd2
Binary files /dev/null and b/src/audio/samples/rel75.m4a differ
diff --git a/src/audio/samples/rel76.m4a b/src/audio/samples/rel76.m4a
new file mode 100644
index 0000000..e95f09d
Binary files /dev/null and b/src/audio/samples/rel76.m4a differ
diff --git a/src/audio/samples/rel77.m4a b/src/audio/samples/rel77.m4a
new file mode 100644
index 0000000..dab0b10
Binary files /dev/null and b/src/audio/samples/rel77.m4a differ
diff --git a/src/audio/samples/rel78.m4a b/src/audio/samples/rel78.m4a
new file mode 100644
index 0000000..0c9808c
Binary files /dev/null and b/src/audio/samples/rel78.m4a differ
diff --git a/src/audio/samples/rel79.m4a b/src/audio/samples/rel79.m4a
new file mode 100644
index 0000000..75d2f1e
Binary files /dev/null and b/src/audio/samples/rel79.m4a differ
diff --git a/src/audio/samples/rel8.m4a b/src/audio/samples/rel8.m4a
new file mode 100644
index 0000000..69c5036
Binary files /dev/null and b/src/audio/samples/rel8.m4a differ
diff --git a/src/audio/samples/rel80.m4a b/src/audio/samples/rel80.m4a
new file mode 100644
index 0000000..f43ae99
Binary files /dev/null and b/src/audio/samples/rel80.m4a differ
diff --git a/src/audio/samples/rel81.m4a b/src/audio/samples/rel81.m4a
new file mode 100644
index 0000000..7a6198f
Binary files /dev/null and b/src/audio/samples/rel81.m4a differ
diff --git a/src/audio/samples/rel82.m4a b/src/audio/samples/rel82.m4a
new file mode 100644
index 0000000..19a1c8e
Binary files /dev/null and b/src/audio/samples/rel82.m4a differ
diff --git a/src/audio/samples/rel83.m4a b/src/audio/samples/rel83.m4a
new file mode 100644
index 0000000..78ca8a2
Binary files /dev/null and b/src/audio/samples/rel83.m4a differ
diff --git a/src/audio/samples/rel84.m4a b/src/audio/samples/rel84.m4a
new file mode 100644
index 0000000..3f4d8c5
Binary files /dev/null and b/src/audio/samples/rel84.m4a differ
diff --git a/src/audio/samples/rel85.m4a b/src/audio/samples/rel85.m4a
new file mode 100644
index 0000000..afa6e8e
Binary files /dev/null and b/src/audio/samples/rel85.m4a differ
diff --git a/src/audio/samples/rel86.m4a b/src/audio/samples/rel86.m4a
new file mode 100644
index 0000000..26977b1
Binary files /dev/null and b/src/audio/samples/rel86.m4a differ
diff --git a/src/audio/samples/rel87.m4a b/src/audio/samples/rel87.m4a
new file mode 100644
index 0000000..666d1d8
Binary files /dev/null and b/src/audio/samples/rel87.m4a differ
diff --git a/src/audio/samples/rel88.m4a b/src/audio/samples/rel88.m4a
new file mode 100644
index 0000000..6045663
Binary files /dev/null and b/src/audio/samples/rel88.m4a differ
diff --git a/src/audio/samples/rel9.m4a b/src/audio/samples/rel9.m4a
new file mode 100644
index 0000000..1d2be34
Binary files /dev/null and b/src/audio/samples/rel9.m4a differ
diff --git a/src/config.ts b/src/config.ts
index e0e6b19..37e7f78 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -1,199 +1,9 @@
-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 { defaultSettings } from './config/default-settings';
export {
normalizeNumberControlValue,
normalizeRuntimeSettings,
} from './config/normalize-runtime-settings';
+export { runtimeControls } from './config/runtime-controls';
+export { defaultVibeId, vibePresets } from './config/vibe-presets';
-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;
+export type { GardenRuntimeSettings, NumberControlConfig } from './config/types';
diff --git a/src/config/default-settings.ts b/src/config/default-settings.ts
index 37ce510..ba59a1e 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 { GardenAppConfig } from './types';
+import type { GardenDefaultSettings } 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: GardenAppConfig['defaultSettings'] = {
+export const defaultSettings: GardenDefaultSettings = {
selectedColorIndex: 0,
introNearDistanceMin: 28,
diff --git a/src/config/eraser-size.ts b/src/config/eraser-size.ts
new file mode 100644
index 0000000..008bee7
--- /dev/null
+++ b/src/config/eraser-size.ts
@@ -0,0 +1,84 @@
+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 ec4dcf5..60cbf38 100644
--- a/src/config/normalize-runtime-settings.ts
+++ b/src/config/normalize-runtime-settings.ts
@@ -1,11 +1,9 @@
import type {
- GardenAppConfig,
GardenRuntimeSettings,
NumberControlConfig,
+ RuntimeSettingControlConfig,
} from './types';
-type RuntimeSettingControls = GardenAppConfig['runtimeSettings']['controls'];
-
export const normalizeNumberControlValue = (
value: number,
config: NumberControlConfig
@@ -28,7 +26,7 @@ export const normalizeNumberControlValue = (
export const normalizeRuntimeSettings = (
settings: GardenRuntimeSettings,
- controls: RuntimeSettingControls
+ controls: RuntimeSettingControlConfig
): GardenRuntimeSettings => {
const normalized = { ...settings };
diff --git a/src/config/runtime-controls.ts b/src/config/runtime-controls.ts
index e6b8a70..463735a 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 { GardenAppConfig } from './types';
+import type { RuntimeSettingControlConfig } 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: GardenAppConfig['runtimeSettings']['controls'] = {
+export const runtimeControls: RuntimeSettingControlConfig = {
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 6f9aa29..cd94c9a 100644
--- a/src/config/types.ts
+++ b/src/config/types.ts
@@ -1,7 +1,4 @@
-import type {
- GardenAudioConfig,
- GardenAudioVibeSettings,
-} from '../audio/garden-audio-config';
+import type { 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';
@@ -49,7 +46,7 @@ export type GardenRuntimeSettings = {
DiffusionSettings &
RenderSettings;
-type RuntimeSettingControlConfig = Partial<
+export type RuntimeSettingControlConfig = Partial<
Record
>;
@@ -79,7 +76,7 @@ export type GardenVibeSettings = Pick<
| 'turnWhenLost'
>;
-type GardenDefaultSettings = Omit<
+export type GardenDefaultSettings = Omit<
GardenRuntimeSettings,
keyof GardenVibeSettings | 'eraserSize' | 'mirrorSegmentCount'
>;
@@ -101,169 +98,3 @@ 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 2bc0e26..7232781 100644
--- a/src/game-loop/agent-population.test.ts
+++ b/src/game-loop/agent-population.test.ts
@@ -1,11 +1,14 @@
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 } from './agent-population';
+import {
+ AgentPopulation,
+ STROKE_AGENT_BATCH_CAPACITY,
+ STROKE_DENSITY_MULTIPLIER,
+} from './agent-population';
import { type FramePerformance } from './frame-performance';
const originalSettings = {
@@ -70,7 +73,7 @@ const createPopulation = (): {
};
const setSpawnRate = (agentsPerPixel: number): void => {
- settings.spawnPerPixel = agentsPerPixel / appConfig.simulation.stroke.densityMultiplier;
+ settings.spawnPerPixel = agentsPerPixel / STROKE_DENSITY_MULTIPLIER;
};
describe('AgentPopulation stroke spawning', () => {
@@ -124,7 +127,7 @@ describe('AgentPopulation stroke spawning', () => {
it('chunks long stroke writes without clipping length-linear spawn counts', () => {
const { pipeline, population } = createPopulation();
- const batchCapacity = appConfig.simulation.stroke.maxAgentCount;
+ const batchCapacity = STROKE_AGENT_BATCH_CAPACITY;
const expectedAgentCount = batchCapacity + 10;
population.spawnStrokeAgents(
diff --git a/src/game-loop/agent-population.ts b/src/game-loop/agent-population.ts
index 1d0390f..019a74f 100644
--- a/src/game-loop/agent-population.ts
+++ b/src/game-loop/agent-population.ts
@@ -1,6 +1,5 @@
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';
@@ -9,6 +8,11 @@ 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.
@@ -22,7 +26,7 @@ export class AgentPopulation {
private readonly queuedAgentBatches: Array = [];
private pendingStrokeAgentCount = 0;
private readonly strokeAgentData = new Float32Array(
- appConfig.simulation.stroke.maxAgentCount * AGENT_FLOAT_COUNT
+ STROKE_AGENT_BATCH_CAPACITY * AGENT_FLOAT_COUNT
);
public constructor(
@@ -46,10 +50,7 @@ export class AgentPopulation {
public replaceIntroAgents(canvasSize: vec2, progress: number): void {
this.adaptiveCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
- const introAgentCount = Math.min(
- this.adaptiveCap,
- appConfig.simulation.initialAgentCount
- );
+ const introAgentCount = Math.min(this.adaptiveCap, INITIAL_INTRO_AGENT_COUNT);
const data = createIntroTitleAgents({
count: introAgentCount,
width: canvasSize[0],
@@ -332,8 +333,5 @@ const getStrokeSpawnRate = (): number => {
const spawnPerPixel = Number.isFinite(settings.spawnPerPixel)
? settings.spawnPerPixel
: 0;
- const densityMultiplier = Number.isFinite(appConfig.simulation.stroke.densityMultiplier)
- ? appConfig.simulation.stroke.densityMultiplier
- : 0;
- return Math.max(0, spawnPerPixel * densityMultiplier);
+ return Math.max(0, spawnPerPixel * STROKE_DENSITY_MULTIPLIER);
};
diff --git a/src/game-loop/brush-stroke-smoother.ts b/src/game-loop/brush-stroke-smoother.ts
index 3d45283..918b95c 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 { appConfig } from '../config';
+import { defaultSettings } 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
- : appConfig.defaultSettings.brushCurveResolution;
+ : defaultSettings.brushCurveResolution;
return Math.max(1, Math.floor(resolution));
};
const getBrushSmoothingDistanceSquared = (pixelRatio?: number): number => {
const distance = Number.isFinite(settings.brushSmoothingMinSampleDistance)
? settings.brushSmoothingMinSampleDistance
- : appConfig.defaultSettings.brushSmoothingMinSampleDistance;
+ : 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 fefd93c..493d497 100644
--- a/src/game-loop/eraser-preview.ts
+++ b/src/game-loop/eraser-preview.ts
@@ -1,3 +1,4 @@
+import { getEffectiveEraserSize } from '../config/eraser-size';
import { settings } from '../settings';
export class EraserPreview {
@@ -49,9 +50,15 @@ export class EraserPreview {
};
}
- if (this.previousSize !== settings.eraserSize) {
- this.element.style.setProperty('--eraser-preview-size', `${settings.eraserSize}px`);
- this.previousSize = settings.eraserSize;
+ 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 (
@@ -63,7 +70,6 @@ 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 0be8911..db675cc 100644
--- a/src/game-loop/export-snapshot-renderer.ts
+++ b/src/game-loop/export-snapshot-renderer.ts
@@ -1,7 +1,13 @@
-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;
@@ -121,15 +127,15 @@ export class ExportSnapshotRenderer {
context.putImageData(new ImageData(pixels, width, height), 0, 0);
const blob = await canvas.convertToBlob({
- type: appConfig.exportSnapshot.mimeType,
+ type: SNAPSHOT_MIME_TYPE,
});
const link = document.createElement('a');
const objectUrl = URL.createObjectURL(blob);
try {
link.href = objectUrl;
- link.download = `${appConfig.exportSnapshot.filenamePrefix}_${this.options.getVibeId()}_${
+ link.download = `${SNAPSHOT_FILENAME_PREFIX}_${this.options.getVibeId()}_${
this.options.seed
- }_${width}x${height}${appConfig.exportSnapshot.filenameSuffix}.${appConfig.exportSnapshot.filenameExtension}`;
+ }_${width}x${height}${SNAPSHOT_FILENAME_SUFFIX}.${SNAPSHOT_FILENAME_EXTENSION}`;
link.click();
} finally {
URL.revokeObjectURL(objectUrl);
@@ -154,11 +160,8 @@ const getSnapshotDimension = (value: number): number =>
const getSnapshotLayout = (sourceWidth: number, sourceHeight: number): SnapshotLayout => {
const width = getSnapshotDimension(sourceWidth);
const height = getSnapshotDimension(sourceHeight);
- const unpaddedBytesPerRow = width * appConfig.exportSnapshot.bytesPerPixel;
- const bytesPerRow = alignTo(
- unpaddedBytesPerRow,
- appConfig.exportSnapshot.rowAlignmentBytes
- );
+ const unpaddedBytesPerRow = width * SNAPSHOT_BYTES_PER_PIXEL;
+ const bytesPerRow = alignTo(unpaddedBytesPerRow, SNAPSHOT_ROW_ALIGNMENT_BYTES);
return {
width,
@@ -191,8 +194,8 @@ const readSnapshotPixels = ({
const sourceOffset = y * bytesPerRow;
const targetOffset = y * unpaddedBytesPerRow;
for (let x = 0; x < width; x++) {
- const source = sourceOffset + x * appConfig.exportSnapshot.bytesPerPixel;
- const target = targetOffset + x * appConfig.exportSnapshot.bytesPerPixel;
+ const source = sourceOffset + x * SNAPSHOT_BYTES_PER_PIXEL;
+ const target = targetOffset + x * SNAPSHOT_BYTES_PER_PIXEL;
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 64c9680..a380c23 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 { appConfig, type GardenRuntimeSettings } from '../config';
+import { 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,9 +12,12 @@ 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;
@@ -78,7 +81,7 @@ export class GameLoopResources {
this.renderPipeline = new RenderPipeline(context, this.device, this.canvasFormat);
this.gpuProfiler = GpuProfiler.create(
this.device,
- () => appConfig.tuningPane.showFpsOverlay
+ () => perfStatsOverlayState.isVisible
);
this.frameRenderer = new SimulationFrameRenderer(
@@ -134,7 +137,7 @@ export class GameLoopResources {
deltaTime,
time,
agentCount: activeAgentCount,
- introMoveSpeed: appConfig.simulation.introMoveSpeed,
+ introMoveSpeed: INTRO_MOVE_SPEED,
introProgress,
});
this.brushPipeline.setParameters({
diff --git a/src/game-loop/game-loop.ts b/src/game-loop/game-loop.ts
index aed5289..d53a73c 100644
--- a/src/game-loop/game-loop.ts
+++ b/src/game-loop/game-loop.ts
@@ -1,7 +1,8 @@
import { vec2 } from 'gl-matrix';
import { GardenAudio } from '../audio/garden-audio';
-import { appConfig } from '../config';
+import { createGardenAudioConfig } from '../audio/garden-audio-config';
+import { getEffectiveEraserSize, getElementCssPixelSize } from '../config/eraser-size';
import { activeVibe, settings } from '../settings';
import { DeltaTimeCalculator } from '../utils/delta-time-calculator';
import { rgbColorToCss, type RgbColor } from '../utils/rgb-color';
@@ -13,14 +14,19 @@ 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 } from './perf-stats-overlay';
+import { PerfStatsOverlay, perfStatsOverlayState } 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(appConfig.audio);
+ private readonly audio = new GardenAudio(GARDEN_AUDIO_CONFIG);
private readonly introPrompt: IntroPrompt;
private readonly eraserPreview: EraserPreview;
private readonly pointerInput: GardenPointerInput;
@@ -204,7 +210,11 @@ export default class GameLoop {
const runtimeSettings = { ...settings };
const introProgress = this.introPrompt.progress;
const canvasPixelRatio = this.canvasPixelRatio;
- const eraserPixelSize = runtimeSettings.eraserSize * canvasPixelRatio;
+ const eraserCssSize = getEffectiveEraserSize(
+ runtimeSettings.eraserSize,
+ getElementCssPixelSize(this.canvas)
+ );
+ const eraserPixelSize = eraserCssSize * canvasPixelRatio;
const isErasing = this.pointerInput.isEraseMode;
const accentColor =
channelColors[runtimeSettings.selectedColorIndex] ?? channelColors[0];
@@ -250,7 +260,7 @@ export default class GameLoop {
};
private syncPerfStatsOverlay(): void {
- if (appConfig.tuningPane.showFpsOverlay) {
+ if (perfStatsOverlayState.isVisible) {
this.perfStatsOverlay ??= new PerfStatsOverlay(
this.canvas.parentElement ?? document.body
);
@@ -323,13 +333,11 @@ export default class GameLoop {
return;
}
- if (time - this.pendingIntroResizeAt < appConfig.simulation.intro.resizeSettleMs) {
+ if (time - this.pendingIntroResizeAt < INTRO_RESIZE_SETTLE_MS) {
return;
}
- this.introPrompt.rewindToLeaveRemainingTime(
- appConfig.simulation.intro.resizeMinimumRemainingSeconds
- );
+ this.introPrompt.rewindToLeaveRemainingTime(INTRO_RESIZE_MINIMUM_REMAINING_SECONDS);
this.resources.clearSimulation();
this.agentPopulation.replaceIntroAgents(this.canvasSize, this.introPrompt.progress);
this.pendingIntroResizeAt = null;
@@ -351,10 +359,10 @@ export default class GameLoop {
private get mirrorSegmentCount(): number {
const count = Number.isFinite(settings.mirrorSegmentCount)
? settings.mirrorSegmentCount
- : appConfig.toolbar.mirror.min;
+ : MIN_MIRROR_SEGMENT_COUNT;
return Math.min(
- appConfig.toolbar.mirror.max,
- Math.max(appConfig.toolbar.mirror.min, Math.round(count))
+ MAX_MIRROR_SEGMENT_COUNT,
+ Math.max(MIN_MIRROR_SEGMENT_COUNT, Math.round(count))
);
}
diff --git a/src/game-loop/intro-prompt.ts b/src/game-loop/intro-prompt.ts
index cd9468d..483f7f0 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,10 +13,7 @@ export class IntroPrompt {
public get progress(): number {
return this.introComplete
? 1
- : Math.min(
- 1,
- this.introElapsedSeconds / appConfig.simulation.intro.durationSeconds
- );
+ : Math.min(1, this.introElapsedSeconds / INTRO_DURATION_SECONDS);
}
public get shouldRegenerateTitleOnResize(): boolean {
@@ -33,7 +30,7 @@ export class IntroPrompt {
: 0;
this.introElapsedSeconds = Math.min(
this.introElapsedSeconds,
- Math.max(0, appConfig.simulation.intro.durationSeconds - safeRemainingSeconds)
+ Math.max(0, INTRO_DURATION_SECONDS - safeRemainingSeconds)
);
}
@@ -45,10 +42,7 @@ export class IntroPrompt {
this.introElapsedSeconds += safeDeltaTime;
}
- if (
- !this.introComplete &&
- this.introElapsedSeconds >= appConfig.simulation.intro.durationSeconds
- ) {
+ if (!this.introComplete && this.introElapsedSeconds >= INTRO_DURATION_SECONDS) {
this.complete(now);
}
@@ -56,7 +50,7 @@ export class IntroPrompt {
!this.introComplete ||
this.hasStartedDrawing ||
this.introCompletedAt === null ||
- now - this.introCompletedAt < appConfig.simulation.intro.drawHintDelayMs
+ now - this.introCompletedAt < DRAW_HINT_DELAY_MS
) {
return;
}
diff --git a/src/game-loop/intro-title-agents.ts b/src/game-loop/intro-title-agents.ts
index a34bbf4..168c106 100644
--- a/src/game-loop/intro-title-agents.ts
+++ b/src/game-loop/intro-title-agents.ts
@@ -1,4 +1,3 @@
-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';
@@ -18,9 +17,43 @@ interface IntroTitleAgentOptions {
}
type RandomSource = () => number;
-type IntroPathEasing = GardenAppConfig['simulation']['intro']['pathEasing'];
+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;
-const INTRO_TITLE = appConfig.simulation.intro.title;
const isLinearPathEasing = (pathEasing: IntroPathEasing): boolean =>
pathEasing === 'linear';
@@ -47,30 +80,27 @@ export const createIntroTitleAgents = ({
const data = new Float32Array(count * AGENT_FLOAT_COUNT);
const minSide = Math.min(safeWidth, safeHeight);
const targetJitter = Math.max(
- appConfig.simulation.intro.minTargetJitterPx,
- minSide * appConfig.simulation.intro.targetJitterSideRatio
+ INTRO_MIN_TARGET_JITTER_PX,
+ minSide * INTRO_TARGET_JITTER_SIDE_RATIO
);
const entryJitter = Math.max(
- appConfig.simulation.intro.minEntryJitterPx,
- minSide * appConfig.simulation.intro.entryJitterSideRatio
+ INTRO_MIN_ENTRY_JITTER_PX,
+ minSide * INTRO_ENTRY_JITTER_SIDE_RATIO
);
const titleRadius = points.reduce(
(radius, point) =>
Math.max(
radius,
- Math.hypot(
- point.x - safeWidth / 2,
- point.y - safeHeight * appConfig.simulation.intro.verticalAnchor
- )
+ Math.hypot(point.x - safeWidth / 2, point.y - safeHeight * INTRO_VERTICAL_ANCHOR)
),
0
);
const introCircleRadius = Math.min(
Math.max(
- titleRadius * appConfig.simulation.intro.titleRadiusMultiplier,
- minSide * appConfig.simulation.intro.circleMinSideRatio
+ titleRadius * INTRO_TITLE_RADIUS_MULTIPLIER,
+ minSide * INTRO_CIRCLE_MIN_SIDE_RATIO
),
- minSide * appConfig.simulation.intro.circleMaxSideRatio
+ minSide * INTRO_CIRCLE_MAX_SIDE_RATIO
);
for (let i = 0; i < count; i++) {
@@ -101,21 +131,16 @@ export const createIntroTitleAgents = ({
const distanceFraction =
Math.hypot(targetX - startX, targetY - startY) / Math.hypot(safeWidth, safeHeight);
const introDelay = Math.min(
- appConfig.simulation.intro.targetDelayMax,
- distanceFraction * appConfig.simulation.intro.targetDelayDistanceMultiplier +
- random() * appConfig.simulation.intro.targetDelayRandomMultiplier
+ INTRO_TARGET_DELAY_MAX,
+ distanceFraction * INTRO_TARGET_DELAY_DISTANCE_MULTIPLIER +
+ random() * INTRO_TARGET_DELAY_RANDOM_MULTIPLIER
);
const pathProgress = getIntroAgentPathProgress(introProgress, introDelay);
- const initialAngle =
- approachAngle + (random() - 0.5) * appConfig.simulation.intro.angleJitterRadians;
+ const initialAngle = approachAngle + (random() - 0.5) * INTRO_ANGLE_JITTER_RADIANS;
const currentAngle = mixAngle(
initialAngle,
targetAngle,
- smoothstep(
- appConfig.simulation.intro.angleEaseStart,
- appConfig.simulation.intro.angleEaseEnd,
- pathProgress
- )
+ smoothstep(INTRO_ANGLE_EASE_START, INTRO_ANGLE_EASE_END, pathProgress)
);
writeAgentValues(data, i, {
positionX: mix(startX, targetX, pathProgress),
@@ -142,12 +167,12 @@ const getIntroRadialStart = (
random: RandomSource
): [number, number] => {
const centerX = width / 2;
- const centerY = height * appConfig.simulation.intro.verticalAnchor;
+ const centerY = height * INTRO_VERTICAL_ANCHOR;
const offsetX = targetX - centerX;
const offsetY = targetY - centerY;
const length = Math.hypot(offsetX, offsetY);
const angle =
- length > appConfig.simulation.intro.radialStartEpsilon
+ length > INTRO_RADIAL_START_EPSILON
? Math.atan2(offsetY, offsetX)
: random() * Math.PI * 2;
const directionX = Math.cos(angle);
@@ -155,8 +180,7 @@ const getIntroRadialStart = (
const tangentX = -directionY;
const tangentY = directionX;
const tangentJitter = (random() - 0.5) * jitter;
- const radialJitter =
- (random() - 0.5) * jitter * appConfig.simulation.intro.radialJitterRatio;
+ const radialJitter = (random() - 0.5) * jitter * INTRO_RADIAL_JITTER_RATIO;
const startX =
centerX + directionX * (radius + radialJitter) + tangentX * tangentJitter;
const startY =
@@ -172,7 +196,7 @@ const createIntroTitlePoints = (
width: number,
height: number
): Array => {
- const safeMaxPixels = Math.max(1, appConfig.simulation.intro.maskMaxPixels);
+ const safeMaxPixels = Math.max(1, INTRO_MASK_MAX_PIXELS);
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));
@@ -188,28 +212,28 @@ const createIntroTitlePoints = (
const fontSize = getIntroTitleFontSize(context, maskWidth, maskHeight);
context.clearRect(0, 0, maskWidth, maskHeight);
- context.font = `${fontSize}px ${appConfig.simulation.intro.fontFamily}`;
+ context.font = `${fontSize}px ${INTRO_FONT_FAMILY}`;
context.textAlign = 'center';
context.textBaseline = 'middle';
context.fillStyle = '#fff';
context.strokeStyle = '#fff';
context.lineJoin = 'round';
context.lineWidth = Math.max(
- appConfig.simulation.intro.titleStrokeWidthMinPx,
- fontSize * appConfig.simulation.intro.titleStrokeWidthRatio
+ INTRO_TITLE_STROKE_WIDTH_MIN_PX,
+ fontSize * INTRO_TITLE_STROKE_WIDTH_RATIO
);
- const letterSpacing = fontSize * appConfig.simulation.intro.letterSpacingEm;
+ const letterSpacing = fontSize * INTRO_LETTER_SPACING_EM;
drawIntroTitleText(
context,
maskWidth / 2,
- maskHeight * appConfig.simulation.intro.verticalAnchor,
+ maskHeight * INTRO_VERTICAL_ANCHOR,
letterSpacing,
'stroke'
);
drawIntroTitleText(
context,
maskWidth / 2,
- maskHeight * appConfig.simulation.intro.verticalAnchor,
+ maskHeight * INTRO_VERTICAL_ANCHOR,
letterSpacing,
'fill'
);
@@ -217,9 +241,7 @@ const createIntroTitlePoints = (
const { data } = context.getImageData(0, 0, maskWidth, maskHeight);
const step = Math.max(
1,
- Math.floor(
- Math.min(maskWidth, maskHeight) / appConfig.simulation.intro.maskSampleDensity
- )
+ Math.floor(Math.min(maskWidth, maskHeight) / INTRO_MASK_SAMPLE_DENSITY)
);
const points: Array = [];
const characterColorBoundaries = getIntroTitleColorBoundaries(
@@ -231,7 +253,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 < appConfig.simulation.intro.maskAlphaThreshold) {
+ if (alpha < INTRO_MASK_ALPHA_THRESHOLD) {
continue;
}
@@ -255,9 +277,9 @@ const getIntroTitleColorBoundaries = (
const letters = Array.from(INTRO_TITLE);
const totalWidth = measureIntroTitleText(context, letters, letterSpacing);
let x = width / 2 - totalWidth / 2;
- 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 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 [firstCutLetter, secondCutLetter] = cutLetters;
const letterBoxes = letters.map((letter, index) => {
const letterWidth = context.measureText(letter).width;
@@ -330,17 +352,17 @@ const getIntroTitleFontSize = (
width: number,
height: number
): number => {
- const maxWidth = width * appConfig.simulation.intro.maxWidthRatio;
- const maxHeight = height * appConfig.simulation.intro.maxHeightRatio;
+ const maxWidth = width * INTRO_MAX_WIDTH_RATIO;
+ const maxHeight = height * INTRO_MAX_HEIGHT_RATIO;
let fontSize = Math.floor(
Math.min(
- height * appConfig.simulation.intro.initialFontHeightRatio,
- width * appConfig.simulation.intro.initialFontWidthRatio
+ height * INTRO_INITIAL_FONT_HEIGHT_RATIO,
+ width * INTRO_INITIAL_FONT_WIDTH_RATIO
)
);
- while (fontSize > appConfig.simulation.intro.minFontSizePx) {
- context.font = `${fontSize}px ${appConfig.simulation.intro.fontFamily}`;
+ while (fontSize > INTRO_MIN_FONT_SIZE_PX) {
+ context.font = `${fontSize}px ${INTRO_FONT_FAMILY}`;
const metrics = context.measureText(INTRO_TITLE);
const measuredHeight =
metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent || fontSize;
@@ -349,7 +371,7 @@ const getIntroTitleFontSize = (
return fontSize;
}
- fontSize = Math.floor(fontSize * appConfig.simulation.intro.fontScaleDown);
+ fontSize = Math.floor(fontSize * INTRO_FONT_SCALE_DOWN);
}
return fontSize;
@@ -369,10 +391,7 @@ const estimateMaskTangent = (
getMaskAlpha(data, width, height, x, y + 1) -
getMaskAlpha(data, width, height, x, y - 1);
- if (
- Math.abs(gradientX) + Math.abs(gradientY) <
- appConfig.simulation.intro.maskGradientThreshold
- ) {
+ if (Math.abs(gradientX) + Math.abs(gradientY) < INTRO_MASK_GRADIENT_THRESHOLD) {
return null;
}
@@ -397,8 +416,7 @@ const getIntroAgentPathProgress = (introProgress: number, introDelay: number): n
}
const activeProgress =
- (introProgress - introDelay) /
- Math.max(appConfig.simulation.intro.pathProgressEpsilon, 1 - introDelay);
+ (introProgress - introDelay) / Math.max(INTRO_PATH_PROGRESS_EPSILON, 1 - introDelay);
return easePathProgress(clamp(activeProgress, 0, 1));
};
@@ -414,7 +432,7 @@ const createSeededRandom = (seed: number): RandomSource => {
};
const easePathProgress = (amount: number): number => {
- if (isLinearPathEasing(appConfig.simulation.intro.pathEasing)) {
+ if (isLinearPathEasing(INTRO_PATH_EASING)) {
return amount;
}
diff --git a/src/game-loop/perf-stats-overlay.ts b/src/game-loop/perf-stats-overlay.ts
index 9e6717f..e9853d9 100644
--- a/src/game-loop/perf-stats-overlay.ts
+++ b/src/game-loop/perf-stats-overlay.ts
@@ -4,6 +4,10 @@ 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 bee83ef..c04196b 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(
- appConfig.deltaTime.minDeltaTimeSeconds,
+ MIN_DELTA_TIME_SECONDS,
(event.timeStamp - previousTimeMs) / 1000
);
diff --git a/src/game-loop/simulation-frame.ts b/src/game-loop/simulation-frame.ts
index 36629e8..9064af2 100644
--- a/src/game-loop/simulation-frame.ts
+++ b/src/game-loop/simulation-frame.ts
@@ -1,4 +1,3 @@
-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';
@@ -10,6 +9,11 @@ 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;
@@ -135,10 +139,9 @@ export class SimulationFrameRenderer {
}
const getSourceActiveFrameCount = (): number => {
- const frameCount =
- settings.brushEffectDuration * appConfig.simulation.brushEffectFramesPerSecond;
+ const frameCount = settings.brushEffectDuration * BRUSH_EFFECT_FRAMES_PER_SECOND;
if (Number.isFinite(frameCount) && frameCount > 0) {
return Math.ceil(frameCount);
}
- return Math.max(1, appConfig.simulation.sourceActiveFramesAfterWrite);
+ return Math.max(1, SOURCE_ACTIVE_FRAMES_AFTER_WRITE);
};
diff --git a/src/game-loop/simulation-textures.ts b/src/game-loop/simulation-textures.ts
index 7166595..bf62766 100644
--- a/src/game-loop/simulation-textures.ts
+++ b/src/game-loop/simulation-textures.ts
@@ -1,12 +1,13 @@
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
@@ -83,7 +84,7 @@ export class SimulationTextures {
colorAttachments: [
{
view: texture.getTextureView(),
- clearValue: appConfig.simulation.clearColor,
+ clearValue: SIMULATION_CLEAR_COLOR,
loadOp: 'clear',
storeOp: 'store',
},
@@ -122,7 +123,7 @@ export class SimulationTextures {
colorAttachments: [
{
view: this.sourceMapA.getTextureView(),
- clearValue: appConfig.simulation.clearColor,
+ clearValue: SIMULATION_CLEAR_COLOR,
loadOp: 'clear',
storeOp: 'store',
},
diff --git a/src/game-loop/stroke-mirroring.ts b/src/game-loop/stroke-mirroring.ts
index 9bfb8d4..ffa9412 100644
--- a/src/game-loop/stroke-mirroring.ts
+++ b/src/game-loop/stroke-mirroring.ts
@@ -2,6 +2,9 @@ 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 8898123..df074d4 100644
--- a/src/game-loop/toolbar-contrast-monitor.ts
+++ b/src/game-loop/toolbar-contrast-monitor.ts
@@ -1,4 +1,3 @@
-import { appConfig } from '../config';
import { clamp01 } from '../utils/math';
import type { CanvasReadbackRequest } from './game-loop-types';
@@ -24,21 +23,41 @@ 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 <= appConfig.toolbar.contrast.linearChannelBreakpoint
- ? normalized / appConfig.toolbar.contrast.linearChannelDivisor
- : ((normalized + appConfig.toolbar.contrast.linearChannelOffset) /
- appConfig.toolbar.contrast.linearChannelScale) **
- appConfig.toolbar.contrast.linearChannelGamma;
+ return normalized <= LINEAR_CHANNEL_BREAKPOINT
+ ? normalized / LINEAR_CHANNEL_DIVISOR
+ : ((normalized + LINEAR_CHANNEL_OFFSET) / LINEAR_CHANNEL_SCALE) **
+ LINEAR_CHANNEL_GAMMA;
};
const getRelativeLuminance = (red: number, green: number, blue: number): number =>
- appConfig.toolbar.contrast.luminanceRedWeight * getLinearChannel(red) +
- appConfig.toolbar.contrast.luminanceGreenWeight * getLinearChannel(green) +
- appConfig.toolbar.contrast.luminanceBlueWeight * getLinearChannel(blue);
+ LUMINANCE_RED_WEIGHT * getLinearChannel(red) +
+ LUMINANCE_GREEN_WEIGHT * getLinearChannel(green) +
+ LUMINANCE_BLUE_WEIGHT * getLinearChannel(blue);
const getToolbarContrastMetrics = (
pixels: Uint8Array,
@@ -46,8 +65,7 @@ const getToolbarContrastMetrics = (
isBgra: boolean
): ToolbarContrastMetrics => {
const count = sampleOffsets.filter(
- (offset) =>
- offset >= 0 && offset + appConfig.toolbar.contrast.bytesPerSample <= pixels.length
+ (offset) => offset >= 0 && offset + BYTES_PER_SAMPLE <= pixels.length
).length;
if (count === 0) {
return {
@@ -63,10 +81,7 @@ const getToolbarContrastMetrics = (
let lowContrastCount = 0;
sampleOffsets.forEach((offset) => {
- if (
- offset < 0 ||
- offset + appConfig.toolbar.contrast.bytesPerSample > pixels.length
- ) {
+ if (offset < 0 || offset + BYTES_PER_SAMPLE > pixels.length) {
return;
}
@@ -74,15 +89,13 @@ const getToolbarContrastMetrics = (
const green = pixels[offset + 1];
const blue = pixels[offset + (isBgra ? 0 : 2)];
const luminance = getRelativeLuminance(red, green, blue);
- const contrastWithWhite =
- appConfig.toolbar.contrast.whiteContrastNumerator /
- (luminance + appConfig.toolbar.contrast.contrastOffset);
+ const contrastWithWhite = WHITE_CONTRAST_NUMERATOR / (luminance + CONTRAST_OFFSET);
luminanceTotal += luminance;
- if (luminance > appConfig.toolbar.contrast.brightLuminanceThreshold) {
+ if (luminance > BRIGHT_LUMINANCE_THRESHOLD) {
brightCount++;
}
- if (contrastWithWhite < appConfig.toolbar.contrast.lowContrastThreshold) {
+ if (contrastWithWhite < LOW_CONTRAST_THRESHOLD) {
lowContrastCount++;
}
});
@@ -91,13 +104,11 @@ const getToolbarContrastMetrics = (
const brightRatio = brightCount / count;
const lowContrastRatio = lowContrastCount / count;
const backgroundStrength = clamp01(
- Math.max(0, averageLuminance - appConfig.toolbar.contrast.luminanceBase) /
- appConfig.toolbar.contrast.luminanceRange +
- brightRatio * appConfig.toolbar.contrast.brightWeight +
- lowContrastRatio * appConfig.toolbar.contrast.lowContrastWeight
+ Math.max(0, averageLuminance - LUMINANCE_BASE) / LUMINANCE_RANGE +
+ brightRatio * BRIGHT_WEIGHT +
+ lowContrastRatio * LOW_CONTRAST_WEIGHT
);
- const backgroundOpacity =
- backgroundStrength * appConfig.toolbar.contrast.backgroundOpacityMax;
+ const backgroundOpacity = backgroundStrength * BACKGROUND_OPACITY_MAX;
return {
averageLuminance,
@@ -128,7 +139,7 @@ export class ToolbarContrastMonitor {
if (
this.isDestroyed ||
this.isReadbackPending ||
- time - this.lastSampleAt < appConfig.toolbar.contrast.sampleIntervalMs
+ time - this.lastSampleAt < SAMPLE_INTERVAL_MS
) {
return null;
}
@@ -211,12 +222,12 @@ export class ToolbarContrastMonitor {
private setToolbarBackgroundOpacity(backgroundOpacity: number): void {
const safeBackgroundOpacity = Math.min(
- appConfig.toolbar.contrast.backgroundOpacityMax,
+ BACKGROUND_OPACITY_MAX,
Math.max(0, backgroundOpacity)
);
const backgroundStrength =
- appConfig.toolbar.contrast.backgroundOpacityMax > 0
- ? clamp01(safeBackgroundOpacity / appConfig.toolbar.contrast.backgroundOpacityMax)
+ BACKGROUND_OPACITY_MAX > 0
+ ? clamp01(safeBackgroundOpacity / BACKGROUND_OPACITY_MAX)
: 0;
this.toolbar.style.setProperty(
@@ -279,22 +290,20 @@ export class ToolbarContrastMonitor {
}
const bytesPerRow = alignTo(
- width * appConfig.toolbar.contrast.bytesPerSample,
+ width * BYTES_PER_SAMPLE,
GPU_COPY_BYTES_PER_ROW_ALIGNMENT
);
const points = new Map();
- for (let row = 0; row < appConfig.toolbar.contrast.sampleRows; row++) {
- const cssY =
- top + ((row + 0.5) / appConfig.toolbar.contrast.sampleRows) * cssHeight;
+ for (let row = 0; row < SAMPLE_ROWS; row++) {
+ const cssY = top + ((row + 0.5) / SAMPLE_ROWS) * cssHeight;
const y = Math.min(
this.canvas.height - 1,
Math.max(0, Math.floor((cssY - canvasRect.top) * yScale))
);
- for (let column = 0; column < appConfig.toolbar.contrast.sampleColumns; column++) {
- const cssX =
- left + ((column + 0.5) / appConfig.toolbar.contrast.sampleColumns) * cssWidth;
+ for (let column = 0; column < SAMPLE_COLUMNS; column++) {
+ const cssX = left + ((column + 0.5) / SAMPLE_COLUMNS) * cssWidth;
const x = Math.min(
this.canvas.width - 1,
Math.max(0, Math.floor((cssX - canvasRect.left) * xScale))
@@ -309,8 +318,7 @@ export class ToolbarContrastMonitor {
origin,
sampleOffsets: [...points.values()].map(
(point) =>
- (point.y - origin.y) * bytesPerRow +
- (point.x - origin.x) * appConfig.toolbar.contrast.bytesPerSample
+ (point.y - origin.y) * bytesPerRow + (point.x - origin.x) * BYTES_PER_SAMPLE
),
width,
};
diff --git a/src/index.ts b/src/index.ts
index 74b98d7..79b5251 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -71,6 +71,10 @@ 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"]',
@@ -113,6 +117,7 @@ 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 7677bad..0918eda 100644
--- a/src/page/audio-control.ts
+++ b/src/page/audio-control.ts
@@ -1,19 +1,23 @@
-import { appConfig } from '../config';
+import { DEFAULT_AUDIO_VOLUME, MAX_AUDIO_VOLUME } from '../audio/garden-audio-config';
import type GameLoop from '../game-loop/game-loop';
import { readBrowserStorage, writeBrowserStorage } from '../utils/browser-storage';
import { queryRequiredElement } from '../utils/dom';
-import { clamp01 } from '../utils/math';
+
+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;
const clampAudioVolume = (value: number): number => {
- 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 safeValue = Number.isFinite(value) ? value : DEFAULT_AUDIO_VOLUME;
+ return Math.min(AUDIO_VOLUME_MAX, Math.max(AUDIO_VOLUME_MIN, safeValue));
};
const readInitialAudioVolume = (): number => {
- const storedVolume = readBrowserStorage(appConfig.storage.audioVolumeKey);
+ const storedVolume = readBrowserStorage(AUDIO_VOLUME_STORAGE_KEY);
return storedVolume === null
- ? appConfig.toolbar.volume.default
+ ? DEFAULT_AUDIO_VOLUME
: clampAudioVolume(Number(storedVolume));
};
@@ -45,7 +49,7 @@ export class AudioControl {
private audioVolume = readInitialAudioVolume();
private isMutedState =
- readBrowserStorage(appConfig.storage.audioMutedKey) === STORED_MUTED_TRUE ||
+ readBrowserStorage(AUDIO_MUTED_STORAGE_KEY) === STORED_MUTED_TRUE ||
this.audioVolume <= 0;
public constructor(private readonly options: AudioControlOptions) {
@@ -78,6 +82,7 @@ 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));
@@ -85,9 +90,9 @@ export class AudioControl {
this.soundButton.setAttribute('aria-label', muteLabel);
this.soundButton.title = muteLabel;
- 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.min = AUDIO_VOLUME_MIN.toString();
+ this.volumeSlider.max = AUDIO_VOLUME_MAX.toString();
+ this.volumeSlider.step = AUDIO_VOLUME_STEP.toString();
this.volumeSlider.value = formatStoredAudioVolume(this.audioVolume);
this.volumeSlider.setAttribute(
'aria-valuetext',
@@ -97,7 +102,10 @@ export class AudioControl {
this.volumeControl.title = isEffectivelyMuted
? `Muted, ${volumePercent}% volume`
: `${volumePercent}% volume`;
- this.volumeControl.style.setProperty('--volume-progress', `${volumePercent}%`);
+ this.volumeControl.style.setProperty(
+ '--volume-progress',
+ `${volumeProgressPercent}%`
+ );
const game = this.options.getGame();
game?.setAudioVolume(this.audioVolume);
@@ -107,7 +115,7 @@ export class AudioControl {
private readonly onToggleMute = () => {
const shouldUnmute = this.isMutedState || this.audioVolume <= 0;
if (shouldUnmute && this.audioVolume <= 0) {
- this.audioVolume = appConfig.toolbar.volume.default;
+ this.audioVolume = DEFAULT_AUDIO_VOLUME;
}
this.isMutedState = !shouldUnmute;
this.persist();
@@ -141,11 +149,11 @@ export class AudioControl {
private persist(): void {
writeBrowserStorage(
- appConfig.storage.audioMutedKey,
+ AUDIO_MUTED_STORAGE_KEY,
this.isMutedState ? STORED_MUTED_TRUE : STORED_MUTED_FALSE
);
writeBrowserStorage(
- appConfig.storage.audioVolumeKey,
+ AUDIO_VOLUME_STORAGE_KEY,
formatStoredAudioVolume(this.audioVolume)
);
}
diff --git a/src/page/color-reaction-matrix-control.ts b/src/page/color-reaction-matrix-control.ts
index ca78cbb..756fd63 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 { appConfig, normalizeNumberControlValue } from '../config';
+import { normalizeNumberControlValue, runtimeControls } 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 = appConfig.runtimeSettings.controls[key];
+ const config = runtimeControls[key];
if (!config) {
return cell;
}
@@ -165,7 +165,7 @@ export class ColorReactionMatrixControl {
sourceColorIndex: number,
targetColorIndex: number
): void {
- const config = appConfig.runtimeSettings.controls[key];
+ const config = runtimeControls[key];
if (!config) {
return;
}
diff --git a/src/page/config-pane.ts b/src/page/config-pane.ts
index 8bb7f28..bdae8f7 100644
--- a/src/page/config-pane.ts
+++ b/src/page/config-pane.ts
@@ -3,11 +3,12 @@ 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';
@@ -29,6 +30,8 @@ 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;
@@ -56,7 +59,7 @@ interface ConfigPaneOptions {
const getRuntimeControlKeys = (folder: string): Array =>
(
- Object.entries(appConfig.runtimeSettings.controls) as Array<
+ Object.entries(runtimeControls) as Array<
[RuntimeControlKey, NumberControlConfig | undefined]
>
)
@@ -123,10 +126,10 @@ export class ConfigPane {
this.pane = new Pane({
container: this.container,
- title: appConfig.tuningPane.title,
+ title: CONFIG_PANE_TITLE,
expanded: true,
});
- this.pane.hidden = appConfig.tuningPane.startHidden;
+ this.pane.hidden = CONFIG_PANE_START_HIDDEN;
this.pane.element.classList.add('config-pane');
this.pane.element.id = 'config-pane';
@@ -310,7 +313,7 @@ export class ConfigPane {
private getRuntimeControlConfig(
key: RuntimeControlKey
): NumberControlConfig | undefined {
- const config = appConfig.runtimeSettings.controls[key];
+ const config = runtimeControls[key];
if (!config || key !== 'maxAgentCount') {
return config;
}
@@ -323,7 +326,7 @@ export class ConfigPane {
private addFpsOverlayBinding(container: PaneContainer): void {
container
- .addBinding(appConfig.tuningPane, 'showFpsOverlay', {
+ .addBinding(perfStatsOverlayState, 'isVisible', {
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 6b2bbe8..372c63e 100644
--- a/src/page/eraser-size-control.test.ts
+++ b/src/page/eraser-size-control.test.ts
@@ -1,26 +1,39 @@
import { describe, expect, it } from 'vitest';
-import { appConfig } from '../config';
import {
+ ERASER_SIZE_MAX,
+ ERASER_SIZE_MIN,
+ getEffectiveEraserSize,
getEraserSizeFromSliderRatio,
+ getEraserSizeMaxForCssSize,
getEraserSliderRatioFromSize,
-} from './eraser-size-control';
+} from '../config/eraser-size';
describe('eraser size slider mapping', () => {
it('maps slider position quadratically to eraser size', () => {
- 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);
+ 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);
});
it('maps eraser size back to the inverse slider position', () => {
- const { max, min } = appConfig.toolbar.eraser;
- const quarterRangeSize = min + (max - min) * 0.25;
+ const quarterRangeSize = ERASER_SIZE_MIN + (ERASER_SIZE_MAX - ERASER_SIZE_MIN) * 0.25;
- expect(getEraserSliderRatioFromSize(min)).toBe(0);
+ expect(getEraserSliderRatioFromSize(ERASER_SIZE_MIN)).toBe(0);
expect(getEraserSliderRatioFromSize(quarterRangeSize)).toBe(0.5);
- expect(getEraserSliderRatioFromSize(max)).toBe(1);
+ 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
+ );
});
});
diff --git a/src/page/eraser-size-control.ts b/src/page/eraser-size-control.ts
index 2c0e8a8..beb2d0f 100644
--- a/src/page/eraser-size-control.ts
+++ b/src/page/eraser-size-control.ts
@@ -1,35 +1,25 @@
-import { appConfig } from '../config';
+import {
+ clampEraserSize,
+ ERASER_SIZE_MAX,
+ getElementCssPixelSize,
+ getEraserSizeFromSliderRatio,
+ getEraserSizeMaxForCssSize,
+ getEraserSizeRatio,
+ getEraserSliderRatioFromSize,
+} from '../config/eraser-size';
import type GameLoop from '../game-loop/game-loop';
-import { settings } from '../settings';
+import { DEFAULT_ERASER_SIZE, settings } from '../settings';
import { queryRequiredElement } from '../utils/dom';
-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_CONTROL_SCALE_MIN = 0.74;
+const ERASER_CONTROL_SCALE_MAX = 1.34;
const ERASER_SLIDER_MIN = 0;
const ERASER_SLIDER_MAX = 1;
const ERASER_SLIDER_STEP = 0.001;
-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));
+const clampStoredEraserSize = (value: number): number =>
+ clampEraserSize(value, ERASER_SIZE_MAX, DEFAULT_ERASER_SIZE);
interface EraserSizeControlOptions {
getGame: () => GameLoop | null;
@@ -43,6 +33,7 @@ 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) {
@@ -50,7 +41,10 @@ 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));
+ settings.eraserSize = getEraserSizeFromSliderRatio(
+ Number(this.slider.value),
+ this.getResponsiveMaxSize()
+ );
this.activate();
this.render();
this.options.onChange();
@@ -58,24 +52,24 @@ export class EraserSizeControl {
}
public render(): void {
- const size = clampEraserSize(settings.eraserSize);
- if (settings.eraserSize !== size) {
- settings.eraserSize = size;
+ const maxSize = this.getResponsiveMaxSize();
+ const storedSize = clampStoredEraserSize(settings.eraserSize);
+ if (settings.eraserSize !== storedSize) {
+ settings.eraserSize = storedSize;
}
- const sliderRatio = getEraserSliderRatioFromSize(size);
+ const size = clampEraserSize(storedSize, maxSize, DEFAULT_ERASER_SIZE);
+ const sliderRatio = getEraserSliderRatioFromSize(size, maxSize);
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);
+ const sizeRatio = getEraserSizeRatio(size, maxSize);
const scale =
- appConfig.toolbar.eraser.controlScaleMin +
- (appConfig.toolbar.eraser.controlScaleMax -
- appConfig.toolbar.eraser.controlScaleMin) *
- sizeRatio;
+ ERASER_CONTROL_SCALE_MIN +
+ (ERASER_CONTROL_SCALE_MAX - ERASER_CONTROL_SCALE_MIN) * sizeRatio;
this.control.style.setProperty('--eraser-progress', `${sliderRatio * 100}%`);
this.control.style.setProperty('--eraser-control-scale', scale.toFixed(3));
this.syncActiveState();
@@ -92,6 +86,10 @@ 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 ed8aa59..4f371c6 100644
--- a/src/page/menu-hider.ts
+++ b/src/page/menu-hider.ts
@@ -1,9 +1,10 @@
-import { appConfig } from '../config';
+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;
export class MenuHider {
- private readonly desktopMediaQuery = window.matchMedia(
- appConfig.menuHider.desktopMediaQuery
- );
+ private readonly desktopMediaQuery = window.matchMedia(DESKTOP_AUTO_HIDE_MEDIA_QUERY);
private hideTimeout: number | undefined;
private isHidden = false;
private pointerInside = false;
@@ -95,7 +96,7 @@ export class MenuHider {
if (this.canAutoHide) {
this.hide();
}
- }, appConfig.menuHider.hideDelayMs);
+ }, HIDE_DELAY_MS);
}
private reveal(): void {
@@ -134,6 +135,6 @@ export class MenuHider {
private isNearViewportBottom(clientY: number): boolean {
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
- return clientY >= viewportHeight - appConfig.menuHider.bottomRevealDistancePx;
+ return clientY >= viewportHeight - BOTTOM_REVEAL_DISTANCE_PX;
}
}
diff --git a/src/page/mirror-segment-control.ts b/src/page/mirror-segment-control.ts
index 1ea8ddc..f058429 100644
--- a/src/page/mirror-segment-control.ts
+++ b/src/page/mirror-segment-control.ts
@@ -1,25 +1,48 @@
-import { appConfig } from '../config';
-import { settings } from '../settings';
+import {
+ MAX_MIRROR_SEGMENT_COUNT,
+ MIN_MIRROR_SEGMENT_COUNT,
+} from '../game-loop/stroke-mirroring';
+import { DEFAULT_MIRROR_SEGMENT_COUNT, 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 { 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 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 getMirrorSegmentRatio = (count: number): number => {
- const { max, min } = appConfig.toolbar.mirror;
- return (count - min) / (max - min);
+ return (
+ (count - MIN_MIRROR_SEGMENT_COUNT) /
+ (MAX_MIRROR_SEGMENT_COUNT - MIN_MIRROR_SEGMENT_COUNT)
+ );
};
const formatMirrorSegmentCount = (count: number): string =>
count <= 1
- ? appConfig.toolbar.mirror.offLabel
+ ? MIRROR_SEGMENT_OFF_LABEL
: `${count} ${
- appConfig.toolbar.mirror.names[
- count as keyof typeof appConfig.toolbar.mirror.names
- ] ?? appConfig.toolbar.mirror.fallbackSegmentName
+ MIRROR_SEGMENT_NAMES[count as keyof typeof MIRROR_SEGMENT_NAMES] ??
+ MIRROR_SEGMENT_FALLBACK_NAME
}`;
interface MirrorSegmentControlOptions {
@@ -50,9 +73,9 @@ export class MirrorSegmentControl {
settings.mirrorSegmentCount = count;
}
- 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.min = MIN_MIRROR_SEGMENT_COUNT.toString();
+ this.slider.max = MAX_MIRROR_SEGMENT_COUNT.toString();
+ this.slider.step = MIRROR_SEGMENT_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 b759c4f..4897020 100644
--- a/src/pipelines/brush/brush-pipeline.ts
+++ b/src/pipelines/brush/brush-pipeline.ts
@@ -1,6 +1,5 @@
import { vec2 } from 'gl-matrix';
-import { appConfig } from '../../config';
import { getRenderQualityBrushSize } from '../../config/brush-size';
import {
createCachedBufferWrite,
@@ -40,6 +39,7 @@ 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, appConfig.pipelines.brush.maxLineCount);
+ this.segments = new LineSegmentBuffer(device, MAX_BRUSH_LINE_COUNT);
this.bindGroupLayout = device.createBindGroupLayout({
entries: [
diff --git a/src/pipelines/common-state/common-state.ts b/src/pipelines/common-state/common-state.ts
index 5157459..deeaa8a 100644
--- a/src/pipelines/common-state/common-state.ts
+++ b/src/pipelines/common-state/common-state.ts
@@ -1,11 +1,10 @@
import { vec2 } from 'gl-matrix';
-import { appConfig } from '../../config';
import {
createCachedBufferWrite,
writeBufferIfChanged,
} from '../../utils/graphics/cached-buffer-write';
-import { generateNoise } from '../../utils/graphics/noise';
+import { generateNoise, NOISE_TEXTURE_SIZE } from '../../utils/graphics/noise';
export class CommonState {
private static readonly UNIFORM_COUNT = 4;
@@ -39,8 +38,8 @@ export class CommonState {
const noise = generateNoise({
device,
- width: appConfig.pipelines.common.noiseTextureSize,
- height: appConfig.pipelines.common.noiseTextureSize,
+ width: NOISE_TEXTURE_SIZE,
+ height: NOISE_TEXTURE_SIZE,
});
this.noise = noise.texture;
diff --git a/src/pipelines/diffusion/diffusion-pipeline.ts b/src/pipelines/diffusion/diffusion-pipeline.ts
index b1c763d..ef9bfef 100644
--- a/src/pipelines/diffusion/diffusion-pipeline.ts
+++ b/src/pipelines/diffusion/diffusion-pipeline.ts
@@ -1,6 +1,5 @@
import { vec2 } from 'gl-matrix';
-import { appConfig } from '../../config';
import { createBindGroupCache } from '../../utils/graphics/bind-group-cache';
import {
createCachedBufferWrite,
@@ -29,12 +28,13 @@ type DiffusionUniformSettings = Pick<
| 'brushDecayAlphaOffset'
>;
+const MIN_DIFFUSION_RATE = 0.000001;
+
const getSafeInverseDiffusionRate = (diffusionRate: number): number =>
1 /
- (Number.isFinite(diffusionRate) &&
- diffusionRate > appConfig.pipelines.diffusion.minDiffusionRate
+ (Number.isFinite(diffusionRate) && diffusionRate > MIN_DIFFUSION_RATE
? diffusionRate
- : appConfig.pipelines.diffusion.minDiffusionRate);
+ : MIN_DIFFUSION_RATE);
const setDiffusionUniformValues = (
target: Float32Array,
diff --git a/src/pipelines/eraser/eraser-texture-pipeline.ts b/src/pipelines/eraser/eraser-texture-pipeline.ts
index 694777f..cf3e84a 100644
--- a/src/pipelines/eraser/eraser-texture-pipeline.ts
+++ b/src/pipelines/eraser/eraser-texture-pipeline.ts
@@ -1,6 +1,5 @@
import { vec2 } from 'gl-matrix';
-import { appConfig } from '../../config';
import {
createCachedBufferWrite,
writeBufferIfChanged,
@@ -29,6 +28,7 @@ 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,10 +50,7 @@ export class EraserTexturePipeline {
private readonly device: GPUDevice,
private readonly commonState: CommonState
) {
- this.segments = new LineSegmentBuffer(
- device,
- appConfig.pipelines.eraser.maxTextureLineCount
- );
+ this.segments = new LineSegmentBuffer(device, MAX_ERASER_TEXTURE_LINE_COUNT);
this.bindGroupLayout = device.createBindGroupLayout({
entries: [
diff --git a/src/settings.ts b/src/settings.ts
index c91ac83..bceadd9 100644
--- a/src/settings.ts
+++ b/src/settings.ts
@@ -1,10 +1,14 @@
import {
- appConfig,
+ defaultSettings,
normalizeRuntimeSettings,
+ runtimeControls,
type GardenRuntimeSettings,
} from './config';
import { writeBrowserStorage } from './utils/browser-storage';
-import { getInitialVibe, type VibePreset } from './vibes';
+import { getInitialVibe, VIBE_STORAGE_KEY, type VibePreset } from './vibes';
+
+export const DEFAULT_ERASER_SIZE = 96;
+export const DEFAULT_MIRROR_SEGMENT_COUNT = 8;
const preservedRuntimeSettingKeys = [
'eraserSize',
@@ -37,12 +41,12 @@ const cloneVibePreset = (vibe: VibePreset): VibePreset => ({
const buildSettings = (vibe: VibePreset): GardenRuntimeSettings =>
normalizeRuntimeSettings(
{
- ...appConfig.defaultSettings,
- eraserSize: appConfig.toolbar.eraser.default,
- mirrorSegmentCount: appConfig.toolbar.mirror.default,
+ ...defaultSettings,
+ eraserSize: DEFAULT_ERASER_SIZE,
+ mirrorSegmentCount: DEFAULT_MIRROR_SEGMENT_COUNT,
...vibe.settings,
},
- appConfig.runtimeSettings.controls
+ runtimeControls
);
export let activeVibe = cloneVibePreset(getInitialVibe());
@@ -52,7 +56,7 @@ export const settings: GardenRuntimeSettings = {
};
export const rememberActiveVibeSelection = (): void => {
- writeBrowserStorage(appConfig.storage.vibeKey, activeVibe.id);
+ writeBrowserStorage(VIBE_STORAGE_KEY, activeVibe.id);
};
export const applyVibeSettings = (vibe: VibePreset) => {
@@ -66,10 +70,7 @@ export const applyVibeSettings = (vibe: VibePreset) => {
activeVibe.colors.length - 1
);
- Object.assign(
- settings,
- normalizeRuntimeSettings(nextSettings, appConfig.runtimeSettings.controls)
- );
+ Object.assign(settings, normalizeRuntimeSettings(nextSettings, runtimeControls));
rememberActiveVibeSelection();
diff --git a/src/style/_loading.scss b/src/style/_loading.scss
index 925f915..d9a519b 100644
--- a/src/style/_loading.scss
+++ b/src/style/_loading.scss
@@ -52,7 +52,7 @@
> .splash-description {
margin: 0;
- max-width: 28ch;
+ max-width: 70ch;
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 28717bb..029a044 100644
--- a/src/style/_panels.scss
+++ b/src/style/_panels.scss
@@ -1,76 +1,182 @@
@use 'mixins' as *;
html > body > aside.control-dock > .info-page {
- width: min(calc(100vw - 1rem), 560px);
- max-height: min(58vh, 520px);
- max-height: min(58dvh, 520px);
+ width: min(100%, 520px);
+ max-height: 200vh;
+ max-height: 200dvh;
margin: 0 auto 10px;
- overflow-x: hidden;
- overflow-y: auto;
- border: 1px solid rgb(255 255 255 / 78%);
+ overflow: hidden;
+ border: 1px solid rgb(255 255 255 / 46%);
border-radius: 8px;
background:
- linear-gradient(180deg, rgb(255 255 255 / 97%), rgb(243 247 239 / 96%)),
- rgb(255 255 255);
- color: rgb(24 30 27);
+ linear-gradient(180deg, rgb(252 255 249 / 94%), rgb(234 241 232 / 91%)),
+ rgb(249 252 247 / 92%);
+ color: rgb(18 28 24);
box-shadow:
- 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;
+ 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%);
transition:
max-height var(--transition-time-long),
opacity var(--transition-time-long),
transform var(--transition-time-long),
margin-bottom var(--transition-time-long);
- &::-webkit-scrollbar-track,
- &::-webkit-scrollbar {
- background-color: transparent;
- width: 6px;
- }
-
- &::-webkit-scrollbar-thumb {
- background-color: var(--main-color);
- border-radius: 8px;
- }
-
&:focus-visible {
- outline: 2px solid white;
+ 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%);
}
- > section {
+ .info-page__content {
display: flex;
flex-direction: column;
gap: 0.85rem;
- padding: var(--normal-margin);
+ padding: 18px 20px 16px;
+ }
- h1 {
- margin-bottom: 0;
- color: rgb(16 24 20);
- font-size: 2rem;
- line-height: 1.1;
+ .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;
}
- p {
- max-width: 54ch;
- margin-bottom: 0;
- color: rgb(42 48 45);
- font-size: 1.1rem;
- line-height: 1.65;
- hyphens: auto;
+ &:hover {
+ border-color: rgb(34 57 50 / 30%);
+ background: rgb(255 255 255 / 64%);
+ transform: translateY(-1px);
}
- a {
- color: rgb(0 84 120);
- font-weight: 400;
+ &:focus-visible {
+ outline: 2px solid rgb(17 56 45);
+ outline-offset: 2px;
+ box-shadow: 0 0 0 4px rgb(255 255 255 / 72%);
+ }
+ }
- &:focus-visible {
- outline: 2px solid currentColor;
- outline-offset: 3px;
- }
+ .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;
}
}
@@ -81,16 +187,22 @@ html > body > aside.control-dock > .info-page {
opacity: 0;
pointer-events: none;
box-shadow: none;
- transform: translateY(8px);
+ transform: translateY(6px) scale(0.985);
visibility: hidden;
}
@include on-small-screen {
- max-height: min(54vh, 500px);
- max-height: min(54dvh, 500px);
+ width: min(100%, 520px);
- > section {
- padding: var(--small-margin);
+ .info-page__content {
+ gap: 0.75rem;
+ padding: 14px;
+ }
+
+ .info-page__main,
+ .info-page__notes {
+ font-size: 0.95rem;
+ line-height: 1.52;
}
}
}
diff --git a/src/style/common.scss b/src/style/common.scss
index 23c82aa..9996bc5 100644
--- a/src/style/common.scss
+++ b/src/style/common.scss
@@ -16,15 +16,24 @@
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 78489c2..bff0468 100644
--- a/src/utils/delta-time-calculator.ts
+++ b/src/utils/delta-time-calculator.ts
@@ -1,6 +1,9 @@
-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();
@@ -16,11 +19,7 @@ export class DeltaTimeCalculator {
const delta = currentTime - this.previousTime;
this.previousTime = currentTime;
- return clamp(
- delta / 1000,
- appConfig.deltaTime.minDeltaTimeSeconds,
- appConfig.deltaTime.maxDeltaTimeSeconds
- );
+ return clamp(delta / 1000, MIN_DELTA_TIME_SECONDS, MAX_DELTA_TIME_SECONDS);
}
private handleVisibilityChange() {
diff --git a/src/utils/graphics/noise.ts b/src/utils/graphics/noise.ts
index c7cdc96..4e171fd 100644
--- a/src/utils/graphics/noise.ts
+++ b/src/utils/graphics/noise.ts
@@ -1,7 +1,17 @@
-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;
@@ -29,16 +39,16 @@ export const generateNoise = ({
return fract(sin(dot(
uv,
vec2(
- ${appConfig.pipelines.common.noiseHashX} + seed,
- ${appConfig.pipelines.common.noiseHashY} + seed
+ ${NOISE_HASH_X} + seed,
+ ${NOISE_HASH_Y} + seed
)
- )) * ${appConfig.pipelines.common.noiseHashMultiplier} + seed);
+ )) * ${NOISE_HASH_MULTIPLIER} + seed);
}
@fragment
fn fragment(@location(0) uv: vec2) -> @location(0) vec4 {
return vec4(
- random_with_seed(uv, ${appConfig.pipelines.common.noiseChannelSeeds[0]}),
+ random_with_seed(uv, ${NOISE_CHANNEL_SEEDS[0]}),
0.0,
0.0,
1.0,
@@ -48,7 +58,7 @@ export const generateNoise = ({
entryPoint: 'fragment',
targets: [
{
- format: appConfig.pipelines.common.noiseTextureFormat,
+ format: NOISE_TEXTURE_FORMAT,
},
],
},
@@ -63,7 +73,7 @@ export const generateNoise = ({
height,
depthOrArrayLayers: 1,
},
- format: appConfig.pipelines.common.noiseTextureFormat,
+ format: NOISE_TEXTURE_FORMAT,
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
});
@@ -71,7 +81,7 @@ export const generateNoise = ({
colorAttachments: [
{
view: colorTexture.createView(),
- clearValue: appConfig.pipelines.common.noiseClearValue,
+ clearValue: NOISE_CLEAR_VALUE,
loadOp: 'clear',
storeOp: 'store',
},
@@ -82,10 +92,7 @@ export const generateNoise = ({
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
passEncoder.setPipeline(pipeline);
- passEncoder.draw(
- appConfig.pipelines.common.noiseDrawVertexCount,
- appConfig.pipelines.common.noiseDrawInstanceCount
- );
+ passEncoder.draw(NOISE_DRAW_VERTEX_COUNT, NOISE_DRAW_INSTANCE_COUNT);
passEncoder.end();
device.queue.submit([commandEncoder.finish()]);
diff --git a/src/vibe-registry.ts b/src/vibe-registry.ts
index 0b0ae54..ad16554 100644
--- a/src/vibe-registry.ts
+++ b/src/vibe-registry.ts
@@ -1,7 +1,7 @@
-import { appConfig } from './config';
+import { vibePresets } from './config';
import type { VibeId, VibePreset } from './config/types';
-export const VIBE_PRESETS: Array = appConfig.vibes.presets;
+export const VIBE_PRESETS: Array = vibePresets;
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 95d1f7c..28aed48 100644
--- a/src/vibes.ts
+++ b/src/vibes.ts
@@ -1,4 +1,4 @@
-import { appConfig } from './config';
+import { defaultVibeId } from './config';
import { VibeId, type VibePreset } from './config/types';
import { readBrowserStorage } from './utils/browser-storage';
import { getVibeById, VIBE_PRESETS } from './vibe-registry';
@@ -8,6 +8,8 @@ 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 =>
@@ -15,12 +17,11 @@ const isVibeId = (value: unknown): value is VibeId =>
export const getInitialVibe = (): VibePreset => {
const uriVibeId = getCurrentUriVibeId();
- const storedVibeId = readBrowserStorage(appConfig.storage.vibeKey);
+ const storedVibeId = readBrowserStorage(VIBE_STORAGE_KEY);
const storedOrLegacyVibeId = isVibeId(storedVibeId)
? storedVibeId
: getVibeIdFromUri(`?vibe=${encodeURIComponent(storedVibeId ?? '')}`);
- const initialVibeId =
- uriVibeId ?? storedOrLegacyVibeId ?? appConfig.vibes.defaultVibeId;
+ const initialVibeId = uriVibeId ?? storedOrLegacyVibeId ?? defaultVibeId;
return getVibeById(initialVibeId) ?? VIBE_PRESETS[0];
};