simplify more

This commit is contained in:
Andras Schmelczer 2026-05-20 21:37:30 +01:00
commit 2fe3c69963
40 changed files with 690 additions and 873 deletions

BIN
public/og-image.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View file

@ -1,11 +1,9 @@
import { DEFAULT_AUDIO_VOLUME } from '../app-constants'; import { DEFAULT_AUDIO_VOLUME } from '../app-constants';
import type { PianoNoteRole } from './garden-audio-types'; import type { PianoNoteRole } from './garden-audio-types';
type GardenAudioChordQuality = 'major' | 'minor';
export interface GardenAudioChord { export interface GardenAudioChord {
rootOffset: number; rootOffset: number;
quality: GardenAudioChordQuality; quality: 'major' | 'minor';
} }
export interface GardenAudioVibeSettings { export interface GardenAudioVibeSettings {

View file

@ -14,10 +14,6 @@ export interface GardenAudioStroke {
elapsedSeconds: number; elapsedSeconds: number;
} }
export interface GardenAudioStartOptions {
userGesture?: boolean;
}
export interface LoadedPianoSample { export interface LoadedPianoSample {
midi: number; midi: number;
buffer: AudioBuffer; buffer: AudioBuffer;

View file

@ -7,11 +7,7 @@ import { GardenAudioGestureState } from './garden-audio-gesture-state';
import { GardenAudioGraph } from './garden-audio-graph'; import { GardenAudioGraph } from './garden-audio-graph';
import { getStrokeMetrics } from './garden-audio-input'; import { getStrokeMetrics } from './garden-audio-input';
import { getVibeProfile } from './garden-audio-music'; import { getVibeProfile } from './garden-audio-music';
import type { import type { GardenAudioSnapshot, GardenAudioStroke } from './garden-audio-types';
GardenAudioSnapshot,
GardenAudioStartOptions,
GardenAudioStroke,
} from './garden-audio-types';
import { GenerativePianoEngine } from './generative-piano'; import { GenerativePianoEngine } from './generative-piano';
import { NoiseBurstPlayer } from './noise-burst-player'; import { NoiseBurstPlayer } from './noise-burst-player';
import { PianoSampler } from './piano-sampler'; import { PianoSampler } from './piano-sampler';
@ -53,7 +49,7 @@ export class GardenAudio {
this.pianoEngine = new GenerativePianoEngine(config, (note) => this.piano.play(note)); this.pianoEngine = new GenerativePianoEngine(config, (note) => this.piano.play(note));
} }
public start(vibe: VibePreset, options: GardenAudioStartOptions = {}): void { public start(vibe: VibePreset, options: { userGesture?: boolean } = {}): void {
const isUserGesture = options.userGesture === true; const isUserGesture = options.userGesture === true;
if (this.lifecycle === 'destroyed') { if (this.lifecycle === 'destroyed') {
@ -134,7 +130,7 @@ export class GardenAudio {
} }
} }
public changeVibe(vibe: VibePreset, options: GardenAudioStartOptions = {}): void { public changeVibe(vibe: VibePreset, options: { userGesture?: boolean } = {}): void {
const previousVibeId = this.currentVibeId; const previousVibeId = this.currentVibeId;
this.start(vibe, options); this.start(vibe, options);
const didChangeVibe = previousVibeId !== null && previousVibeId !== vibe.id; const didChangeVibe = previousVibeId !== null && previousVibeId !== vibe.id;

View file

@ -46,20 +46,6 @@ const degreeToSemitone = (profile: GardenAudioVibeProfile, degree: number): numb
type GardenAudioStyleIndex = 0 | 1 | 2; type GardenAudioStyleIndex = 0 | 1 | 2;
interface RenderLookaheadRequest {
vibe: VibePreset;
now: number;
activity: number;
lookaheadSeconds?: number;
}
interface StrokeAccentRequest {
vibe: VibePreset;
now: number;
activity: number;
maniaAmount?: number;
}
interface TouchDownRequest { interface TouchDownRequest {
vibe: VibePreset; vibe: VibePreset;
now: number; now: number;
@ -118,10 +104,6 @@ export class GenerativePianoEngine {
private readonly playNote: (note: PianoNote) => void private readonly playNote: (note: PianoNote) => void
) {} ) {}
private get generation(): typeof generativePianoTuning {
return generativePianoTuning;
}
public prime(now: number, profile: GardenAudioVibeProfile): void { public prime(now: number, profile: GardenAudioVibeProfile): void {
this.activeProfile = profile; this.activeProfile = profile;
this.timelineStartedAt ??= now; this.timelineStartedAt ??= now;
@ -162,7 +144,7 @@ export class GenerativePianoEngine {
this.brushPhraseLayers = []; this.brushPhraseLayers = [];
this.brushStreamNoteCountsByBar.clear(); this.brushStreamNoteCountsByBar.clear();
return releaseStart + this.generation.releaseResolution.fadeAfterSeconds; return releaseStart + generativePianoTuning.releaseResolution.fadeAfterSeconds;
} }
private recordTouchDown({ private recordTouchDown({
@ -198,7 +180,12 @@ export class GenerativePianoEngine {
now, now,
activity, activity,
maniaAmount = 0, maniaAmount = 0,
}: StrokeAccentRequest): void { }: {
vibe: VibePreset;
now: number;
activity: number;
maniaAmount?: number;
}): void {
const profile = getVibeProfile(vibe); const profile = getVibeProfile(vibe);
this.prime(now, profile); this.prime(now, profile);
const strength = clamp01(activity); const strength = clamp01(activity);
@ -206,12 +193,12 @@ export class GenerativePianoEngine {
const styleIndex = this.getStyleIndex(now); const styleIndex = this.getStyleIndex(now);
const accentStep = this.getNextStepIndexAt( const accentStep = this.getNextStepIndexAt(
now, now,
this.generation.gestureAccent.quantizeStepLookahead generativePianoTuning.gestureAccent.quantizeStepLookahead
); );
if ( if (
this.isWaitingForGestureAccent && this.isWaitingForGestureAccent &&
now - this.lastGestureAccentAt >= this.generation.gestureAccentMinIntervalSeconds now - this.lastGestureAccentAt >= generativePianoTuning.gestureAccentMinIntervalSeconds
) { ) {
this.recordTouchDown({ this.recordTouchDown({
vibe, vibe,
@ -230,8 +217,8 @@ export class GenerativePianoEngine {
maniaAmount: normalizedManiaAmount, maniaAmount: normalizedManiaAmount,
}); });
if ( if (
strength >= this.generation.strokeAccentThreshold && strength >= generativePianoTuning.strokeAccentThreshold &&
accentStep - this.lastStrokeAccentStep >= this.generation.strokeAccentMinSteps accentStep - this.lastStrokeAccentStep >= generativePianoTuning.strokeAccentMinSteps
) { ) {
this.lastStrokeAccentStep = accentStep; this.lastStrokeAccentStep = accentStep;
this.playGestureAccent(vibe, accentStep, styleIndex, strength); this.playGestureAccent(vibe, accentStep, styleIndex, strength);
@ -243,7 +230,12 @@ export class GenerativePianoEngine {
now, now,
activity, activity,
lookaheadSeconds = GENERATIVE_LOOKAHEAD_SECONDS, lookaheadSeconds = GENERATIVE_LOOKAHEAD_SECONDS,
}: RenderLookaheadRequest): void { }: {
vibe: VibePreset;
now: number;
activity: number;
lookaheadSeconds?: number;
}): void {
const profile = getVibeProfile(vibe); const profile = getVibeProfile(vibe);
this.prime(now, profile); this.prime(now, profile);
this.skipLateBeats(now); this.skipLateBeats(now);
@ -253,13 +245,14 @@ export class GenerativePianoEngine {
} }
const lookaheadEnd = now + lookaheadSeconds; const lookaheadEnd = now + lookaheadSeconds;
const expression = this.getExpression(activity);
while (this.getTimeForStep(this.nextBeatStep) <= lookaheadEnd) { while (this.getTimeForStep(this.nextBeatStep) <= lookaheadEnd) {
const beatIndex = this.getBeatIndexForStep(this.nextBeatStep); const beatIndex = this.getBeatIndexForStep(this.nextBeatStep);
this.renderBeat({ this.renderBeat({
profile, profile,
beatIndex, beatIndex,
startTime: this.getTimeForStep(this.nextBeatStep), startTime: this.getTimeForStep(this.nextBeatStep),
expression: this.getExpression(activity), expression,
}); });
this.nextBeatStep += this.config.rhythm.stepsPerBeat; this.nextBeatStep += this.config.rhythm.stepsPerBeat;
} }
@ -267,7 +260,7 @@ export class GenerativePianoEngine {
vibe, vibe,
now, now,
lookaheadEnd, lookaheadEnd,
activity, activity: expression,
}); });
} }
@ -276,7 +269,7 @@ export class GenerativePianoEngine {
const chord = this.getChord(profile, 0); const chord = this.getChord(profile, 0);
const intervals = getChordIntervals(chord, true); const intervals = getChordIntervals(chord, true);
const rootMidi = profile.rootMidi + chord.rootOffset; const rootMidi = profile.rootMidi + chord.rootOffset;
const stinger = this.generation.vibeChangeStinger; const stinger = generativePianoTuning.vibeChangeStinger;
const offsetsByVoice: ReadonlyArray<ReadonlyArray<number>> = [ const offsetsByVoice: ReadonlyArray<ReadonlyArray<number>> = [
[0], [0],
[intervals[1], intervals[2]], [intervals[1], intervals[2]],
@ -286,7 +279,7 @@ export class GenerativePianoEngine {
offsetsByVoice.forEach((offsets, index) => { offsetsByVoice.forEach((offsets, index) => {
const midi = this.chooseMidi( const midi = this.chooseMidi(
{ baseMidi: rootMidi, offsets }, { baseMidi: rootMidi, offsets },
this.generation.padRegisters[index] generativePianoTuning.padRegisters[index]
); );
this.playProfileNote(profile, { this.playProfileNote(profile, {
midi, midi,
@ -340,7 +333,7 @@ export class GenerativePianoEngine {
const barIndex = Math.floor(beatIndex / beatsPerBar); const barIndex = Math.floor(beatIndex / beatsPerBar);
const styleIndex = this.getStyleIndex(startTime); const styleIndex = this.getStyleIndex(startTime);
if (beatInBar === 0 && barIndex % this.generation.chordBars === 0) { if (beatInBar === 0 && barIndex % generativePianoTuning.chordBars === 0) {
this.playPadChord(profile, barIndex, startTime, expression); this.playPadChord(profile, barIndex, startTime, expression);
} }
@ -349,21 +342,21 @@ export class GenerativePianoEngine {
} }
if ( if (
beatInBar === this.generation.textureBeat && beatInBar === generativePianoTuning.textureBeat &&
this.shouldPlayTexture(expression, barIndex) this.shouldPlayTexture(expression, barIndex)
) { ) {
this.playTextureNote(profile, barIndex, startTime, expression, styleIndex); this.playTextureNote(profile, barIndex, startTime, expression, styleIndex);
} }
if ( if (
beatInBar === this.generation.highActivityExtraBeat && beatInBar === generativePianoTuning.highActivityExtraBeat &&
expression >= this.generation.highActivityExtraThreshold expression >= generativePianoTuning.highActivityExtraThreshold
) { ) {
this.playTextureNote( this.playTextureNote(
profile, profile,
barIndex + this.generation.highActivityExtra.barOffset, barIndex + generativePianoTuning.highActivityExtra.barOffset,
startTime, startTime,
expression * this.generation.highActivityExtra.expressionMultiplier, expression * generativePianoTuning.highActivityExtra.expressionMultiplier,
styleIndex styleIndex
); );
} }
@ -380,23 +373,23 @@ export class GenerativePianoEngine {
const rootMidi = profile.rootMidi + chord.rootOffset; const rootMidi = profile.rootMidi + chord.rootOffset;
const durationSeconds = const durationSeconds =
this.getBarDurationSeconds() * this.getBarDurationSeconds() *
this.generation.chordBars * generativePianoTuning.chordBars *
this.generation.padDurationBarScale; generativePianoTuning.padDurationBarScale;
const notes = [ const notes = [
{ {
source: { baseMidi: rootMidi, offsets: [0] }, source: { baseMidi: rootMidi, offsets: [0] },
register: this.generation.padRegisters[0], register: generativePianoTuning.padRegisters[0],
velocity: this.generation.padChord.velocities[0], velocity: generativePianoTuning.padChord.velocities[0],
}, },
{ {
source: { baseMidi: rootMidi, offsets: [intervals[1]] }, source: { baseMidi: rootMidi, offsets: [intervals[1]] },
register: this.generation.padRegisters[1], register: generativePianoTuning.padRegisters[1],
velocity: this.generation.padChord.velocities[1], velocity: generativePianoTuning.padChord.velocities[1],
}, },
{ {
source: { baseMidi: rootMidi, offsets: [intervals[3], intervals[2]] }, source: { baseMidi: rootMidi, offsets: [intervals[3], intervals[2]] },
register: this.generation.padRegisters[2], register: generativePianoTuning.padRegisters[2],
velocity: this.generation.padChord.velocities[2], velocity: generativePianoTuning.padChord.velocities[2],
}, },
]; ];
@ -411,16 +404,16 @@ export class GenerativePianoEngine {
this.playProfileNote(profile, { this.playProfileNote(profile, {
midi, midi,
velocity: velocity:
velocity + expression * this.generation.padChord.expressionVelocityWeight, velocity + expression * generativePianoTuning.padChord.expressionVelocityWeight,
startTime, startTime,
durationSeconds, durationSeconds,
pan: register.pan, pan: register.pan,
role: 'pad', role: 'pad',
delaySend: this.generation.padChord.delaySend, delaySend: generativePianoTuning.padChord.delaySend,
lowpassHz: this.getLowpassHz( lowpassHz: this.getLowpassHz(
profile, profile,
midi, midi,
expression * this.generation.padChord.lowpassExpressionWeight expression * generativePianoTuning.padChord.lowpassExpressionWeight
), ),
}); });
}); });
@ -434,7 +427,7 @@ export class GenerativePianoEngine {
const chord = this.getChord(profile, this.getBarIndexForStep(stepIndex)); const chord = this.getChord(profile, this.getBarIndexForStep(stepIndex));
const intervals = getChordIntervals(chord, true); const intervals = getChordIntervals(chord, true);
const rootMidi = profile.rootMidi + chord.rootOffset; const rootMidi = profile.rootMidi + chord.rootOffset;
const release = this.generation.releaseResolution; const release = generativePianoTuning.releaseResolution;
const offsetsByVoice: ReadonlyArray<ReadonlyArray<number>> = [ const offsetsByVoice: ReadonlyArray<ReadonlyArray<number>> = [
[0], [0],
[intervals[1], intervals[2]], [intervals[1], intervals[2]],
@ -442,7 +435,7 @@ export class GenerativePianoEngine {
]; ];
offsetsByVoice.forEach((offsets, index) => { offsetsByVoice.forEach((offsets, index) => {
const register = this.generation.padRegisters[index]; const register = generativePianoTuning.padRegisters[index];
const midi = this.chooseMidi( const midi = this.chooseMidi(
{ baseMidi: rootMidi, offsets }, { baseMidi: rootMidi, offsets },
register, register,
@ -469,7 +462,7 @@ export class GenerativePianoEngine {
expression: number, expression: number,
styleIndex: GardenAudioStyleIndex styleIndex: GardenAudioStyleIndex
): void { ): void {
const pool = this.generation.stylePools[styleIndex]; const pool = generativePianoTuning.stylePools[styleIndex];
const chord = this.getChord(profile, barIndex); const chord = this.getChord(profile, barIndex);
const chordIntervals = getChordIntervals(chord, false); const chordIntervals = getChordIntervals(chord, false);
const rootMidi = profile.rootMidi + chord.rootOffset; const rootMidi = profile.rootMidi + chord.rootOffset;
@ -488,22 +481,22 @@ export class GenerativePianoEngine {
this.playProfileNote(profile, { this.playProfileNote(profile, {
midi, midi,
velocity: velocity:
(this.generation.supportNote.velocityBase + (generativePianoTuning.supportNote.velocityBase +
expression * this.generation.supportNote.velocityExpressionWeight) * expression * generativePianoTuning.supportNote.velocityExpressionWeight) *
styleVoices[styleIndex].velocityMultiplier, styleVoices[styleIndex].velocityMultiplier,
startTime, startTime,
durationSeconds: durationSeconds:
this.generation.supportNote.durationBaseSeconds + generativePianoTuning.supportNote.durationBaseSeconds +
expression * this.generation.supportNote.durationExpressionSeconds, expression * generativePianoTuning.supportNote.durationExpressionSeconds,
pan: this.getStylePan(styleIndex), pan: this.getStylePan(styleIndex),
role: 'support', role: 'support',
delaySend: delaySend:
this.generation.supportNote.delaySendBase + generativePianoTuning.supportNote.delaySendBase +
expression * this.generation.supportNote.delaySendExpressionWeight, expression * generativePianoTuning.supportNote.delaySendExpressionWeight,
lowpassHz: this.getLowpassHz( lowpassHz: this.getLowpassHz(
profile, profile,
midi, midi,
expression * this.generation.supportNote.lowpassExpressionWeight expression * generativePianoTuning.supportNote.lowpassExpressionWeight
), ),
}); });
} }
@ -515,7 +508,7 @@ export class GenerativePianoEngine {
expression: number, expression: number,
styleIndex: GardenAudioStyleIndex styleIndex: GardenAudioStyleIndex
): void { ): void {
const pool = this.generation.stylePools[styleIndex]; const pool = generativePianoTuning.stylePools[styleIndex];
const chord = this.getChord(profile, barIndex); const chord = this.getChord(profile, barIndex);
const chordIntervals = getChordIntervals(chord, false); const chordIntervals = getChordIntervals(chord, false);
const degrees = this.rotate(pool.scaleDegrees, barIndex + styleIndex); const degrees = this.rotate(pool.scaleDegrees, barIndex + styleIndex);
@ -534,18 +527,18 @@ export class GenerativePianoEngine {
this.playProfileNote(profile, { this.playProfileNote(profile, {
midi, midi,
velocity: velocity:
(this.generation.textureNote.velocityBase + (generativePianoTuning.textureNote.velocityBase +
expression * this.generation.textureNote.velocityExpressionWeight) * expression * generativePianoTuning.textureNote.velocityExpressionWeight) *
styleVoices[styleIndex].velocityMultiplier, styleVoices[styleIndex].velocityMultiplier,
startTime, startTime,
durationSeconds: durationSeconds:
this.generation.textureNote.durationBaseSeconds + generativePianoTuning.textureNote.durationBaseSeconds +
expression * this.generation.textureNote.durationExpressionSeconds, expression * generativePianoTuning.textureNote.durationExpressionSeconds,
pan: this.getStylePan(styleIndex), pan: this.getStylePan(styleIndex),
role: 'texture', role: 'texture',
delaySend: delaySend:
this.generation.textureNote.delaySendBase + generativePianoTuning.textureNote.delaySendBase +
expression * this.generation.textureNote.delaySendExpressionWeight, expression * generativePianoTuning.textureNote.delaySendExpressionWeight,
lowpassHz: this.getLowpassHz(profile, midi, expression), lowpassHz: this.getLowpassHz(profile, midi, expression),
}); });
} }
@ -557,13 +550,13 @@ export class GenerativePianoEngine {
strength: number strength: number
): void { ): void {
const profile = getVibeProfile(vibe); const profile = getVibeProfile(vibe);
const pool = this.generation.stylePools[styleIndex]; const pool = generativePianoTuning.stylePools[styleIndex];
const startTime = this.getTimeForStep(stepIndex); const startTime = this.getTimeForStep(stepIndex);
const chord = this.getChord(profile, this.getBarIndexForStep(stepIndex)); const chord = this.getChord(profile, this.getBarIndexForStep(stepIndex));
const chordIntervals = getChordIntervals(chord, false); const chordIntervals = getChordIntervals(chord, false);
const degrees = this.rotate( const degrees = this.rotate(
pool.scaleDegrees, pool.scaleDegrees,
Math.round(strength * this.generation.gestureAccent.rotationStrengthMultiplier) Math.round(strength * generativePianoTuning.gestureAccent.rotationStrengthMultiplier)
); );
const midi = this.chooseMidi( const midi = this.chooseMidi(
@ -581,16 +574,16 @@ export class GenerativePianoEngine {
this.playProfileNote(profile, { this.playProfileNote(profile, {
midi, midi,
velocity: velocity:
(this.generation.gestureAccent.velocityBase + (generativePianoTuning.gestureAccent.velocityBase +
strength * this.generation.gestureAccent.velocityStrengthWeight) * strength * generativePianoTuning.gestureAccent.velocityStrengthWeight) *
styleVoices[styleIndex].velocityMultiplier, styleVoices[styleIndex].velocityMultiplier,
startTime, startTime,
durationSeconds: durationSeconds:
this.generation.gestureAccent.durationBaseSeconds + generativePianoTuning.gestureAccent.durationBaseSeconds +
strength * this.generation.gestureAccent.durationStrengthSeconds, strength * generativePianoTuning.gestureAccent.durationStrengthSeconds,
pan: this.getStylePan(styleIndex), pan: this.getStylePan(styleIndex),
role: 'gesture', role: 'gesture',
delaySend: this.generation.gestureAccent.delaySend, delaySend: generativePianoTuning.gestureAccent.delaySend,
lowpassHz: this.getLowpassHz(profile, midi, strength), lowpassHz: this.getLowpassHz(profile, midi, strength),
}); });
} }
@ -607,7 +600,7 @@ export class GenerativePianoEngine {
strength: number; strength: number;
}): void { }): void {
const profile = getVibeProfile(vibe); const profile = getVibeProfile(vibe);
const pool = this.generation.stylePools[styleIndex]; const pool = generativePianoTuning.stylePools[styleIndex];
const chord = this.getChord(profile, this.getGlobalBarIndex(now)); const chord = this.getChord(profile, this.getGlobalBarIndex(now));
const chordIntervals = getChordIntervals(chord, false); const chordIntervals = getChordIntervals(chord, false);
const rootMidi = profile.rootMidi + chord.rootOffset; const rootMidi = profile.rootMidi + chord.rootOffset;
@ -626,22 +619,22 @@ export class GenerativePianoEngine {
this.playProfileNote(profile, { this.playProfileNote(profile, {
midi, midi,
velocity: velocity:
(this.generation.touchNote.velocityBase + (generativePianoTuning.touchNote.velocityBase +
strength * this.generation.touchNote.velocityStrengthWeight) * strength * generativePianoTuning.touchNote.velocityStrengthWeight) *
styleVoices[styleIndex].velocityMultiplier, styleVoices[styleIndex].velocityMultiplier,
startTime: now, startTime: now,
durationSeconds: durationSeconds:
this.generation.touchNote.durationBaseSeconds + generativePianoTuning.touchNote.durationBaseSeconds +
strength * this.generation.touchNote.durationStrengthSeconds, strength * generativePianoTuning.touchNote.durationStrengthSeconds,
pan: this.getStylePan(styleIndex), pan: this.getStylePan(styleIndex),
role: 'gesture', role: 'gesture',
delaySend: this.generation.touchNote.delaySend, delaySend: generativePianoTuning.touchNote.delaySend,
lowpassHz: this.getLowpassHz( lowpassHz: this.getLowpassHz(
profile, profile,
midi, midi,
clamp01( clamp01(
this.generation.touchNote.lowpassBaseExpression + generativePianoTuning.touchNote.lowpassBaseExpression +
strength * this.generation.touchNote.lowpassStrengthWeight strength * generativePianoTuning.touchNote.lowpassStrengthWeight
) )
), ),
}); });
@ -661,8 +654,8 @@ export class GenerativePianoEngine {
maniaAmount: number; maniaAmount: number;
}): void { }): void {
const lifetimeSeconds = const lifetimeSeconds =
this.generation.brushLayerBaseSeconds + generativePianoTuning.brushLayerBaseSeconds +
strength * this.generation.brushLayerEnergySeconds; strength * generativePianoTuning.brushLayerEnergySeconds;
const expiresAt = this.getNextBarTimeAt(now + lifetimeSeconds); const expiresAt = this.getNextBarTimeAt(now + lifetimeSeconds);
this.brushPhraseLayers.push({ this.brushPhraseLayers.push({
@ -672,13 +665,13 @@ export class GenerativePianoEngine {
expiresAt, expiresAt,
styleIndex, styleIndex,
energy: strength, energy: strength,
motifOffsets: [styleIndex + this.generation.brushPhrase.initialMotifOffset], motifOffsets: [styleIndex + generativePianoTuning.brushPhrase.initialMotifOffset],
maniaAmount, maniaAmount,
}); });
if (this.brushPhraseLayers.length > this.generation.maxBrushPhraseLayers) { if (this.brushPhraseLayers.length > generativePianoTuning.maxBrushPhraseLayers) {
this.brushPhraseLayers = this.brushPhraseLayers.slice( this.brushPhraseLayers = this.brushPhraseLayers.slice(
-this.generation.maxBrushPhraseLayers -generativePianoTuning.maxBrushPhraseLayers
); );
} }
} }
@ -704,17 +697,17 @@ export class GenerativePianoEngine {
layer.styleIndex = styleIndex; layer.styleIndex = styleIndex;
layer.energy = Math.max( layer.energy = Math.max(
layer.energy * layer.energy *
Math.exp(-elapsedSeconds / this.generation.brushPhrase.energyDecaySeconds), Math.exp(-elapsedSeconds / generativePianoTuning.brushPhrase.energyDecaySeconds),
strength strength
); );
layer.maniaAmount = Math.max( layer.maniaAmount = Math.max(
layer.maniaAmount * layer.maniaAmount *
Math.exp(-elapsedSeconds / this.generation.brushPhrase.maniaDecaySeconds), Math.exp(-elapsedSeconds / generativePianoTuning.brushPhrase.maniaDecaySeconds),
maniaAmount maniaAmount
); );
layer.motifOffsets.push(this.getMotifOffset(strength)); layer.motifOffsets.push(this.getMotifOffset(strength));
if (layer.motifOffsets.length > this.generation.brushMotifMaxSteps) { if (layer.motifOffsets.length > generativePianoTuning.brushMotifMaxSteps) {
layer.motifOffsets = layer.motifOffsets.slice(-this.generation.brushMotifMaxSteps); layer.motifOffsets = layer.motifOffsets.slice(-generativePianoTuning.brushMotifMaxSteps);
} }
} }
@ -750,7 +743,7 @@ export class GenerativePianoEngine {
const startTime = this.getTimeForStep(this.nextBrushStreamStep); const startTime = this.getTimeForStep(this.nextBrushStreamStep);
const frame = this.getBrushStreamFrame(startTime, activity); const frame = this.getBrushStreamFrame(startTime, activity);
if ( if (
frame.intensity >= this.generation.brushLayerMinIntensity && frame.intensity >= generativePianoTuning.brushLayerMinIntensity &&
this.reserveBrushStreamNote(this.nextBrushStreamStep) this.reserveBrushStreamNote(this.nextBrushStreamStep)
) { ) {
this.playBrushStreamNote({ this.playBrushStreamNote({
@ -783,22 +776,22 @@ export class GenerativePianoEngine {
layer: BrushPhraseLayer | null; layer: BrushPhraseLayer | null;
}): void { }): void {
const profile = getVibeProfile(vibe); const profile = getVibeProfile(vibe);
const pool = this.generation.stylePools[styleIndex]; const pool = generativePianoTuning.stylePools[styleIndex];
const maniaAmount = const maniaAmount =
layer?.maniaAmount ?? layer?.maniaAmount ??
clamp01( clamp01(
(intensity - this.generation.brushStream.inferredManiaThreshold) / (intensity - generativePianoTuning.brushStream.inferredManiaThreshold) /
this.generation.brushStream.inferredManiaRange generativePianoTuning.brushStream.inferredManiaRange
); );
const register = this.getBiasedRegister( const register = this.getBiasedRegister(
pool, pool,
maniaAmount * this.generation.brushStream.registerManiaShift maniaAmount * generativePianoTuning.brushStream.registerManiaShift
); );
const chord = this.getChord(profile, this.getBarIndexForStep(stepIndex)); const chord = this.getChord(profile, this.getBarIndexForStep(stepIndex));
const chordIntervals = getChordIntervals(chord, false); const chordIntervals = getChordIntervals(chord, false);
const rootMidi = profile.rootMidi + chord.rootOffset; const rootMidi = profile.rootMidi + chord.rootOffset;
const useChordTone = const useChordTone =
this.brushStreamNoteIndex % this.generation.brushStream.chordToneEverySteps === 0; this.brushStreamNoteIndex % generativePianoTuning.brushStream.chordToneEverySteps === 0;
const source = useChordTone const source = useChordTone
? { ? {
baseMidi: rootMidi, baseMidi: rootMidi,
@ -817,18 +810,18 @@ export class GenerativePianoEngine {
const midi = this.chooseMidi(source, register, this.lastBrushStreamMidi, true); const midi = this.chooseMidi(source, register, this.lastBrushStreamMidi, true);
const pan = this.getStylePan(styleIndex); const pan = this.getStylePan(styleIndex);
const durationSeconds = clamp( const durationSeconds = clamp(
this.generation.brushStream.durationBaseSeconds + generativePianoTuning.brushStream.durationBaseSeconds +
intensity * this.generation.brushStream.durationIntensitySeconds - intensity * generativePianoTuning.brushStream.durationIntensitySeconds -
maniaAmount * this.generation.brushStream.durationManiaSeconds, maniaAmount * generativePianoTuning.brushStream.durationManiaSeconds,
this.generation.brushStream.durationMinSeconds, generativePianoTuning.brushStream.durationMinSeconds,
this.generation.brushStream.durationMaxSeconds generativePianoTuning.brushStream.durationMaxSeconds
); );
const delaySend = clamp( const delaySend = clamp(
this.generation.brushStream.delaySendBase + generativePianoTuning.brushStream.delaySendBase +
intensity * this.generation.brushStream.delaySendIntensityWeight - intensity * generativePianoTuning.brushStream.delaySendIntensityWeight -
maniaAmount * this.generation.brushStream.delaySendManiaWeight, maniaAmount * generativePianoTuning.brushStream.delaySendManiaWeight,
this.generation.brushStream.delaySendMin, generativePianoTuning.brushStream.delaySendMin,
this.generation.brushStream.delaySendMax generativePianoTuning.brushStream.delaySendMax
); );
this.lastBrushStreamMidi = midi; this.lastBrushStreamMidi = midi;
@ -836,8 +829,8 @@ export class GenerativePianoEngine {
this.playProfileNote(profile, { this.playProfileNote(profile, {
midi, midi,
velocity: velocity:
(this.generation.brushStream.velocityBase + (generativePianoTuning.brushStream.velocityBase +
intensity * this.generation.brushStream.velocityIntensityWeight) * intensity * generativePianoTuning.brushStream.velocityIntensityWeight) *
styleVoices[styleIndex].velocityMultiplier, styleVoices[styleIndex].velocityMultiplier,
startTime, startTime,
durationSeconds, durationSeconds,
@ -848,46 +841,46 @@ export class GenerativePianoEngine {
profile, profile,
midi, midi,
clamp01( clamp01(
this.generation.brushStream.lowpassBaseExpression + generativePianoTuning.brushStream.lowpassBaseExpression +
intensity * this.generation.brushStream.lowpassIntensityWeight + intensity * generativePianoTuning.brushStream.lowpassIntensityWeight +
maniaAmount * this.generation.brushStream.lowpassManiaWeight maniaAmount * generativePianoTuning.brushStream.lowpassManiaWeight
) )
), ),
}); });
if ( if (
maniaAmount >= this.generation.brushStreamEcho.maniaThreshold && maniaAmount >= generativePianoTuning.brushStreamEcho.maniaThreshold &&
(this.brushStreamNoteIndex % this.generation.brushStreamEcho.stepModulo === (this.brushStreamNoteIndex % generativePianoTuning.brushStreamEcho.stepModulo ===
this.generation.brushStreamEcho.stepRemainder || generativePianoTuning.brushStreamEcho.stepRemainder ||
intensity >= this.generation.brushStreamEcho.intensityThreshold) intensity >= generativePianoTuning.brushStreamEcho.intensityThreshold)
) { ) {
const echoMidi = const echoMidi =
midi + this.generation.brushStreamEcho.octaveSemitones <= midi + generativePianoTuning.brushStreamEcho.octaveSemitones <=
this.generation.brushStreamEcho.maxMidi generativePianoTuning.brushStreamEcho.maxMidi
? midi + this.generation.brushStreamEcho.octaveSemitones ? midi + generativePianoTuning.brushStreamEcho.octaveSemitones
: midi - this.generation.brushStreamEcho.octaveSemitones; : midi - generativePianoTuning.brushStreamEcho.octaveSemitones;
this.playProfileNote(profile, { this.playProfileNote(profile, {
midi: echoMidi, midi: echoMidi,
velocity: velocity:
(this.generation.brushStreamEcho.velocityBase + (generativePianoTuning.brushStreamEcho.velocityBase +
intensity * this.generation.brushStreamEcho.velocityIntensityWeight) * intensity * generativePianoTuning.brushStreamEcho.velocityIntensityWeight) *
styleVoices[styleIndex].velocityMultiplier, styleVoices[styleIndex].velocityMultiplier,
startTime: startTime + this.generation.brushMotifCanonDelaySeconds, startTime: startTime + generativePianoTuning.brushMotifCanonDelaySeconds,
durationSeconds: Math.max( durationSeconds: Math.max(
this.generation.brushStreamEcho.durationMinSeconds, generativePianoTuning.brushStreamEcho.durationMinSeconds,
durationSeconds * this.generation.brushStreamEcho.durationScale durationSeconds * generativePianoTuning.brushStreamEcho.durationScale
), ),
pan: clamp(pan * this.generation.brushStreamEcho.panScale, -1, 1), pan: clamp(pan * generativePianoTuning.brushStreamEcho.panScale, -1, 1),
role: 'brush', role: 'brush',
delaySend: Math.max( delaySend: Math.max(
this.generation.brushStreamEcho.delaySendMin, generativePianoTuning.brushStreamEcho.delaySendMin,
delaySend * this.generation.brushStreamEcho.delaySendScale delaySend * generativePianoTuning.brushStreamEcho.delaySendScale
), ),
lowpassHz: this.getLowpassHz( lowpassHz: this.getLowpassHz(
profile, profile,
echoMidi, echoMidi,
this.generation.brushStreamEcho.lowpassBaseExpression + generativePianoTuning.brushStreamEcho.lowpassBaseExpression +
maniaAmount * this.generation.brushStreamEcho.lowpassManiaWeight maniaAmount * generativePianoTuning.brushStreamEcho.lowpassManiaWeight
), ),
}); });
} }
@ -905,8 +898,8 @@ export class GenerativePianoEngine {
intensity: intensity:
layer.energy * layer.energy *
this.getBrushPhraseFade(layer, startTime) * this.getBrushPhraseFade(layer, startTime) *
(this.generation.brushPhrase.layerIntensityBase + (generativePianoTuning.brushPhrase.layerIntensityBase +
layer.maniaAmount * this.generation.brushPhrase.layerIntensityManiaWeight), layer.maniaAmount * generativePianoTuning.brushPhrase.layerIntensityManiaWeight),
})); }));
const dominant = layerStates.reduce<{ const dominant = layerStates.reduce<{
layer: BrushPhraseLayer; layer: BrushPhraseLayer;
@ -924,10 +917,10 @@ export class GenerativePianoEngine {
return { return {
intensity: clamp01( intensity: clamp01(
activity * this.generation.brushPhrase.frameActivityWeight + activity * generativePianoTuning.brushPhrase.frameActivityWeight +
layeredIntensity + layeredIntensity +
(dominant?.layer.maniaAmount ?? 0) * (dominant?.layer.maniaAmount ?? 0) *
this.generation.brushPhrase.frameManiaWeight generativePianoTuning.brushPhrase.frameManiaWeight
), ),
layer: dominant?.layer ?? null, layer: dominant?.layer ?? null,
}; };
@ -935,11 +928,11 @@ export class GenerativePianoEngine {
private getBrushStreamIntervalSteps(intensity: number): number { private getBrushStreamIntervalSteps(intensity: number): number {
const intervalBeats = const intervalBeats =
intensity >= this.generation.brushStream.intenseThreshold intensity >= generativePianoTuning.brushStream.intenseThreshold
? this.generation.brushStreamIntenseIntervalBeats ? generativePianoTuning.brushStreamIntenseIntervalBeats
: intensity >= this.generation.brushStream.activeThreshold : intensity >= generativePianoTuning.brushStream.activeThreshold
? this.generation.brushStreamActiveIntervalBeats ? generativePianoTuning.brushStreamActiveIntervalBeats
: this.generation.brushStreamIdleIntervalBeats; : generativePianoTuning.brushStreamIdleIntervalBeats;
return Math.max(1, Math.round(intervalBeats * this.config.rhythm.stepsPerBeat)); return Math.max(1, Math.round(intervalBeats * this.config.rhythm.stepsPerBeat));
} }
@ -950,11 +943,11 @@ export class GenerativePianoEngine {
} }
private getMotifOffset(strength: number): number { private getMotifOffset(strength: number): number {
return strength >= this.generation.brushMotif.highThreshold return strength >= generativePianoTuning.brushMotif.highThreshold
? this.generation.brushMotif.highOffset ? generativePianoTuning.brushMotif.highOffset
: strength >= this.generation.brushMotif.mediumThreshold : strength >= generativePianoTuning.brushMotif.mediumThreshold
? this.generation.brushMotif.mediumOffset ? generativePianoTuning.brushMotif.mediumOffset
: this.generation.brushMotif.lowOffset; : generativePianoTuning.brushMotif.lowOffset;
} }
private getBrushMotifDegrees({ private getBrushMotifDegrees({
@ -986,17 +979,17 @@ export class GenerativePianoEngine {
maniaAmount: number maniaAmount: number
): GardenAudioRegister { ): GardenAudioRegister {
const shift = Math.round( const shift = Math.round(
maniaAmount * this.generation.registerBias.maniaShiftSemitones maniaAmount * generativePianoTuning.registerBias.maniaShiftSemitones
); );
const midiMin = clamp( const midiMin = clamp(
register.midiMin + shift, register.midiMin + shift,
this.generation.registerBias.midiMin, generativePianoTuning.registerBias.midiMin,
this.generation.registerBias.midiMaxForMin generativePianoTuning.registerBias.midiMaxForMin
); );
const midiMax = clamp( const midiMax = clamp(
register.midiMax + shift, register.midiMax + shift,
midiMin + this.generation.registerBias.minimumSpan, midiMin + generativePianoTuning.registerBias.minimumSpan,
this.generation.registerBias.midiMax generativePianoTuning.registerBias.midiMax
); );
return { return {
@ -1039,8 +1032,8 @@ export class GenerativePianoEngine {
pitchSource.offsets.forEach((offset, preference) => { pitchSource.offsets.forEach((offset, preference) => {
for ( for (
let octave = this.generation.candidateOctaveSearch.min; let octave = generativePianoTuning.candidateOctaveSearch.min;
octave <= this.generation.candidateOctaveSearch.max; octave <= generativePianoTuning.candidateOctaveSearch.max;
octave += 1 octave += 1
) { ) {
const midi = pitchSource.baseMidi + offset + octave * PITCH_SEMITONES_PER_OCTAVE; const midi = pitchSource.baseMidi + offset + octave * PITCH_SEMITONES_PER_OCTAVE;
@ -1067,38 +1060,38 @@ export class GenerativePianoEngine {
return ( return (
Math.abs(candidate.midi - previousMidi) + Math.abs(candidate.midi - previousMidi) +
Math.abs(candidate.midi - register.preferredMidi) * Math.abs(candidate.midi - register.preferredMidi) *
this.generation.noteScoreRegisterWeight + generativePianoTuning.noteScoreRegisterWeight +
candidate.preference * this.generation.noteScorePreferenceWeight + candidate.preference * generativePianoTuning.noteScorePreferenceWeight +
candidate.chordToneDistance * this.generation.noteScoreChordToneWeight + candidate.chordToneDistance * generativePianoTuning.noteScoreChordToneWeight +
(avoidRepeat && candidate.midi === previousMidi (avoidRepeat && candidate.midi === previousMidi
? this.generation.noteScoreRepeatPenalty ? generativePianoTuning.noteScoreRepeatPenalty
: 0) : 0)
); );
} }
private shouldPlaySupport(expression: number, barIndex: number): boolean { private shouldPlaySupport(expression: number, barIndex: number): boolean {
if (expression >= this.generation.supportNote.expressionThreshold) { if (expression >= generativePianoTuning.supportNote.expressionThreshold) {
return true; return true;
} }
return ( return (
barIndex % this.generation.supportBarSpacing === this.generation.supportBarOffset barIndex % generativePianoTuning.supportBarSpacing === generativePianoTuning.supportBarOffset
); );
} }
private shouldPlayTexture(expression: number, barIndex: number): boolean { private shouldPlayTexture(expression: number, barIndex: number): boolean {
const spacing = const spacing =
expression < this.generation.textureNote.idleExpressionThreshold expression < generativePianoTuning.textureNote.idleExpressionThreshold
? this.generation.idleTextureBarSpacing ? generativePianoTuning.idleTextureBarSpacing
: expression < this.generation.textureNote.mediumExpressionThreshold : expression < generativePianoTuning.textureNote.mediumExpressionThreshold
? this.generation.mediumTextureBarSpacing ? generativePianoTuning.mediumTextureBarSpacing
: this.generation.textureNote.intenseSpacing; : generativePianoTuning.textureNote.intenseSpacing;
return ( return (
barIndex % spacing === barIndex % spacing ===
(spacing === this.generation.textureNote.intenseSpacing (spacing === generativePianoTuning.textureNote.intenseSpacing
? 0 ? 0
: this.generation.textureNote.idlePhase) : generativePianoTuning.textureNote.idlePhase)
); );
} }
@ -1106,14 +1099,14 @@ export class GenerativePianoEngine {
chordIntervals: ReadonlyArray<number>, chordIntervals: ReadonlyArray<number>,
styleIndex: GardenAudioStyleIndex styleIndex: GardenAudioStyleIndex
): Array<number> { ): Array<number> {
return this.generation.supportNote.offsetsByStyle[styleIndex].map((offset) => return generativePianoTuning.supportNote.offsetsByStyle[styleIndex].map((offset) =>
getConfiguredChordOffset(chordIntervals, offset) getConfiguredChordOffset(chordIntervals, offset)
); );
} }
private getChord(profile: GardenAudioVibeProfile, barIndex: number): GardenAudioChord { private getChord(profile: GardenAudioVibeProfile, barIndex: number): GardenAudioChord {
const progressionIndex = const progressionIndex =
Math.floor(barIndex / this.generation.chordBars) % profile.progression.length; Math.floor(barIndex / generativePianoTuning.chordBars) % profile.progression.length;
return profile.progression[progressionIndex]; return profile.progression[progressionIndex];
} }
@ -1122,17 +1115,17 @@ export class GenerativePianoEngine {
} }
private getStyleIndex(startTime: number): GardenAudioStyleIndex { private getStyleIndex(startTime: number): GardenAudioStyleIndex {
const styleCount = this.generation.stylePools.length; const styleCount = generativePianoTuning.stylePools.length;
const rotationBars = Math.max(1, Math.round(this.generation.styleRotationBars)); const rotationBars = Math.max(1, Math.round(generativePianoTuning.styleRotationBars));
return (Math.floor(this.getGlobalBarIndex(startTime) / rotationBars) % return (Math.floor(this.getGlobalBarIndex(startTime) / rotationBars) %
styleCount) as GardenAudioStyleIndex; styleCount) as GardenAudioStyleIndex;
} }
private getStylePan(styleIndex: GardenAudioStyleIndex): number { private getStylePan(styleIndex: GardenAudioStyleIndex): number {
const pool = this.generation.stylePools[styleIndex]; const pool = generativePianoTuning.stylePools[styleIndex];
const styleVoice = styleVoices[styleIndex]; const styleVoice = styleVoices[styleIndex];
return clamp( return clamp(
pool.pan + styleVoice.panOffset * this.generation.stylePanOffsetScale, pool.pan + styleVoice.panOffset * generativePianoTuning.stylePanOffsetScale,
-1, -1,
1 1
); );
@ -1145,13 +1138,13 @@ export class GenerativePianoEngine {
): number { ): number {
const midiLift = const midiLift =
clamp01( clamp01(
(midi - this.generation.lowpass.midiBase) / this.generation.lowpass.midiRange (midi - generativePianoTuning.lowpass.midiBase) / generativePianoTuning.lowpass.midiRange
) * this.generation.lowpass.midiLiftHz; ) * generativePianoTuning.lowpass.midiLiftHz;
return clamp( return clamp(
this.config.piano.lowpassHz * this.config.piano.lowpassHz *
profile.brightness * profile.brightness *
(this.generation.lowpass.expressionBase + (generativePianoTuning.lowpass.expressionBase +
expression * this.generation.lowpass.expressionWeight) + expression * generativePianoTuning.lowpass.expressionWeight) +
midiLift, midiLift,
this.config.piano.lowpassMinHz, this.config.piano.lowpassMinHz,
this.config.piano.lowpassMaxHz this.config.piano.lowpassMaxHz
@ -1174,14 +1167,14 @@ export class GenerativePianoEngine {
} }
private getExpression(activity: number): number { private getExpression(activity: number): number {
const liftedActivity = Math.max( const activityExpression = clamp01(
activity, (activity - this.config.rhythm.sparseActivity) /
this.activeProfile?.idleIntensity ?? this.config.rhythm.idleIntensity
);
return clamp01(
(liftedActivity - this.config.rhythm.sparseActivity) /
(1 - this.config.rhythm.sparseActivity) (1 - this.config.rhythm.sparseActivity)
); );
const idleExpression = clamp01(
this.activeProfile?.idleIntensity ?? this.config.rhythm.idleIntensity
);
return Math.max(activityExpression, idleExpression);
} }
private getBeatDurationSeconds(): number { private getBeatDurationSeconds(): number {
@ -1263,7 +1256,7 @@ export class GenerativePianoEngine {
private reserveBrushStreamNote(stepIndex: number): boolean { private reserveBrushStreamNote(stepIndex: number): boolean {
const barIndex = this.getBarIndexForStep(stepIndex); const barIndex = this.getBarIndexForStep(stepIndex);
const noteCount = this.brushStreamNoteCountsByBar.get(barIndex) ?? 0; const noteCount = this.brushStreamNoteCountsByBar.get(barIndex) ?? 0;
if (noteCount >= this.generation.maxBrushStreamNotesPerBar) { if (noteCount >= generativePianoTuning.maxBrushStreamNotesPerBar) {
return false; return false;
} }

View file

@ -88,10 +88,7 @@ export class PianoSampler {
const sample = this.findNearestSample(midi); const sample = this.findNearestSample(midi);
if (sample) { if (sample) {
const noteGainValue = Math.max( const noteGainValue = this.computeNoteGain(noteVelocity);
pianoSamplerTuning.minGain,
this.config.piano.gain * noteVelocity
);
const sustainSeconds = const sustainSeconds =
profileSustainSeconds * profileSustainSeconds *
(this.config.piano.sustainBase + (this.config.piano.sustainBase +
@ -143,9 +140,9 @@ export class PianoSampler {
return; return;
} }
const noteGainValue = Math.max( const noteGainValue = this.computeNoteGain(
pianoSamplerTuning.minGain, noteVelocity,
this.config.piano.gain * noteVelocity * pianoSamplerTuning.synthGainScale pianoSamplerTuning.synthGainScale
); );
const releaseAt = const releaseAt =
scheduledStart + scheduledStart +
@ -277,6 +274,13 @@ export class PianoSampler {
); );
} }
private computeNoteGain(velocity: number, scale = 1): number {
return Math.max(
pianoSamplerTuning.minGain,
this.config.piano.gain * velocity * scale
);
}
private findNearestSample(midi: number): LoadedPianoSample | null { private findNearestSample(midi: number): LoadedPianoSample | null {
if (this.samples.length === 0) { if (this.samples.length === 0) {
return null; return null;

View file

@ -5,13 +5,10 @@ import { runtimeControls } from './config/runtime-controls';
import type { GardenAppConfig } from './config/types'; import type { GardenAppConfig } from './config/types';
import { defaultVibeId, vibePresets } from './config/vibe-presets'; import { defaultVibeId, vibePresets } from './config/vibe-presets';
export { VibeId } from './config/types';
export type { export type {
GardenAppConfig, GardenAppConfig,
GardenRuntimeSettings, GardenRuntimeSettings,
NumberControlConfig, NumberControlConfig,
VibePreset,
} from './config/types'; } from './config/types';
export const appConfig = { export const appConfig = {

View file

@ -12,17 +12,15 @@ export const colorInteractionSettings = {
color3ToColor3: 1, color3ToColor3: 1,
}; };
const agentInteractionOptions: Record<string, number> = {
Follow: 1,
Avoid: -1,
Ignore: 0,
};
export const colorInteractionControl = (label: string): NumberControlConfig => ({ export const colorInteractionControl = (label: string): NumberControlConfig => ({
folder: 'Color Reactions', folder: 'Color Reactions',
label, label,
min: -1, min: -1,
max: 1, max: 1,
step: 1, step: 1,
options: agentInteractionOptions, options: {
Follow: 1,
Avoid: -1,
Ignore: 0,
},
}); });

View file

@ -34,7 +34,6 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = {
brushAlpha: 1, brushAlpha: 1,
brushDiscardThreshold: 0.02, brushDiscardThreshold: 0.02,
brushCoarseNoiseScale: 160,
brushGrainNoiseScale: 22, brushGrainNoiseScale: 22,
brushGrainNoiseOffsetX: 0.31, brushGrainNoiseOffsetX: 0.31,
brushGrainNoiseOffsetY: 0.67, brushGrainNoiseOffsetY: 0.67,

View file

@ -1,6 +1,8 @@
import { colorInteractionControl } from './color-interactions'; import { colorInteractionControl } from './color-interactions';
import type { GardenAppConfig } from './types'; import type { GardenAppConfig } from './types';
const formatPercent = (value: number): string => `${Math.round(value * 100)}%`;
export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = { export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
color1ToColor1: colorInteractionControl('1 -> 1'), color1ToColor1: colorInteractionControl('1 -> 1'),
color1ToColor2: colorInteractionControl('1 -> 2'), color1ToColor2: colorInteractionControl('1 -> 2'),
@ -26,21 +28,6 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
max: 1, max: 1,
step: 0.001, step: 0.001,
}, },
brushSizeVariation: {
folder: 'Brush',
label: 'brush variance',
min: 0,
max: 1,
step: 0.01,
},
diffusionRateBrush: {
folder: 'Brush',
label: 'brush diffusion',
min: 0.001,
max: 1,
step: 0.001,
},
sensorOffsetDistance: { sensorOffsetDistance: {
folder: 'Agents', folder: 'Agents',
label: 'sensor distance', label: 'sensor distance',
@ -62,6 +49,21 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
max: 200, max: 200,
step: 1, step: 1,
}, },
forwardRotationScale: {
folder: 'Agents',
format: formatPercent,
label: 'following sensor %',
min: 0,
max: 1,
step: 0.01,
},
turnWhenLost: {
folder: 'Agents',
label: 'turn when lost',
min: 0,
max: 6.28,
step: 0.01,
},
individualTrailWeight: { individualTrailWeight: {
folder: 'Agents', folder: 'Agents',
label: 'individual trail weight', label: 'individual trail weight',

View file

@ -2,13 +2,14 @@ import type {
GardenAudioConfig, GardenAudioConfig,
GardenAudioVibeSettings, GardenAudioVibeSettings,
} from '../audio/garden-audio-config'; } from '../audio/garden-audio-config';
import type { AgentSettings } from '../pipelines/agents/agent-settings'; import type { AgentSettings } from '../pipelines/agents/agent-pipeline';
import type { BrushSettings } from '../pipelines/brush/brush-settings'; import type { BrushSettings } from '../pipelines/brush/brush-pipeline';
import type { DiffusionSettings } from '../pipelines/diffusion/diffusion-settings'; import type { DiffusionSettings } from '../pipelines/diffusion/diffusion-pipeline';
import type { RenderSettings } from '../pipelines/render/render-settings'; import type { RenderSettings } from '../pipelines/render/render-pipeline';
import type { RgbColor } from '../utils/rgb-color'; import type { RgbColor } from '../utils/rgb-color';
export interface NumberControlConfig { export interface NumberControlConfig {
format?: (value: number) => string;
folder: string; folder: string;
integer?: boolean; integer?: boolean;
label?: string; label?: string;
@ -53,7 +54,6 @@ type GardenVibeSettings = Pick<
GardenRuntimeSettings, GardenRuntimeSettings,
| 'backgroundGrainStrength' | 'backgroundGrainStrength'
| 'brushSize' | 'brushSize'
| 'brushSizeVariation'
| 'clarity' | 'clarity'
| 'color1ToColor1' | 'color1ToColor1'
| 'color1ToColor2' | 'color1ToColor2'
@ -65,7 +65,6 @@ type GardenVibeSettings = Pick<
| 'color3ToColor2' | 'color3ToColor2'
| 'color3ToColor3' | 'color3ToColor3'
| 'decayRateTrails' | 'decayRateTrails'
| 'diffusionRateBrush'
| 'individualTrailWeight' | 'individualTrailWeight'
| 'moveSpeed' | 'moveSpeed'
| 'sensorOffsetDistance' | 'sensorOffsetDistance'

View file

@ -27,10 +27,8 @@ export const vibePresets: Array<VibePreset> = [
...colorInteractionSettings, ...colorInteractionSettings,
backgroundGrainStrength: 0.018, backgroundGrainStrength: 0.018,
brushSize: 14, brushSize: 14,
brushSizeVariation: 0.5,
clarity: 0.62, clarity: 0.62,
decayRateTrails: 965, decayRateTrails: 965,
diffusionRateBrush: 0.35,
individualTrailWeight: 0.07, individualTrailWeight: 0.07,
moveSpeed: 82, moveSpeed: 82,
sensorOffsetDistance: 38, sensorOffsetDistance: 38,
@ -55,10 +53,8 @@ export const vibePresets: Array<VibePreset> = [
...colorInteractionSettings, ...colorInteractionSettings,
backgroundGrainStrength: 0.014, backgroundGrainStrength: 0.014,
brushSize: 16, brushSize: 16,
brushSizeVariation: 0.35,
clarity: 0.68, clarity: 0.68,
decayRateTrails: 975, decayRateTrails: 975,
diffusionRateBrush: 0.28,
individualTrailWeight: 0.06, individualTrailWeight: 0.06,
moveSpeed: 70, moveSpeed: 70,
sensorOffsetDistance: 46, sensorOffsetDistance: 46,
@ -83,10 +79,8 @@ export const vibePresets: Array<VibePreset> = [
...colorInteractionSettings, ...colorInteractionSettings,
backgroundGrainStrength: 0.022, backgroundGrainStrength: 0.022,
brushSize: 13, brushSize: 13,
brushSizeVariation: 0.58,
clarity: 0.58, clarity: 0.58,
decayRateTrails: 955, decayRateTrails: 955,
diffusionRateBrush: 0.42,
individualTrailWeight: 0.055, individualTrailWeight: 0.055,
moveSpeed: 90, moveSpeed: 90,
sensorOffsetDistance: 35, sensorOffsetDistance: 35,
@ -111,10 +105,8 @@ export const vibePresets: Array<VibePreset> = [
...colorInteractionSettings, ...colorInteractionSettings,
backgroundGrainStrength: 0.018, backgroundGrainStrength: 0.018,
brushSize: 12, brushSize: 12,
brushSizeVariation: 0.45,
clarity: 0.64, clarity: 0.64,
decayRateTrails: 968, decayRateTrails: 968,
diffusionRateBrush: 0.32,
individualTrailWeight: 0.065, individualTrailWeight: 0.065,
moveSpeed: 76, moveSpeed: 76,
sensorOffsetDistance: 42, sensorOffsetDistance: 42,
@ -139,10 +131,8 @@ export const vibePresets: Array<VibePreset> = [
...colorInteractionSettings, ...colorInteractionSettings,
backgroundGrainStrength: 0.024, backgroundGrainStrength: 0.024,
brushSize: 15, brushSize: 15,
brushSizeVariation: 0.62,
clarity: 0.55, clarity: 0.55,
decayRateTrails: 948, decayRateTrails: 948,
diffusionRateBrush: 0.48,
individualTrailWeight: 0.05, individualTrailWeight: 0.05,
moveSpeed: 96, moveSpeed: 96,
sensorOffsetDistance: 32, sensorOffsetDistance: 32,
@ -167,10 +157,8 @@ export const vibePresets: Array<VibePreset> = [
...colorInteractionSettings, ...colorInteractionSettings,
backgroundGrainStrength: 0.012, backgroundGrainStrength: 0.012,
brushSize: 18, brushSize: 18,
brushSizeVariation: 0.28,
clarity: 0.7, clarity: 0.7,
decayRateTrails: 982, decayRateTrails: 982,
diffusionRateBrush: 0.24,
individualTrailWeight: 0.075, individualTrailWeight: 0.075,
moveSpeed: 62, moveSpeed: 62,
sensorOffsetDistance: 52, sensorOffsetDistance: 52,

View file

@ -1,71 +0,0 @@
import { EraserPreview } from './eraser-preview';
interface EraserPointerPreviewControllerOptions {
canvas: HTMLCanvasElement;
eraserPreview: EraserPreview;
getIsSwipeActive: () => boolean;
}
export class EraserPointerPreviewController {
public constructor(private readonly options: EraserPointerPreviewControllerOptions) {}
public attach(): void {
this.canvas.addEventListener('pointerenter', this.onPointerEnter);
this.canvas.addEventListener('pointerleave', this.onPointerLeave);
this.canvas.addEventListener('pointerdown', this.onPointerDown);
this.canvas.addEventListener('pointermove', this.onPointerMove);
this.canvas.addEventListener('pointerup', this.onPointerUp);
this.canvas.addEventListener('pointercancel', this.onPointerUp);
}
public detach(): void {
this.canvas.removeEventListener('pointerenter', this.onPointerEnter);
this.canvas.removeEventListener('pointerleave', this.onPointerLeave);
this.canvas.removeEventListener('pointerdown', this.onPointerDown);
this.canvas.removeEventListener('pointermove', this.onPointerMove);
this.canvas.removeEventListener('pointerup', this.onPointerUp);
this.canvas.removeEventListener('pointercancel', this.onPointerUp);
}
public setEraseMode(isErasing: boolean): void {
this.options.eraserPreview.setEraseMode(isErasing, this.isSwipeActive);
}
public update(event?: PointerEvent): void {
this.options.eraserPreview.update(event, this.isSwipeActive);
}
private get canvas(): HTMLCanvasElement {
return this.options.canvas;
}
private get isSwipeActive(): boolean {
return this.options.getIsSwipeActive();
}
private readonly onPointerDown = (event: PointerEvent) => {
this.options.eraserPreview.setPointerHoveringCanvas(true);
this.update(event);
};
private readonly onPointerMove = (event: PointerEvent) => {
this.update(event);
};
private readonly onPointerUp = (event: PointerEvent) => {
this.options.eraserPreview.setPointerHoveringCanvas(
this.options.eraserPreview.isPointerInsideCanvas(event)
);
this.update(event);
};
private readonly onPointerEnter = (event: PointerEvent) => {
this.options.eraserPreview.setPointerHoveringCanvas(true);
this.update(event);
};
private readonly onPointerLeave = () => {
this.options.eraserPreview.setPointerHoveringCanvas(false);
this.update();
};
}

View file

@ -4,6 +4,7 @@ export class EraserPreview {
private previewClientPosition: { x: number; y: number } | null = null; private previewClientPosition: { x: number; y: number } | null = null;
private isErasing = false; private isErasing = false;
private isPointerHoveringCanvas = false; private isPointerHoveringCanvas = false;
private isSwipeActive = false;
private previousSize: number | null = null; private previousSize: number | null = null;
private previousLeft = ''; private previousLeft = '';
private previousTop = ''; private previousTop = '';
@ -11,19 +12,36 @@ export class EraserPreview {
public constructor( public constructor(
private readonly canvas: HTMLCanvasElement, private readonly canvas: HTMLCanvasElement,
private readonly element: HTMLElement private readonly element: HTMLElement,
private readonly getIsSwipeActive: () => boolean
) {} ) {}
public setEraseMode(isErasing: boolean, isSwipeActive: boolean): void { public attach(): void {
this.canvas.addEventListener('pointerenter', this.onPointerEnter);
this.canvas.addEventListener('pointerleave', this.onPointerLeave);
this.canvas.addEventListener('pointerdown', this.onPointerDown);
this.canvas.addEventListener('pointermove', this.onPointerMove);
this.canvas.addEventListener('pointerup', this.onPointerUp);
this.canvas.addEventListener('pointercancel', this.onPointerUp);
}
public detach(): void {
this.canvas.removeEventListener('pointerenter', this.onPointerEnter);
this.canvas.removeEventListener('pointerleave', this.onPointerLeave);
this.canvas.removeEventListener('pointerdown', this.onPointerDown);
this.canvas.removeEventListener('pointermove', this.onPointerMove);
this.canvas.removeEventListener('pointerup', this.onPointerUp);
this.canvas.removeEventListener('pointercancel', this.onPointerUp);
}
public setEraseMode(isErasing: boolean): void {
this.isErasing = isErasing; this.isErasing = isErasing;
this.update(undefined, isSwipeActive); this.update();
} }
public setPointerHoveringCanvas(isHovering: boolean): void { public update(event?: PointerEvent): void {
this.isPointerHoveringCanvas = isHovering; this.isSwipeActive = this.getIsSwipeActive();
}
public update(event?: PointerEvent, isSwipeActive = false): void {
if (event) { if (event) {
this.previewClientPosition = { this.previewClientPosition = {
x: event.clientX, x: event.clientX,
@ -39,7 +57,7 @@ export class EraserPreview {
if ( if (
!this.isErasing || !this.isErasing ||
this.previewClientPosition === null || this.previewClientPosition === null ||
(!this.isPointerHoveringCanvas && !isSwipeActive) (!this.isPointerHoveringCanvas && !this.isSwipeActive)
) { ) {
this.setVisible(false); this.setVisible(false);
return; return;
@ -59,7 +77,16 @@ export class EraserPreview {
this.setVisible(true); this.setVisible(true);
} }
public isPointerInsideCanvas(event: PointerEvent): boolean { private setVisible(isVisible: boolean): void {
if (this.isVisible === isVisible) {
return;
}
this.isVisible = isVisible;
this.element.classList.toggle('visible', isVisible);
}
private isPointerInsideCanvas(event: PointerEvent): boolean {
const rect = this.canvas.getBoundingClientRect(); const rect = this.canvas.getBoundingClientRect();
return ( return (
event.clientX >= rect.left && event.clientX >= rect.left &&
@ -69,12 +96,27 @@ export class EraserPreview {
); );
} }
private setVisible(isVisible: boolean): void { private readonly onPointerDown = (event: PointerEvent) => {
if (this.isVisible === isVisible) { this.isPointerHoveringCanvas = true;
return; this.update(event);
} };
this.isVisible = isVisible; private readonly onPointerMove = (event: PointerEvent) => {
this.element.classList.toggle('visible', isVisible); this.update(event);
} };
private readonly onPointerUp = (event: PointerEvent) => {
this.isPointerHoveringCanvas = this.isPointerInsideCanvas(event);
this.update(event);
};
private readonly onPointerEnter = (event: PointerEvent) => {
this.isPointerHoveringCanvas = true;
this.update(event);
};
private readonly onPointerLeave = () => {
this.isPointerHoveringCanvas = false;
this.update();
};
} }

View file

@ -23,7 +23,6 @@ interface FrameParameters extends RenderInputs {
canvasPixelRatio: number; canvasPixelRatio: number;
introProgress: number; introProgress: number;
selectedColorIndex: number; selectedColorIndex: number;
isErasing: boolean;
eraserPixelSize: number; eraserPixelSize: number;
} }

View file

@ -7,7 +7,6 @@ import { DeltaTimeCalculator } from '../utils/delta-time-calculator';
import { rgbColorToCss, type RgbColor } from '../utils/rgb-color'; import { rgbColorToCss, type RgbColor } from '../utils/rgb-color';
import { AgentPopulation } from './agent-population'; import { AgentPopulation } from './agent-population';
import { DevStatsOverlay } from './dev-stats-overlay'; import { DevStatsOverlay } from './dev-stats-overlay';
import { EraserPointerPreviewController } from './eraser-pointer-preview-controller';
import { EraserPreview } from './eraser-preview'; import { EraserPreview } from './eraser-preview';
import { ExportSnapshotRenderer } from './export-snapshot-renderer'; import { ExportSnapshotRenderer } from './export-snapshot-renderer';
import { FramePerformance } from './frame-performance'; import { FramePerformance } from './frame-performance';
@ -25,7 +24,6 @@ export default class GameLoop {
private readonly introPrompt: IntroPrompt; private readonly introPrompt: IntroPrompt;
private readonly eraserPreview: EraserPreview; private readonly eraserPreview: EraserPreview;
private readonly pointerInput: GardenPointerInput; private readonly pointerInput: GardenPointerInput;
private readonly eraserPreviewController: EraserPointerPreviewController;
private readonly agentPopulation: AgentPopulation; private readonly agentPopulation: AgentPopulation;
private readonly exportSnapshotRenderer: ExportSnapshotRenderer; private readonly exportSnapshotRenderer: ExportSnapshotRenderer;
private readonly framePerformance = new FramePerformance(); private readonly framePerformance = new FramePerformance();
@ -34,6 +32,7 @@ export default class GameLoop {
private readonly seedValue = Math.floor(Math.random() * 0xffffffff); private readonly seedValue = Math.floor(Math.random() * 0xffffffff);
private readonly seed = this.seedValue.toString(16); private readonly seed = this.seedValue.toString(16);
private readonly resizeListener = this.resize.bind(this); private readonly resizeListener = this.resize.bind(this);
private readonly _canvasSize: vec2 = vec2.create();
private pendingIntroResizeAt: DOMHighResTimeStamp | null = null; private pendingIntroResizeAt: DOMHighResTimeStamp | null = null;
private previousAccentColor = ''; private previousAccentColor = '';
@ -54,7 +53,6 @@ export default class GameLoop {
this.framePerformance.adaptiveCapInitial this.framePerformance.adaptiveCapInitial
); );
this.introPrompt = new IntroPrompt(ui.prompt); this.introPrompt = new IntroPrompt(ui.prompt);
this.eraserPreview = new EraserPreview(canvas, ui.eraserPreview);
this.toolbarContrastMonitor = new ToolbarContrastMonitor(canvas, ui.toolbar, device); this.toolbarContrastMonitor = new ToolbarContrastMonitor(canvas, ui.toolbar, device);
this.agentPopulation = new AgentPopulation( this.agentPopulation = new AgentPopulation(
this.resources.agentGenerationPipeline, this.resources.agentGenerationPipeline,
@ -77,11 +75,11 @@ export default class GameLoop {
onEraseGestureEnded: () => this.agentPopulation.requestCompactionAfterErase(), onEraseGestureEnded: () => this.agentPopulation.requestCompactionAfterErase(),
spawnStrokeAgents: (from, to) => this.agentPopulation.spawnStrokeAgents(from, to), spawnStrokeAgents: (from, to) => this.agentPopulation.spawnStrokeAgents(from, to),
}); });
this.eraserPreviewController = new EraserPointerPreviewController({ this.eraserPreview = new EraserPreview(
canvas, canvas,
eraserPreview: this.eraserPreview, ui.eraserPreview,
getIsSwipeActive: () => this.pointerInput.isSwipeActive, () => this.pointerInput.isSwipeActive
}); );
this.exportSnapshotRenderer = new ExportSnapshotRenderer({ this.exportSnapshotRenderer = new ExportSnapshotRenderer({
device, device,
renderPipeline: this.resources.renderPipeline, renderPipeline: this.resources.renderPipeline,
@ -100,7 +98,7 @@ export default class GameLoop {
}); });
window.addEventListener('resize', this.resizeListener); window.addEventListener('resize', this.resizeListener);
this.eraserPreviewController.attach(); this.eraserPreview.attach();
this.syncDevStatsOverlay(); this.syncDevStatsOverlay();
} }
@ -110,11 +108,11 @@ export default class GameLoop {
public setEraseMode(isErasing: boolean): void { public setEraseMode(isErasing: boolean): void {
this.pointerInput.setEraseMode(isErasing); this.pointerInput.setEraseMode(isErasing);
this.eraserPreviewController.setEraseMode(isErasing); this.eraserPreview.setEraseMode(isErasing);
} }
public updateEraserPreview(event?: PointerEvent): void { public updateEraserPreview(event?: PointerEvent): void {
this.eraserPreviewController.update(event); this.eraserPreview.update(event);
} }
public onVibeChanged(): void { public onVibeChanged(): void {
@ -153,7 +151,7 @@ export default class GameLoop {
window.removeEventListener('resize', this.resizeListener); window.removeEventListener('resize', this.resizeListener);
this.pointerInput.detach(); this.pointerInput.detach();
this.eraserPreviewController.detach(); this.eraserPreview.detach();
this.devStatsOverlay?.destroy(); this.devStatsOverlay?.destroy();
this.devStatsOverlay = null; this.devStatsOverlay = null;
this.toolbarContrastMonitor.destroy(); this.toolbarContrastMonitor.destroy();
@ -198,7 +196,6 @@ export default class GameLoop {
canvasPixelRatio, canvasPixelRatio,
introProgress, introProgress,
selectedColorIndex: settings.selectedColorIndex, selectedColorIndex: settings.selectedColorIndex,
isErasing,
channelColors, channelColors,
backgroundColor, backgroundColor,
eraserPixelSize, eraserPixelSize,
@ -300,7 +297,8 @@ export default class GameLoop {
} }
private get canvasSize(): vec2 { private get canvasSize(): vec2 {
return vec2.fromValues(this.canvas.width, this.canvas.height); vec2.set(this._canvasSize, this.canvas.width, this.canvas.height);
return this._canvasSize;
} }
private get canvasPixelRatio(): number { private get canvasPixelRatio(): number {

View file

@ -47,59 +47,6 @@ const MIRROR_SEGMENT_OFF_LABEL = 'Mirror off';
const MIRROR_SEGMENT_STEP = 1; const MIRROR_SEGMENT_STEP = 1;
const MIRROR_SEGMENT_LABEL_SUFFIX = 'slices'; const MIRROR_SEGMENT_LABEL_SUFFIX = 'slices';
const ELEMENT_TAGS = {
div: 'div',
pre: 'pre',
} as const;
const ARIA_ATTRIBUTES = {
label: 'aria-label',
live: 'aria-live',
pressed: 'aria-pressed',
role: 'role',
valueNow: 'aria-valuenow',
valueText: 'aria-valuetext',
} as const;
const ARIA_LIVE_VALUES = {
assertive: 'assertive',
polite: 'polite',
} as const;
const ARIA_ROLES = {
alert: 'alert',
status: 'status',
} as const;
const CSS_CLASSES = {
active: 'active',
errorsContainer: 'errors-container',
isLoading: 'is-loading',
muted: 'muted',
preDrawing: 'pre-drawing',
} as const;
const CSS_VARIABLES = {
eraserControlScale: '--eraser-control-scale',
eraserProgress: '--eraser-progress',
gardenBackground: '--garden-background',
loadingProgress: '--loading-progress',
mirrorAngle: '--mirror-angle',
mirrorProgress: '--mirror-progress',
volumeProgress: '--volume-progress',
} as const;
const DOM_EVENTS = {
click: 'click',
focus: 'focus',
input: 'input',
keydown: 'keydown',
pointerDown: 'pointerdown',
pointerUp: 'pointerup',
touchEnd: 'touchend',
touchStart: 'touchstart',
} as const;
const APP_SELECTORS = { const APP_SELECTORS = {
aside: 'aside', aside: 'aside',
canvas: 'canvas', canvas: 'canvas',
@ -132,24 +79,6 @@ const APP_SELECTORS = {
volumeSlider: '.volume-slider', volumeSlider: '.volume-slider',
} as const; } as const;
const AUDIO_LABELS = {
mutedPrefix: 'Muted',
mute: 'Mute audio',
unmute: 'Unmute audio',
volumeSuffix: 'volume',
} as const;
const LOADING_MESSAGES = {
fontsError: 'Could not load fonts.',
pianoSamplesError: 'Could not preload piano samples.',
ready: 'Ready',
} as const;
const VIBE_CHANGE_SOURCES = {
nextButton: 'next-button',
previousButton: 'previous-button',
} as const;
const clampEraserSize = (value: number): number => { const clampEraserSize = (value: number): number => {
const safeValue = Number.isFinite(value) ? value : ERASER_SIZE_DEFAULT; const safeValue = Number.isFinite(value) ? value : ERASER_SIZE_DEFAULT;
return Math.min(ERASER_SIZE_MAX, Math.max(ERASER_SIZE_MIN, Math.round(safeValue))); return Math.min(ERASER_SIZE_MAX, Math.max(ERASER_SIZE_MIN, Math.round(safeValue)));
@ -197,18 +126,18 @@ type RuntimeUiError = Parameters<
>[0]; >[0];
const renderRuntimeMessage = (container: HTMLElement, error: RuntimeUiError) => { const renderRuntimeMessage = (container: HTMLElement, error: RuntimeUiError) => {
const message = document.createElement(ELEMENT_TAGS.pre); const message = document.createElement('pre');
message.className = error.severity; message.className = error.severity;
message.textContent = error.code ? `${error.message}\n${error.code}` : error.message; message.textContent = error.code ? `${error.message}\n${error.code}` : error.message;
message.setAttribute( message.setAttribute(
ARIA_ATTRIBUTES.role, 'role',
error.severity === Severity.ERROR ? ARIA_ROLES.alert : ARIA_ROLES.status error.severity === Severity.ERROR ? 'alert' : 'status'
); );
message.setAttribute( message.setAttribute(
ARIA_ATTRIBUTES.live, 'aria-live',
error.severity === Severity.ERROR error.severity === Severity.ERROR
? ARIA_LIVE_VALUES.assertive ? 'assertive'
: ARIA_LIVE_VALUES.polite : 'polite'
); );
container.append(message); container.append(message);
@ -229,14 +158,14 @@ const renderStartupException = (exception: unknown) => {
const container = const container =
existingContainer instanceof HTMLElement existingContainer instanceof HTMLElement
? existingContainer ? existingContainer
: document.createElement(ELEMENT_TAGS.div); : document.createElement('div');
if (!(existingContainer instanceof HTMLElement)) { if (!(existingContainer instanceof HTMLElement)) {
container.className = CSS_CLASSES.errorsContainer; container.className = 'errors-container';
document.body.append(container); document.body.append(container);
} }
container.setAttribute(ARIA_ATTRIBUTES.live, ARIA_LIVE_VALUES.assertive); container.setAttribute('aria-live', 'assertive');
renderRuntimeMessage(container, getRuntimeUiError(exception)); renderRuntimeMessage(container, getRuntimeUiError(exception));
}; };
@ -298,10 +227,10 @@ const setLoadingStage = (label: string, ratio: number) => {
const percent = Math.round(clamp01(ratio) * 100); const percent = Math.round(clamp01(ratio) * 100);
elements.loadingStatus.textContent = label; elements.loadingStatus.textContent = label;
elements.loadingProgress.style.setProperty( elements.loadingProgress.style.setProperty(
CSS_VARIABLES.loadingProgress, '--loading-progress',
`${percent}%` `${percent}%`
); );
elements.loadingProgress.setAttribute(ARIA_ATTRIBUTES.valueNow, String(percent)); elements.loadingProgress.setAttribute('aria-valuenow', String(percent));
}; };
let audioVolume = readInitialAudioVolume(); let audioVolume = readInitialAudioVolume();
@ -323,32 +252,32 @@ const renderAudioUi = (game: GameLoop | null) => {
const isEffectivelyMuted = isAudioMuted || audioVolume <= 0; const isEffectivelyMuted = isAudioMuted || audioVolume <= 0;
const volumePercent = getAudioVolumePercent(audioVolume); const volumePercent = getAudioVolumePercent(audioVolume);
elements.soundButton.classList.toggle(CSS_CLASSES.muted, isEffectivelyMuted); elements.soundButton.classList.toggle('muted', isEffectivelyMuted);
elements.soundButton.setAttribute(ARIA_ATTRIBUTES.pressed, String(isEffectivelyMuted)); elements.soundButton.setAttribute('aria-pressed', String(isEffectivelyMuted));
elements.soundButton.setAttribute( elements.soundButton.setAttribute(
ARIA_ATTRIBUTES.label, 'aria-label',
isEffectivelyMuted ? AUDIO_LABELS.unmute : AUDIO_LABELS.mute isEffectivelyMuted ? 'Unmute audio' : 'Mute audio'
); );
elements.soundButton.title = isEffectivelyMuted elements.soundButton.title = isEffectivelyMuted
? AUDIO_LABELS.unmute ? 'Unmute audio'
: AUDIO_LABELS.mute; : 'Mute audio';
elements.volumeSlider.min = UNIT_INTERVAL_INPUT_MIN; elements.volumeSlider.min = UNIT_INTERVAL_INPUT_MIN;
elements.volumeSlider.max = UNIT_INTERVAL_INPUT_MAX; elements.volumeSlider.max = UNIT_INTERVAL_INPUT_MAX;
elements.volumeSlider.step = AUDIO_VOLUME_STEP.toString(); elements.volumeSlider.step = AUDIO_VOLUME_STEP.toString();
elements.volumeSlider.value = formatStoredAudioVolume(audioVolume); elements.volumeSlider.value = formatStoredAudioVolume(audioVolume);
elements.volumeSlider.setAttribute( elements.volumeSlider.setAttribute(
ARIA_ATTRIBUTES.valueText, 'aria-valuetext',
isEffectivelyMuted isEffectivelyMuted
? `${AUDIO_LABELS.mutedPrefix}, ${volumePercent}%` ? `${'Muted'}, ${volumePercent}%`
: `${volumePercent}%` : `${volumePercent}%`
); );
elements.volumeControl.classList.toggle(CSS_CLASSES.muted, isEffectivelyMuted); elements.volumeControl.classList.toggle('muted', isEffectivelyMuted);
elements.volumeControl.title = isEffectivelyMuted elements.volumeControl.title = isEffectivelyMuted
? `${AUDIO_LABELS.mutedPrefix}, ${volumePercent}% ${AUDIO_LABELS.volumeSuffix}` ? `${'Muted'}, ${volumePercent}% ${'volume'}`
: `${volumePercent}% ${AUDIO_LABELS.volumeSuffix}`; : `${volumePercent}% ${'volume'}`;
elements.volumeControl.style.setProperty( elements.volumeControl.style.setProperty(
CSS_VARIABLES.volumeProgress, '--volume-progress',
`${volumePercent}%` `${volumePercent}%`
); );
@ -360,14 +289,14 @@ const renderPaletteUi = (game: GameLoop | null) => {
elements.swatches.forEach((swatch, index) => { elements.swatches.forEach((swatch, index) => {
swatch.style.backgroundColor = rgbColorToCss(activeVibe.colors[index]); swatch.style.backgroundColor = rgbColorToCss(activeVibe.colors[index]);
swatch.classList.toggle( swatch.classList.toggle(
CSS_CLASSES.active, 'active',
settings.selectedColorIndex === index && !isEraserActive settings.selectedColorIndex === index && !isEraserActive
); );
}); });
elements.eraserSizeControl.classList.toggle(CSS_CLASSES.active, isEraserActive); elements.eraserSizeControl.classList.toggle('active', isEraserActive);
game?.setEraseMode(isEraserActive); game?.setEraseMode(isEraserActive);
document.documentElement.style.setProperty( document.documentElement.style.setProperty(
CSS_VARIABLES.gardenBackground, '--garden-background',
rgbColorToCss(activeVibe.backgroundColor) rgbColorToCss(activeVibe.backgroundColor)
); );
}; };
@ -382,18 +311,18 @@ const renderEraserSizeUi = (game: GameLoop | null) => {
elements.eraserSizeSlider.max = ERASER_SIZE_MAX.toString(); elements.eraserSizeSlider.max = ERASER_SIZE_MAX.toString();
elements.eraserSizeSlider.step = ERASER_SIZE_STEP.toString(); elements.eraserSizeSlider.step = ERASER_SIZE_STEP.toString();
elements.eraserSizeSlider.value = size.toString(); elements.eraserSizeSlider.value = size.toString();
elements.eraserSizeSlider.setAttribute(ARIA_ATTRIBUTES.valueText, `${size}px`); elements.eraserSizeSlider.setAttribute('aria-valuetext', `${size}px`);
const ratio = getEraserSizeRatio(size); const ratio = getEraserSizeRatio(size);
const scale = const scale =
ERASER_CONTROL_SCALE_MIN + ERASER_CONTROL_SCALE_MIN +
(ERASER_CONTROL_SCALE_MAX - ERASER_CONTROL_SCALE_MIN) * ratio; (ERASER_CONTROL_SCALE_MAX - ERASER_CONTROL_SCALE_MIN) * ratio;
elements.eraserSizeControl.style.setProperty( elements.eraserSizeControl.style.setProperty(
CSS_VARIABLES.eraserProgress, '--eraser-progress',
`${ratio * 100}%` `${ratio * 100}%`
); );
elements.eraserSizeControl.style.setProperty( elements.eraserSizeControl.style.setProperty(
CSS_VARIABLES.eraserControlScale, '--eraser-control-scale',
scale.toFixed(3) scale.toFixed(3)
); );
game?.updateEraserPreview(); game?.updateEraserPreview();
@ -412,15 +341,15 @@ const renderMirrorSegmentUi = () => {
const label = formatMirrorSegmentCount(count); const label = formatMirrorSegmentCount(count);
const ratio = getMirrorSegmentRatio(count); const ratio = getMirrorSegmentRatio(count);
elements.mirrorSegmentSlider.setAttribute(ARIA_ATTRIBUTES.valueText, label); elements.mirrorSegmentSlider.setAttribute('aria-valuetext', label);
elements.mirrorSegmentControl.title = label; elements.mirrorSegmentControl.title = label;
elements.mirrorSegmentControl.classList.toggle(CSS_CLASSES.active, count > 1); elements.mirrorSegmentControl.classList.toggle('active', count > 1);
elements.mirrorSegmentControl.style.setProperty( elements.mirrorSegmentControl.style.setProperty(
CSS_VARIABLES.mirrorProgress, '--mirror-progress',
`${ratio * 100}%` `${ratio * 100}%`
); );
elements.mirrorSegmentControl.style.setProperty( elements.mirrorSegmentControl.style.setProperty(
CSS_VARIABLES.mirrorAngle, '--mirror-angle',
`${(360 / count).toFixed(3)}deg` `${(360 / count).toFixed(3)}deg`
); );
}; };
@ -437,13 +366,13 @@ const main = async () => {
elements = queryAppElements(); elements = queryAppElements();
elements.errorContainer.setAttribute( elements.errorContainer.setAttribute(
ARIA_ATTRIBUTES.live, 'aria-live',
ARIA_LIVE_VALUES.assertive 'assertive'
); );
ErrorHandler.addOnErrorListener((error) => { ErrorHandler.addOnErrorListener((error) => {
renderRuntimeMessage(elements.errorContainer, error); renderRuntimeMessage(elements.errorContainer, error);
if (error.severity === Severity.ERROR) { if (error.severity === Severity.ERROR) {
document.body.classList.remove(CSS_CLASSES.isLoading); document.body.classList.remove('is-loading');
game?.destroy(); game?.destroy();
shouldStop = true; shouldStop = true;
} }
@ -488,31 +417,31 @@ const main = async () => {
game?.startAudio(true); game?.startAudio(true);
}; };
window.addEventListener(DOM_EVENTS.touchStart, startAudioFromUserGesture, { window.addEventListener('touchstart', startAudioFromUserGesture, {
capture: true, capture: true,
passive: true, passive: true,
}); });
window.addEventListener(DOM_EVENTS.pointerDown, startAudioFromUserGesture, { window.addEventListener('pointerdown', startAudioFromUserGesture, {
capture: true, capture: true,
passive: true, passive: true,
}); });
window.addEventListener(DOM_EVENTS.touchEnd, startAudioFromUserGesture, { window.addEventListener('touchend', startAudioFromUserGesture, {
capture: true, capture: true,
passive: true, passive: true,
}); });
window.addEventListener(DOM_EVENTS.pointerUp, startAudioFromUserGesture, { window.addEventListener('pointerup', startAudioFromUserGesture, {
capture: true, capture: true,
passive: true, passive: true,
}); });
window.addEventListener(DOM_EVENTS.click, startAudioFromUserGesture, { window.addEventListener('click', startAudioFromUserGesture, {
capture: true, capture: true,
}); });
window.addEventListener(DOM_EVENTS.keydown, startAudioFromUserGesture, { window.addEventListener('keydown', startAudioFromUserGesture, {
capture: true, capture: true,
}); });
elements.restartButton.addEventListener(DOM_EVENTS.click, () => game?.destroy()); elements.restartButton.addEventListener('click', () => game?.destroy());
elements.soundButton.addEventListener(DOM_EVENTS.click, () => { elements.soundButton.addEventListener('click', () => {
const shouldUnmute = isAudioMuted || audioVolume <= 0; const shouldUnmute = isAudioMuted || audioVolume <= 0;
if (shouldUnmute && audioVolume <= 0) { if (shouldUnmute && audioVolume <= 0) {
audioVolume = DEFAULT_AUDIO_VOLUME; audioVolume = DEFAULT_AUDIO_VOLUME;
@ -524,7 +453,7 @@ const main = async () => {
game?.startAudio(true); game?.startAudio(true);
} }
}); });
elements.volumeSlider.addEventListener(DOM_EVENTS.input, () => { elements.volumeSlider.addEventListener('input', () => {
audioVolume = clampAudioVolume(Number(elements.volumeSlider.value)); audioVolume = clampAudioVolume(Number(elements.volumeSlider.value));
isAudioMuted = audioVolume <= 0; isAudioMuted = audioVolume <= 0;
persistAudioUiState(); persistAudioUiState();
@ -550,16 +479,16 @@ const main = async () => {
game?.playVibeChangeAudio(true); game?.playVibeChangeAudio(true);
}; };
elements.previousVibe.addEventListener(DOM_EVENTS.click, () => elements.previousVibe.addEventListener('click', () =>
selectRelativeVibe(-1, VIBE_CHANGE_SOURCES.previousButton) selectRelativeVibe(-1, 'previous-button')
); );
elements.nextVibe.addEventListener(DOM_EVENTS.click, () => elements.nextVibe.addEventListener('click', () =>
selectRelativeVibe(1, VIBE_CHANGE_SOURCES.nextButton) selectRelativeVibe(1, 'next-button')
); );
elements.swatches.forEach((swatch, index) => { elements.swatches.forEach((swatch, index) => {
swatch.addEventListener(DOM_EVENTS.click, () => { swatch.addEventListener('click', () => {
settings.selectedColorIndex = index; settings.selectedColorIndex = index;
isEraserActive = false; isEraserActive = false;
renderPaletteUi(game); renderPaletteUi(game);
@ -572,11 +501,11 @@ const main = async () => {
renderPaletteUi(game); renderPaletteUi(game);
}; };
elements.eraserSizeControl.addEventListener(DOM_EVENTS.pointerDown, activateEraser); elements.eraserSizeControl.addEventListener('pointerdown', activateEraser);
elements.eraserSizeControl.addEventListener(DOM_EVENTS.click, activateEraser); elements.eraserSizeControl.addEventListener('click', activateEraser);
elements.eraserSizeSlider.addEventListener(DOM_EVENTS.focus, activateEraser); elements.eraserSizeSlider.addEventListener('focus', activateEraser);
elements.eraserSizeSlider.addEventListener(DOM_EVENTS.input, () => { elements.eraserSizeSlider.addEventListener('input', () => {
settings.eraserSize = clampEraserSize(Number(elements.eraserSizeSlider.value)); settings.eraserSize = clampEraserSize(Number(elements.eraserSizeSlider.value));
isEraserActive = true; isEraserActive = true;
renderEraserSizeUi(game); renderEraserSizeUi(game);
@ -584,7 +513,7 @@ const main = async () => {
configPane?.refresh(); configPane?.refresh();
}); });
elements.mirrorSegmentSlider.addEventListener(DOM_EVENTS.input, () => { elements.mirrorSegmentSlider.addEventListener('input', () => {
settings.mirrorSegmentCount = clampMirrorSegmentCount( settings.mirrorSegmentCount = clampMirrorSegmentCount(
Number(elements.mirrorSegmentSlider.value) Number(elements.mirrorSegmentSlider.value)
); );
@ -594,7 +523,7 @@ const main = async () => {
configPane?.refresh(); configPane?.refresh();
}); });
elements.export4k.addEventListener(DOM_EVENTS.click, async () => { elements.export4k.addEventListener('click', async () => {
if (!game || elements.export4k.disabled) { if (!game || elements.export4k.disabled) {
return; return;
} }
@ -620,7 +549,7 @@ const main = async () => {
// the AudioContext on iOS, and gates the intro. // the AudioContext on iOS, and gates the intro.
const fontsReady = document.fonts.ready.catch((error) => { const fontsReady = document.fonts.ready.catch((error) => {
ErrorHandler.addException(error, { ErrorHandler.addException(error, {
fallbackMessage: LOADING_MESSAGES.fontsError, fallbackMessage: 'Could not load fonts.',
severity: Severity.WARNING, severity: Severity.WARNING,
}); });
}); });
@ -633,12 +562,12 @@ const main = async () => {
}).then( }).then(
() => { () => {
isPreloadComplete = true; isPreloadComplete = true;
setLoadingStage(LOADING_MESSAGES.ready, 1); setLoadingStage('Ready', 1);
}, },
(error: unknown) => { (error: unknown) => {
isPreloadComplete = true; isPreloadComplete = true;
ErrorHandler.addException(error, { ErrorHandler.addException(error, {
fallbackMessage: LOADING_MESSAGES.pianoSamplesError, fallbackMessage: 'Could not preload piano samples.',
severity: Severity.WARNING, severity: Severity.WARNING,
}); });
} }
@ -651,7 +580,6 @@ const main = async () => {
game?.onVibeChanged(); game?.onVibeChanged();
syncRuntimeUi(); syncRuntimeUi();
}, },
onOpenChange: () => undefined,
onRuntimeChange: syncRuntimeUi, onRuntimeChange: syncRuntimeUi,
}); });
infoPageHandler.onOpen = configPane.close.bind(configPane); infoPageHandler.onOpen = configPane.close.bind(configPane);
@ -680,14 +608,14 @@ const main = async () => {
elements.startButton.disabled = false; elements.startButton.disabled = false;
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
const onClick = () => { const onClick = () => {
elements.startButton.removeEventListener(DOM_EVENTS.click, onClick); elements.startButton.removeEventListener('click', onClick);
hasStarted = true; hasStarted = true;
game?.startAudio(true); game?.startAudio(true);
trackStart(); trackStart();
elements.splash.hidden = true; elements.splash.hidden = true;
resolve(); resolve();
}; };
elements.startButton.addEventListener(DOM_EVENTS.click, onClick); elements.startButton.addEventListener('click', onClick);
}); });
if (!isPreloadComplete) { if (!isPreloadComplete) {
@ -698,16 +626,16 @@ const main = async () => {
} }
// Keep the dev stats overlay hidden until the user actually starts drawing. // Keep the dev stats overlay hidden until the user actually starts drawing.
document.body.classList.add(CSS_CLASSES.preDrawing); document.body.classList.add('pre-drawing');
elements.canvas.addEventListener( elements.canvas.addEventListener(
DOM_EVENTS.pointerDown, 'pointerdown',
() => document.body.classList.remove(CSS_CLASSES.preDrawing), () => document.body.classList.remove('pre-drawing'),
{ once: true } { once: true }
); );
requestAnimationFrame(() => requestAnimationFrame(() =>
requestAnimationFrame(() => requestAnimationFrame(() =>
document.body.classList.remove(CSS_CLASSES.isLoading) document.body.classList.remove('is-loading')
) )
); );
} }
@ -715,7 +643,7 @@ const main = async () => {
await game.start(); await game.start();
} }
} catch (e) { } catch (e) {
document.body.classList.remove(CSS_CLASSES.isLoading); document.body.classList.remove('is-loading');
if (hasRuntimeErrorListener) { if (hasRuntimeErrorListener) {
ErrorHandler.addException(e); ErrorHandler.addException(e);
} else { } else {

View file

@ -29,6 +29,11 @@ interface PaneState extends GardenAudioVibeSettings {
} }
const COLOR_REACTION_LABELS = ['1', '2', '3'] as const; const COLOR_REACTION_LABELS = ['1', '2', '3'] as const;
const COLOR_REACTION_STATES = [
{ id: 'follow', label: 'Follow', value: 1 },
{ id: 'ignore', label: 'Ignore', value: 0 },
{ id: 'avoid', label: 'Avoid', value: -1 },
] as const;
const colorReactionRows = [ const colorReactionRows = [
{ {
@ -51,14 +56,14 @@ const colorReactionRows = [
const brushControlKeys = [ const brushControlKeys = [
'brushSize', 'brushSize',
'spawnPerPixel', 'spawnPerPixel',
'brushSizeVariation',
'diffusionRateBrush',
] satisfies Array<RuntimeControlKey>; ] satisfies Array<RuntimeControlKey>;
const agentControlKeys = [ const agentControlKeys = [
'sensorOffsetDistance', 'sensorOffsetDistance',
'moveSpeed', 'moveSpeed',
'turnSpeed', 'turnSpeed',
'forwardRotationScale',
'turnWhenLost',
'individualTrailWeight', 'individualTrailWeight',
'decayRateTrails', 'decayRateTrails',
] satisfies Array<RuntimeControlKey>; ] satisfies Array<RuntimeControlKey>;
@ -80,7 +85,7 @@ const MUSIC_CONTROLS: ReadonlyArray<{
max: number; max: number;
step: number; step: number;
}> = [ }> = [
{ key: 'idleIntensity', label: 'idle intensity', min: 0, max: 0.8, step: 0.01 }, { key: 'idleIntensity', label: 'idle intensity', min: 0, max: 1, step: 0.01 },
{ key: 'bpm', label: 'bpm', min: 48, max: 150, step: 1 }, { key: 'bpm', label: 'bpm', min: 48, max: 150, step: 1 },
{ key: 'rampUpIntensity', label: 'ramp up intensity', min: 0, max: 2, step: 0.01 }, { key: 'rampUpIntensity', label: 'ramp up intensity', min: 0, max: 2, step: 0.01 },
{ key: 'rampUpTime', label: 'ramp up time', min: 0.01, max: 0.4, step: 0.01 }, { key: 'rampUpTime', label: 'ramp up time', min: 0.01, max: 0.4, step: 0.01 },
@ -91,7 +96,6 @@ const MUSIC_CONTROLS: ReadonlyArray<{
interface ConfigPaneOptions { interface ConfigPaneOptions {
onConfigChange: () => void; onConfigChange: () => void;
onOpenChange?: (isOpen: boolean) => void;
onRuntimeChange: () => void; onRuntimeChange: () => void;
settingsButton: HTMLButtonElement; settingsButton: HTMLButtonElement;
} }
@ -119,6 +123,9 @@ const getNumberBindingParams = (config: NumberControlConfig): BindingParams => {
options: config.options, options: config.options,
step: config.step, step: config.step,
}; };
if (config.format !== undefined) {
params.format = config.format;
}
if (config.min !== undefined) { if (config.min !== undefined) {
params.min = config.min; params.min = config.min;
} }
@ -128,10 +135,32 @@ const getNumberBindingParams = (config: NumberControlConfig): BindingParams => {
return params; return params;
}; };
const getColorReactionStateIndex = (value: number): number =>
COLOR_REACTION_STATES.findIndex((state) => state.value === value);
const getColorReactionState = (value: number): (typeof COLOR_REACTION_STATES)[number] =>
COLOR_REACTION_STATES[getColorReactionStateIndex(value)] ?? COLOR_REACTION_STATES[1];
const getNextColorReactionState = (
value: number
): (typeof COLOR_REACTION_STATES)[number] => {
const index = getColorReactionStateIndex(value);
return COLOR_REACTION_STATES[
((index < 0 ? 1 : index) + 1) % COLOR_REACTION_STATES.length
];
};
export class ConfigPane { export class ConfigPane {
private readonly container: HTMLDivElement; private readonly container: HTMLDivElement;
private readonly pane: Pane; private readonly pane: Pane;
private readonly colorReactionSelects = new Map<ColorReactionKey, HTMLSelectElement>(); private readonly colorReactionButtons = new Map<
ColorReactionKey,
{
element: HTMLButtonElement;
sourceColorIndex: number;
targetColorIndex: number;
}
>();
private readonly colorReactionSwatches: Array<{ private readonly colorReactionSwatches: Array<{
colorIndex: number; colorIndex: number;
element: HTMLElement; element: HTMLElement;
@ -379,56 +408,79 @@ export class ConfigPane {
key: ColorReactionKey, key: ColorReactionKey,
sourceColorIndex: number, sourceColorIndex: number,
targetColorIndex: number targetColorIndex: number
): HTMLLabelElement { ): HTMLDivElement {
const cell = doc.createElement('label'); const cell = doc.createElement('div');
cell.className = 'color-reaction-matrix__cell'; cell.className = 'color-reaction-matrix__cell';
const select = doc.createElement('select');
select.setAttribute(
'aria-label',
`Color ${sourceColorIndex + 1} agents reacting to color ${targetColorIndex + 1}`
);
const config = appConfig.runtimeSettings.controls[key]; const config = appConfig.runtimeSettings.controls[key];
if (!config) { if (!config) {
return cell; return cell;
} }
Object.entries(config.options ?? {}).forEach(([label, value]) => { const button = doc.createElement('button');
const option = doc.createElement('option'); button.className = 'color-reaction-matrix__button';
option.value = String(value); button.type = 'button';
option.textContent = label;
select.appendChild(option);
});
select.addEventListener('change', () => { const icon = doc.createElement('span');
settings[key] = normalizeNumber(Number(select.value), config); icon.className = 'color-reaction-matrix__icon';
select.value = String(settings[key]); button.appendChild(icon);
button.addEventListener('click', () => {
const currentValue = normalizeNumber(settings[key], config);
const nextState = getNextColorReactionState(currentValue);
settings[key] = nextState.value;
this.syncColorReactionButton(button, key, sourceColorIndex, targetColorIndex);
this.options.onRuntimeChange(); this.options.onRuntimeChange();
}); });
this.colorReactionSelects.set(key, select); this.colorReactionButtons.set(key, {
cell.appendChild(select); element: button,
sourceColorIndex,
targetColorIndex,
});
cell.appendChild(button);
return cell; return cell;
} }
private syncColorReactionMatrix(): void { private syncColorReactionMatrix(): void {
this.colorReactionSelects.forEach((select, key) => { this.colorReactionButtons.forEach(
const config = appConfig.runtimeSettings.controls[key]; ({ element, sourceColorIndex, targetColorIndex }, key) => {
if (!config) { this.syncColorReactionButton(element, key, sourceColorIndex, targetColorIndex);
return;
} }
);
settings[key] = normalizeNumber(settings[key], config);
select.value = String(settings[key]);
});
this.colorReactionSwatches.forEach(({ colorIndex, element }) => { this.colorReactionSwatches.forEach(({ colorIndex, element }) => {
element.style.backgroundColor = rgbColorToCss(activeVibe.colors[colorIndex]); element.style.backgroundColor = rgbColorToCss(activeVibe.colors[colorIndex]);
}); });
} }
private syncColorReactionButton(
button: HTMLButtonElement,
key: ColorReactionKey,
sourceColorIndex: number,
targetColorIndex: number
): void {
const config = appConfig.runtimeSettings.controls[key];
if (!config) {
return;
}
settings[key] = normalizeNumber(settings[key], config);
const state = getColorReactionState(settings[key]);
const nextState = getNextColorReactionState(settings[key]);
const sourceLabel = sourceColorIndex + 1;
const targetLabel = targetColorIndex + 1;
button.dataset.reaction = state.id;
button.setAttribute(
'aria-label',
`Color ${sourceLabel} agents ${state.label.toLowerCase()} color ${targetLabel}; click to switch to ${nextState.label.toLowerCase()}`
);
button.title = state.label;
}
private setUpMusicSection(container: PaneContainer): void { private setUpMusicSection(container: PaneContainer): void {
const folder = container.addFolder({ title: 'Music', expanded: true }); const folder = container.addFolder({ title: 'Music', expanded: true });
MUSIC_CONTROLS.forEach(({ key, label, min, max, step }) => { MUSIC_CONTROLS.forEach(({ key, label, min, max, step }) => {
@ -489,6 +541,5 @@ export class ConfigPane {
settingsButton.setAttribute('aria-expanded', String(this.isOpen)); settingsButton.setAttribute('aria-expanded', String(this.isOpen));
settingsButton.setAttribute('aria-label', label); settingsButton.setAttribute('aria-label', label);
settingsButton.title = label; settingsButton.title = label;
this.options.onOpenChange?.(this.isOpen);
} }
} }

View file

@ -1,25 +1,9 @@
const AGENT_WORKGROUP_SIZE = 64; const AGENT_WORKGROUP_SIZE = 64;
const AGENT_MAX_DISPATCH_WORKGROUPS_PER_DIMENSION = 65_535; export const AGENT_MAX_DISPATCHABLE_COUNT = 65_535 * AGENT_WORKGROUP_SIZE;
export const AGENT_MAX_DISPATCHABLE_COUNT =
AGENT_MAX_DISPATCH_WORKGROUPS_PER_DIMENSION * AGENT_WORKGROUP_SIZE;
const getAgentDispatchWorkgroups = (agentCount: number): [number, number] => {
if (!Number.isFinite(agentCount) || agentCount <= 0) {
throw new Error('Agent count must be a positive finite number');
}
const workgroupCount = Math.ceil(agentCount / AGENT_WORKGROUP_SIZE);
if (workgroupCount > AGENT_MAX_DISPATCH_WORKGROUPS_PER_DIMENSION) {
throw new Error('Agent count exceeds dispatchable workgroup range');
}
return [workgroupCount, 1];
};
export const dispatchAgentWorkgroups = ( export const dispatchAgentWorkgroups = (
passEncoder: GPUComputePassEncoder, passEncoder: GPUComputePassEncoder,
agentCount: number agentCount: number
): void => { ): void => {
const [workgroupX, workgroupY] = getAgentDispatchWorkgroups(agentCount); passEncoder.dispatchWorkgroups(Math.ceil(agentCount / AGENT_WORKGROUP_SIZE), 1);
passEncoder.dispatchWorkgroups(workgroupX, workgroupY);
}; };

View file

@ -1,5 +1,6 @@
import { vec2 } from 'gl-matrix'; import { vec2 } from 'gl-matrix';
import { createBindGroupCache } from '../../../utils/graphics/bind-group-cache';
import { smartCompile } from '../../../utils/graphics/smart-compile'; import { smartCompile } from '../../../utils/graphics/smart-compile';
import { AGENT_MAX_DISPATCHABLE_COUNT, dispatchAgentWorkgroups } from '../agent-dispatch'; import { AGENT_MAX_DISPATCHABLE_COUNT, dispatchAgentWorkgroups } from '../agent-dispatch';
import compactionShader from './agent-compaction.wgsl?raw'; import compactionShader from './agent-compaction.wgsl?raw';
@ -17,10 +18,18 @@ export class AgentGenerationPipeline {
private readonly bindGroupLayout: GPUBindGroupLayout; private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly uniforms: GPUBuffer; private readonly uniforms: GPUBuffer;
private readonly bindGroupsByActiveBuffer = new WeakMap< private readonly bindGroupCache = createBindGroupCache<GPUBuffer, GPUBuffer>(
GPUBuffer, (active, inactive) =>
WeakMap<GPUBuffer, GPUBindGroup> this.device.createBindGroup({
>(); layout: this.bindGroupLayout,
entries: [
{ binding: 0, resource: { buffer: this.uniforms } },
{ binding: 1, resource: { buffer: active } },
{ binding: 2, resource: { buffer: this.countersBuffer } },
{ binding: 3, resource: { buffer: inactive } },
],
})
);
private readonly resizePipeline: GPUComputePipeline; private readonly resizePipeline: GPUComputePipeline;
private readonly compactionPipeline: GPUComputePipeline; private readonly compactionPipeline: GPUComputePipeline;
@ -244,7 +253,6 @@ export class AgentGenerationPipeline {
this.resizeUniformFloatValues[0] = scale[0]; this.resizeUniformFloatValues[0] = scale[0];
this.resizeUniformFloatValues[1] = scale[1]; this.resizeUniformFloatValues[1] = scale[1];
this.resizeUniformUintValues[2] = Math.max(0, Math.floor(agentCount)); this.resizeUniformUintValues[2] = Math.max(0, Math.floor(agentCount));
this.resizeUniformUintValues[3] = 0;
this.device.queue.writeBuffer(this.uniforms, 0, this.resizeUniformBuffer); this.device.queue.writeBuffer(this.uniforms, 0, this.resizeUniformBuffer);
const commandEncoder = this.device.createCommandEncoder(); const commandEncoder = this.device.createCommandEncoder();
@ -314,49 +322,7 @@ export class AgentGenerationPipeline {
} }
private getBindGroup(): GPUBindGroup { private getBindGroup(): GPUBindGroup {
let inactiveCache = this.bindGroupsByActiveBuffer.get(this.activeAgentsBuffer); return this.bindGroupCache(this.activeAgentsBuffer, this.inactiveAgentsBuffer);
if (!inactiveCache) {
inactiveCache = new WeakMap<GPUBuffer, GPUBindGroup>();
this.bindGroupsByActiveBuffer.set(this.activeAgentsBuffer, inactiveCache);
}
const cached = inactiveCache.get(this.inactiveAgentsBuffer);
if (cached) {
return cached;
}
const bindGroup = this.device.createBindGroup({
layout: this.bindGroupLayout,
entries: [
{
binding: 0,
resource: {
buffer: this.uniforms,
},
},
{
binding: 1,
resource: {
buffer: this.activeAgentsBuffer,
},
},
{
binding: 2,
resource: {
buffer: this.countersBuffer,
},
},
{
binding: 3,
resource: {
buffer: this.inactiveAgentsBuffer,
},
},
],
});
inactiveCache.set(this.inactiveAgentsBuffer, bindGroup);
return bindGroup;
} }
private swapAgentBuffers(): void { private swapAgentBuffers(): void {

View file

@ -1,7 +1,6 @@
struct ResizeSettings { struct ResizeSettings {
scale: vec2<f32>, scale: vec2<f32>,
agentCount: u32, agentCount: u32,
padding: u32,
}; };
@group(1) @binding(0) var<uniform> resizeSettings: ResizeSettings; @group(1) @binding(0) var<uniform> resizeSettings: ResizeSettings;

View file

@ -6,9 +6,38 @@ import { smartCompile } from '../../utils/graphics/smart-compile';
import { CommonState } from '../common-state/common-state'; import { CommonState } from '../common-state/common-state';
import { dispatchAgentWorkgroups } from './agent-dispatch'; import { dispatchAgentWorkgroups } from './agent-dispatch';
import agentSchema from './agent-generation/agent-schema.wgsl?raw'; import agentSchema from './agent-generation/agent-schema.wgsl?raw';
import { AgentSettings } from './agent-settings';
import shader from './agent.wgsl?raw'; import shader from './agent.wgsl?raw';
export interface AgentSettings {
color1ToColor1: number;
color1ToColor2: number;
color1ToColor3: number;
color2ToColor1: number;
color2ToColor2: number;
color2ToColor3: number;
color3ToColor1: number;
color3ToColor2: number;
color3ToColor3: number;
moveSpeed: number;
turnSpeed: number;
sensorOffsetAngle: number;
sensorOffsetDistance: number;
turnWhenLost: number;
individualTrailWeight: number;
forwardRotationScale: number;
introNearDistanceMin: number;
introNearSensorOffsetMultiplier: number;
introTargetAngleBlend: number;
introProgressCutoff: number;
introNearDistanceInner: number;
introTurnRateMultiplier: number;
introRandomTurnMultiplier: number;
introFarMoveMultiplier: number;
introNearMoveMultiplier: number;
introStepStopDistance: number;
randomTimeScale: number;
}
export class AgentPipeline { export class AgentPipeline {
private static readonly UNIFORM_COUNT = 30; private static readonly UNIFORM_COUNT = 30;

View file

@ -1,29 +0,0 @@
export interface AgentSettings {
color1ToColor1: number;
color1ToColor2: number;
color1ToColor3: number;
color2ToColor1: number;
color2ToColor2: number;
color2ToColor3: number;
color3ToColor1: number;
color3ToColor2: number;
color3ToColor3: number;
moveSpeed: number;
turnSpeed: number;
sensorOffsetAngle: number;
sensorOffsetDistance: number;
turnWhenLost: number;
individualTrailWeight: number;
forwardRotationScale: number;
introNearDistanceMin: number;
introNearSensorOffsetMultiplier: number;
introTargetAngleBlend: number;
introProgressCutoff: number;
introNearDistanceInner: number;
introTurnRateMultiplier: number;
introRandomTurnMultiplier: number;
introFarMoveMultiplier: number;
introNearMoveMultiplier: number;
introStepStopDistance: number;
randomTimeScale: number;
}

View file

@ -7,9 +7,19 @@ import {
} from '../../utils/graphics/cached-buffer-write'; } from '../../utils/graphics/cached-buffer-write';
import { smartCompile } from '../../utils/graphics/smart-compile'; import { smartCompile } from '../../utils/graphics/smart-compile';
import { CommonState } from '../common-state/common-state'; import { CommonState } from '../common-state/common-state';
import { BrushSettings } from './brush-settings';
import shader from './brush.wgsl?raw'; import shader from './brush.wgsl?raw';
export interface BrushSettings {
brushSize: number;
brushAlpha: number;
brushDiscardThreshold: number;
brushGrainNoiseScale: number;
brushGrainNoiseOffsetX: number;
brushGrainNoiseOffsetY: number;
brushGrainMinStrength: number;
brushGrainMaxStrength: number;
}
interface LineSegment { interface LineSegment {
from: vec2; from: vec2;
to: vec2; to: vec2;
@ -29,10 +39,8 @@ const setBrushUniformValues = (
target: Float32Array, target: Float32Array,
{ {
brushSize, brushSize,
brushSizeVariation,
brushAlpha, brushAlpha,
brushDiscardThreshold, brushDiscardThreshold,
brushCoarseNoiseScale,
brushGrainNoiseScale, brushGrainNoiseScale,
brushGrainNoiseOffsetX, brushGrainNoiseOffsetX,
brushGrainNoiseOffsetY, brushGrainNoiseOffsetY,
@ -44,25 +52,21 @@ const setBrushUniformValues = (
): void => { ): void => {
const safePixelRatio = getSafePixelRatio(pixelRatio); const safePixelRatio = getSafePixelRatio(pixelRatio);
const brushRadius = (brushSize * safePixelRatio) / 2; const brushRadius = (brushSize * safePixelRatio) / 2;
const brushRadiusVariation = Math.floor(brushRadius * brushSizeVariation);
const brushGeometryRadius = brushRadius + Math.max(0, brushRadiusVariation);
target[0] = brushRadius; target[0] = brushRadius;
target[1] = brushRadiusVariation * 2; target[1] = brushRadius * brushRadius;
target[2] = brushGeometryRadius * brushGeometryRadius; target[2] = 0;
target[3] = brushGeometryRadius; target[3] = 0;
target[4] = selectedColorIndex === 0 ? 1 : 0; target[4] = selectedColorIndex === 0 ? 1 : 0;
target[5] = selectedColorIndex === 1 ? 1 : 0; target[5] = selectedColorIndex === 1 ? 1 : 0;
target[6] = selectedColorIndex === 2 ? 1 : 0; target[6] = selectedColorIndex === 2 ? 1 : 0;
target[7] = brushAlpha; target[7] = brushAlpha;
target[8] = 1 / Math.max(Number.EPSILON, brushCoarseNoiseScale * safePixelRatio); target[8] = 1 / Math.max(Number.EPSILON, brushGrainNoiseScale * safePixelRatio);
target[9] = 1 / Math.max(Number.EPSILON, brushGrainNoiseScale * safePixelRatio); target[9] = brushGrainNoiseOffsetX;
target[10] = brushGrainNoiseOffsetX; target[10] = brushGrainNoiseOffsetY;
target[11] = brushGrainNoiseOffsetY; target[11] = brushDiscardThreshold;
target[12] = brushDiscardThreshold; target[12] = brushGrainMinStrength;
target[13] = brushGrainMinStrength; target[13] = brushGrainMaxStrength;
target[14] = brushGrainMaxStrength;
target[15] = 0;
}; };
export class BrushPipeline { export class BrushPipeline {

View file

@ -1,12 +0,0 @@
export interface BrushSettings {
brushSize: number;
brushSizeVariation: number;
brushAlpha: number;
brushDiscardThreshold: number;
brushCoarseNoiseScale: number;
brushGrainNoiseScale: number;
brushGrainNoiseOffsetX: number;
brushGrainNoiseOffsetY: number;
brushGrainMinStrength: number;
brushGrainMaxStrength: number;
}

View file

@ -1,10 +1,10 @@
struct Settings { struct Settings {
brushSize: f32, brushRadius: f32,
brushSizeVariation: f32, brushRadiusSquared: f32,
brushGeometryRadiusSquared: f32, // padding to 16-byte alignment for the following vec4
brushGeometryRadius: f32, _pad0: f32,
_pad1: f32,
brushValue: vec4<f32>, brushValue: vec4<f32>,
brushCoarseNoiseScale: f32,
brushGrainNoiseScale: f32, brushGrainNoiseScale: f32,
brushGrainNoiseOffsetX: f32, brushGrainNoiseOffsetX: f32,
brushGrainNoiseOffsetY: f32, brushGrainNoiseOffsetY: f32,
@ -39,7 +39,7 @@ fn vertex(
if denominator > 0.0001 { if denominator > 0.0001 {
inverseLengthSquared = 1.0 / denominator; inverseLengthSquared = 1.0 / denominator;
} }
let screenPosition = segment_vertex_position(vertexIndex, start, end, settings.brushGeometryRadius); let screenPosition = segment_vertex_position(vertexIndex, start, end, settings.brushRadius);
let uv = screenPosition / state.size; let uv = screenPosition / state.size;
let position = vec2(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0); let position = vec2(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, direction, inverseLengthSquared); return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, direction, inverseLengthSquared);
@ -74,18 +74,11 @@ fn brushStrength(
direction, direction,
inverseLengthSquared inverseLengthSquared
); );
if distanceSquared > settings.brushGeometryRadiusSquared { if distanceSquared > settings.brushRadiusSquared {
return 0.0; return 0.0;
} }
let coarseNoise = textureSampleLevel( let edge = 1.0 - step(settings.brushRadiusSquared, distanceSquared);
noise,
noiseSampler,
screenPosition * settings.brushCoarseNoiseScale,
0.0
).r;
let radius = settings.brushSize + (coarseNoise - 0.5) * settings.brushSizeVariation;
let edge = 1.0 - step(radius * radius, distanceSquared);
if edge * max(settings.brushGrainMinStrength, settings.brushGrainMaxStrength) < settings.brushDiscardThreshold { if edge * max(settings.brushGrainMinStrength, settings.brushGrainMaxStrength) < settings.brushDiscardThreshold {
return 0.0; return 0.0;
} }

View file

@ -24,9 +24,8 @@ export class CommonState {
struct State { struct State {
size: vec2<f32>, size: vec2<f32>,
time: f32, time: f32,
padding0: f32,
}; };
@group(0) @binding(0) var<uniform> state: State; @group(0) @binding(0) var<uniform> state: State;
@group(0) @binding(1) var noiseSampler: sampler; @group(0) @binding(1) var noiseSampler: sampler;
@group(0) @binding(2) var noise: texture_2d<f32>; @group(0) @binding(2) var noise: texture_2d<f32>;
@ -101,7 +100,6 @@ export class CommonState {
this.uniformValues[0] = canvasSize[0]; this.uniformValues[0] = canvasSize[0];
this.uniformValues[1] = canvasSize[1]; this.uniformValues[1] = canvasSize[1];
this.uniformValues[2] = time; this.uniformValues[2] = time;
this.uniformValues[3] = 0;
writeFloat32BufferIfChanged( writeFloat32BufferIfChanged(
this.device, this.device,
this.uniforms, this.uniforms,

View file

@ -1,12 +1,12 @@
struct Settings { struct Settings {
inverseDiffusionRateTrails: f32, inverseDiffusionRateTrails: f32,
decayRateTrails: f32, decayRateTrails: f32,
inverseDiffusionRateBrush: f32,
decayRateBrush: f32,
diffusionNeighborScale: f32, diffusionNeighborScale: f32,
brushDecayAlphaMultiplier: f32, brushDecayAlphaMultiplier: f32,
brushDecayAlphaSubtract: f32, brushDecayAlphaSubtract: f32,
padding0: f32,
padding1: f32, padding1: f32,
padding2: f32,
}; };
const WORKGROUP_SIZE_X = 16u; const WORKGROUP_SIZE_X = 16u;
@ -74,25 +74,16 @@ fn main(
r16, r16,
settings.inverseDiffusionRateTrails settings.inverseDiffusionRateTrails
); );
let brushWeight = diffusion_weight(
random,
r2,
r4,
r8,
r16,
settings.inverseDiffusionRateBrush
);
current += ( current += (
propagate(centerTileIndex, -1, -1, current, trailWeight, brushWeight) propagate(centerTileIndex, -1, -1, current, trailWeight)
+ propagate(centerTileIndex, -1, 1, current, trailWeight, brushWeight) + propagate(centerTileIndex, -1, 1, current, trailWeight)
+ propagate(centerTileIndex, 1, -1, current, trailWeight, brushWeight) + propagate(centerTileIndex, 1, -1, current, trailWeight)
+ propagate(centerTileIndex, 1, 1, current, trailWeight, brushWeight) + propagate(centerTileIndex, 1, 1, current, trailWeight)
+ propagate(centerTileIndex, -1, 0, current, trailWeight, brushWeight) + propagate(centerTileIndex, -1, 0, current, trailWeight)
+ propagate(centerTileIndex, 0, -1, current, trailWeight, brushWeight) + propagate(centerTileIndex, 0, -1, current, trailWeight)
+ propagate(centerTileIndex, 1, 0, current, trailWeight, brushWeight) + propagate(centerTileIndex, 1, 0, current, trailWeight)
+ propagate(centerTileIndex, 0, 1, current, trailWeight, brushWeight) + propagate(centerTileIndex, 0, 1, current, trailWeight)
) * settings.diffusionNeighborScale; ) * settings.diffusionNeighborScale;
let decayed = clamp(vec4( let decayed = clamp(vec4(
@ -108,8 +99,7 @@ fn propagate(
offsetX: i32, offsetX: i32,
offsetY: i32, offsetY: i32,
currentColor: vec4<f32>, currentColor: vec4<f32>,
trailWeight: f32, trailWeight: f32
brushWeight: f32
) -> vec4<f32> { ) -> vec4<f32> {
let neighbourIndex = i32(centerTileIndex) + offsetY * i32(TILE_SIZE_X) + offsetX; let neighbourIndex = i32(centerTileIndex) + offsetY * i32(TILE_SIZE_X) + offsetX;
let neighbourTileIndex = u32(neighbourIndex); let neighbourTileIndex = u32(neighbourIndex);
@ -118,7 +108,7 @@ fn propagate(
return vec4( return vec4(
vec3(tileTrailStrength[neighbourTileIndex] * trailWeight), vec3(tileTrailStrength[neighbourTileIndex] * trailWeight),
neighbour.a * brushWeight neighbour.a * trailWeight
) * difference; ) * difference;
} }

View file

@ -1,20 +1,27 @@
import { vec2 } from 'gl-matrix'; import { vec2 } from 'gl-matrix';
import { appConfig } from '../../config'; import { appConfig } from '../../config';
import { createBindGroupCache } from '../../utils/graphics/bind-group-cache';
import { import {
createCachedFloat32BufferWrite, createCachedFloat32BufferWrite,
writeFloat32BufferIfChanged, writeFloat32BufferIfChanged,
} from '../../utils/graphics/cached-buffer-write'; } from '../../utils/graphics/cached-buffer-write';
import { getWorkgroupCount } from '../../utils/graphics/get-workgroup-count';
import { smartCompile } from '../../utils/graphics/smart-compile'; import { smartCompile } from '../../utils/graphics/smart-compile';
import shader from './diffuse.wgsl?raw'; import shader from './diffuse.wgsl?raw';
import { DiffusionSettings } from './diffusion-settings';
export interface DiffusionSettings {
diffusionRateTrails: number;
decayRateTrails: number;
decayRateBrush: number;
diffusionDecayRateDivisor: number;
diffusionNeighborDivisor: number;
brushDecayAlphaOffset: number;
}
type DiffusionUniformSettings = Pick< type DiffusionUniformSettings = Pick<
DiffusionSettings, DiffusionSettings,
| 'diffusionRateTrails' | 'diffusionRateTrails'
| 'decayRateTrails' | 'decayRateTrails'
| 'diffusionRateBrush'
| 'decayRateBrush' | 'decayRateBrush'
| 'diffusionDecayRateDivisor' | 'diffusionDecayRateDivisor'
| 'diffusionNeighborDivisor' | 'diffusionNeighborDivisor'
@ -33,7 +40,6 @@ const setDiffusionUniformValues = (
{ {
diffusionRateTrails, diffusionRateTrails,
decayRateTrails, decayRateTrails,
diffusionRateBrush,
decayRateBrush, decayRateBrush,
diffusionDecayRateDivisor, diffusionDecayRateDivisor,
diffusionNeighborDivisor, diffusionNeighborDivisor,
@ -47,11 +53,11 @@ const setDiffusionUniformValues = (
: 1; : 1;
target[0] = getSafeInverseDiffusionRate(diffusionRateTrails); target[0] = getSafeInverseDiffusionRate(diffusionRateTrails);
target[1] = decayRateTrails / decayDivisor; target[1] = decayRateTrails / decayDivisor;
target[2] = getSafeInverseDiffusionRate(diffusionRateBrush); target[2] = 1 / neighborDivisor;
target[3] = brushDecayRate; target[3] = 1 + brushDecayRate;
target[4] = 1 / neighborDivisor; target[4] = brushDecayAlphaOffset * brushDecayRate;
target[5] = 1 + brushDecayRate; target[5] = 0;
target[6] = brushDecayAlphaOffset * brushDecayRate; target[6] = 0;
target[7] = 0; target[7] = 0;
}; };
@ -66,10 +72,17 @@ export class DiffusionPipeline {
private readonly uniformCache = createCachedFloat32BufferWrite( private readonly uniformCache = createCachedFloat32BufferWrite(
DiffusionPipeline.UNIFORM_COUNT DiffusionPipeline.UNIFORM_COUNT
); );
private readonly bindGroupsByInput = new WeakMap< private readonly getBindGroup = createBindGroupCache<GPUTextureView, GPUTextureView>(
GPUTextureView, (trailMapIn, trailMapOut) =>
WeakMap<GPUTextureView, GPUBindGroup> this.device.createBindGroup({
>(); layout: this.bindGroupLayout,
entries: [
{ binding: 0, resource: { buffer: this.uniforms } },
{ binding: 1, resource: trailMapIn },
{ binding: 2, resource: trailMapOut },
],
})
);
public constructor(private readonly device: GPUDevice) { public constructor(private readonly device: GPUDevice) {
this.bindGroupLayout = device.createBindGroupLayout( this.bindGroupLayout = device.createBindGroupLayout(
@ -95,7 +108,6 @@ export class DiffusionPipeline {
public setParameters({ public setParameters({
diffusionRateTrails, diffusionRateTrails,
decayRateTrails, decayRateTrails,
diffusionRateBrush,
decayRateBrush, decayRateBrush,
diffusionDecayRateDivisor, diffusionDecayRateDivisor,
diffusionNeighborDivisor, diffusionNeighborDivisor,
@ -104,7 +116,6 @@ export class DiffusionPipeline {
setDiffusionUniformValues(this.uniformValues, { setDiffusionUniformValues(this.uniformValues, {
diffusionRateTrails, diffusionRateTrails,
decayRateTrails, decayRateTrails,
diffusionRateBrush,
decayRateBrush, decayRateBrush,
diffusionDecayRateDivisor, diffusionDecayRateDivisor,
diffusionNeighborDivisor, diffusionNeighborDivisor,
@ -130,51 +141,12 @@ export class DiffusionPipeline {
passEncoder.setPipeline(this.pipeline); passEncoder.setPipeline(this.pipeline);
passEncoder.setBindGroup(0, bindGroup); passEncoder.setBindGroup(0, bindGroup);
passEncoder.dispatchWorkgroups( passEncoder.dispatchWorkgroups(
getWorkgroupCount(size[0], DiffusionPipeline.WORKGROUP_SIZE), Math.ceil(size[0] / DiffusionPipeline.WORKGROUP_SIZE),
getWorkgroupCount(size[1], DiffusionPipeline.WORKGROUP_SIZE) Math.ceil(size[1] / DiffusionPipeline.WORKGROUP_SIZE)
); );
passEncoder.end(); passEncoder.end();
} }
private getBindGroup(
trailMapIn: GPUTextureView,
trailMapOut: GPUTextureView
): GPUBindGroup {
let outputCache = this.bindGroupsByInput.get(trailMapIn);
if (!outputCache) {
outputCache = new WeakMap<GPUTextureView, GPUBindGroup>();
this.bindGroupsByInput.set(trailMapIn, outputCache);
}
const cached = outputCache.get(trailMapOut);
if (cached) {
return cached;
}
const bindGroup = this.device.createBindGroup({
layout: this.bindGroupLayout,
entries: [
{
binding: 0,
resource: {
buffer: this.uniforms,
},
},
{
binding: 1,
resource: trailMapIn,
},
{
binding: 2,
resource: trailMapOut,
},
],
});
outputCache.set(trailMapOut, bindGroup);
return bindGroup;
}
public destroy() { public destroy() {
this.uniforms.destroy(); this.uniforms.destroy();
} }

View file

@ -1,9 +0,0 @@
export interface DiffusionSettings {
diffusionRateTrails: number;
decayRateTrails: number;
diffusionRateBrush: number;
decayRateBrush: number;
diffusionDecayRateDivisor: number;
diffusionNeighborDivisor: number;
brushDecayAlphaOffset: number;
}

View file

@ -1,5 +1,6 @@
import { vec2 } from 'gl-matrix'; import { vec2 } from 'gl-matrix';
import { createBindGroupCache } from '../../utils/graphics/bind-group-cache';
import { import {
createCachedFloat32BufferWrite, createCachedFloat32BufferWrite,
writeFloat32BufferIfChanged, writeFloat32BufferIfChanged,
@ -20,10 +21,17 @@ export class EraserAgentPipeline {
private readonly uniformCache = createCachedFloat32BufferWrite( private readonly uniformCache = createCachedFloat32BufferWrite(
EraserAgentPipeline.UNIFORM_COUNT EraserAgentPipeline.UNIFORM_COUNT
); );
private readonly bindGroupsByAgentsBuffer = new WeakMap< private readonly bindGroupCache = createBindGroupCache<GPUBuffer, GPUTextureView>(
GPUBuffer, (agentsBuffer, eraserMask) =>
WeakMap<GPUTextureView, GPUBindGroup> this.device.createBindGroup({
>(); layout: this.bindGroupLayout,
entries: [
{ binding: 0, resource: { buffer: this.uniforms } },
{ binding: 1, resource: { buffer: agentsBuffer } },
{ binding: 2, resource: eraserMask },
],
})
);
private pendingSegmentCount = 0; private pendingSegmentCount = 0;
private activeSegmentCount = 0; private activeSegmentCount = 0;
@ -121,7 +129,7 @@ export class EraserAgentPipeline {
const passEncoder = commandEncoder.beginComputePass(); const passEncoder = commandEncoder.beginComputePass();
passEncoder.setPipeline(this.pipeline); passEncoder.setPipeline(this.pipeline);
passEncoder.setBindGroup(1, this.getBindGroup(eraserMask)); passEncoder.setBindGroup(1, this.bindGroupCache(this.getAgentsBuffer(), eraserMask));
dispatchAgentWorkgroups(passEncoder, this.agentCount); dispatchAgentWorkgroups(passEncoder, this.agentCount);
passEncoder.end(); passEncoder.end();
} }
@ -129,43 +137,4 @@ export class EraserAgentPipeline {
public destroy(): void { public destroy(): void {
this.uniforms.destroy(); this.uniforms.destroy();
} }
private getBindGroup(eraserMask: GPUTextureView): GPUBindGroup {
const agentsBuffer = this.getAgentsBuffer();
let maskCache = this.bindGroupsByAgentsBuffer.get(agentsBuffer);
if (!maskCache) {
maskCache = new WeakMap<GPUTextureView, GPUBindGroup>();
this.bindGroupsByAgentsBuffer.set(agentsBuffer, maskCache);
}
const cached = maskCache.get(eraserMask);
if (cached) {
return cached;
}
const bindGroup = this.device.createBindGroup({
layout: this.bindGroupLayout,
entries: [
{
binding: 0,
resource: {
buffer: this.uniforms,
},
},
{
binding: 1,
resource: {
buffer: agentsBuffer,
},
},
{
binding: 2,
resource: eraserMask,
},
],
});
maskCache.set(eraserMask, bindGroup);
return bindGroup;
}
} }

View file

@ -124,7 +124,6 @@ export class EraserTexturePipeline {
this.uniformValues[4] = eraserClearBlue; this.uniformValues[4] = eraserClearBlue;
this.uniformValues[5] = eraserClearAlpha; this.uniformValues[5] = eraserClearAlpha;
this.uniformValues[6] = eraserRadius; this.uniformValues[6] = eraserRadius;
this.uniformValues[7] = 0;
writeFloat32BufferIfChanged( writeFloat32BufferIfChanged(
this.device, this.device,
this.uniforms, this.uniforms,

View file

@ -6,7 +6,6 @@ struct Settings {
clearBlue: f32, clearBlue: f32,
clearAlpha: f32, clearAlpha: f32,
eraserRadius: f32, eraserRadius: f32,
padding1: f32,
}; };
@group(1) @binding(0) var<uniform> settings: Settings; @group(1) @binding(0) var<uniform> settings: Settings;

View file

@ -1,3 +1,4 @@
import { createBindGroupCache } from '../../utils/graphics/bind-group-cache';
import { import {
createCachedFloat32BufferWrite, createCachedFloat32BufferWrite,
writeFloat32BufferIfChanged, writeFloat32BufferIfChanged,
@ -6,9 +7,16 @@ import { setUpFullScreenQuad } from '../../utils/graphics/full-screen-quad';
import { smartCompile } from '../../utils/graphics/smart-compile'; import { smartCompile } from '../../utils/graphics/smart-compile';
import { rgbChannelToUnit, type RgbColor } from '../../utils/rgb-color'; import { rgbChannelToUnit, type RgbColor } from '../../utils/rgb-color';
import { CommonState } from '../common-state/common-state'; import { CommonState } from '../common-state/common-state';
import { RenderSettings } from './render-settings';
import shader from './render.wgsl?raw'; import shader from './render.wgsl?raw';
export interface RenderSettings {
clarity: number;
renderTraceNormalizationFloor: number;
renderBrushColorBase: number;
renderBrushColorStrengthMultiplier: number;
backgroundGrainStrength: number;
}
export class RenderPipeline { export class RenderPipeline {
private static readonly UNIFORM_COUNT = 20; private static readonly UNIFORM_COUNT = 20;
@ -21,10 +29,17 @@ export class RenderPipeline {
RenderPipeline.UNIFORM_COUNT RenderPipeline.UNIFORM_COUNT
); );
private readonly bindGroupsByTexture = new WeakMap< private readonly getBindGroup = createBindGroupCache<GPUTextureView, GPUTextureView>(
GPUTextureView, (colorTexture, sourceTexture) =>
WeakMap<GPUTextureView, GPUBindGroup> this.device.createBindGroup({
>(); layout: this.bindGroupLayout,
entries: [
{ binding: 0, resource: { buffer: this.uniforms } },
{ binding: 2, resource: colorTexture },
{ binding: 3, resource: sourceTexture },
],
})
);
public constructor( public constructor(
private readonly context: GPUCanvasContext, private readonly context: GPUCanvasContext,
@ -165,45 +180,6 @@ export class RenderPipeline {
passEncoder.end(); passEncoder.end();
} }
private getBindGroup(
colorTexture: GPUTextureView,
sourceTexture: GPUTextureView
): GPUBindGroup {
let sourceTextureCache = this.bindGroupsByTexture.get(colorTexture);
if (!sourceTextureCache) {
sourceTextureCache = new WeakMap<GPUTextureView, GPUBindGroup>();
this.bindGroupsByTexture.set(colorTexture, sourceTextureCache);
}
const cached = sourceTextureCache.get(sourceTexture);
if (cached) {
return cached;
}
const bindGroup = this.device.createBindGroup({
layout: this.bindGroupLayout,
entries: [
{
binding: 0,
resource: {
buffer: this.uniforms,
},
},
{
binding: 2,
resource: colorTexture,
},
{
binding: 3,
resource: sourceTexture,
},
],
});
sourceTextureCache.set(sourceTexture, bindGroup);
return bindGroup;
}
public destroy() { public destroy() {
this.uniforms.destroy(); this.uniforms.destroy();
} }

View file

@ -1,7 +0,0 @@
export interface RenderSettings {
clarity: number;
renderTraceNormalizationFloor: number;
renderBrushColorBase: number;
renderBrushColorStrengthMultiplier: number;
backgroundGrainStrength: number;
}

View file

@ -44,27 +44,105 @@
.color-reaction-matrix__cell { .color-reaction-matrix__cell {
min-width: 0; min-width: 0;
display: grid;
} }
.color-reaction-matrix__cell > select { .color-reaction-matrix__button {
position: relative;
display: grid;
width: 100%; width: 100%;
min-width: 0; min-width: 0;
height: 28px; height: 28px;
place-items: center;
border: 1px solid rgb(255 255 255 / 16%); border: 1px solid rgb(255 255 255 / 16%);
border-radius: 4px; border-radius: 4px;
padding: 0 4px; padding: 0 4px;
background: rgb(255 255 255 / 8%); background: rgb(255 255 255 / 8%);
color: white; color: white;
font: inherit; font: inherit;
font-size: 11px; cursor: pointer;
transition:
background-color var(--transition-time),
border-color var(--transition-time),
color var(--transition-time),
transform var(--transition-time);
} }
.color-reaction-matrix__cell > select:focus-visible { .color-reaction-matrix__button:hover {
transform: translateY(-1px);
}
.color-reaction-matrix__button:focus-visible {
outline: 2px solid rgb(255 255 255 / 72%); outline: 2px solid rgb(255 255 255 / 72%);
outline-offset: 1px; outline-offset: 1px;
} }
.color-reaction-matrix__cell > select > option { .color-reaction-matrix__button[data-reaction='follow'] {
background: rgb(28 31 38); border-color: rgb(115 235 160 / 44%);
color: white; background: rgb(53 165 96 / 20%);
color: rgb(157 255 195 / 94%);
}
.color-reaction-matrix__button[data-reaction='ignore'] {
border-color: rgb(255 255 255 / 18%);
background: rgb(255 255 255 / 7%);
color: rgb(235 238 245 / 72%);
}
.color-reaction-matrix__button[data-reaction='avoid'] {
border-color: rgb(255 145 120 / 46%);
background: rgb(215 74 54 / 19%);
color: rgb(255 171 148 / 94%);
}
.color-reaction-matrix__icon {
position: relative;
display: block;
width: 16px;
height: 16px;
}
.color-reaction-matrix__icon::before,
.color-reaction-matrix__icon::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
border-radius: 999px;
background: currentColor;
transform: translate(-50%, -50%);
}
.color-reaction-matrix__button[data-reaction='follow']
> .color-reaction-matrix__icon::before,
.color-reaction-matrix__button[data-reaction='avoid']
> .color-reaction-matrix__icon::before {
width: 14px;
height: 2px;
}
.color-reaction-matrix__button[data-reaction='follow']
> .color-reaction-matrix__icon::after {
width: 2px;
height: 14px;
}
.color-reaction-matrix__button[data-reaction='avoid']
> .color-reaction-matrix__icon::after {
display: none;
}
.color-reaction-matrix__button[data-reaction='ignore'] > .color-reaction-matrix__icon {
width: 12px;
height: 12px;
border: 2px solid currentColor;
border-radius: 999px;
opacity: 0.82;
}
.color-reaction-matrix__button[data-reaction='ignore']
> .color-reaction-matrix__icon::before,
.color-reaction-matrix__button[data-reaction='ignore']
> .color-reaction-matrix__icon::after {
display: none;
} }

View file

@ -1,6 +1,6 @@
export const readBrowserStorage = (key: string): string | null => { export const readBrowserStorage = (key: string): string | null => {
try { try {
return typeof localStorage === 'undefined' ? null : localStorage.getItem(key); return localStorage.getItem(key);
} catch { } catch {
return null; return null;
} }
@ -8,13 +8,11 @@ export const readBrowserStorage = (key: string): string | null => {
export const writeBrowserStorage = (key: string, value: string): void => { export const writeBrowserStorage = (key: string, value: string): void => {
try { try {
if (typeof localStorage !== 'undefined') { localStorage.setItem(key, value);
localStorage.setItem(key, value);
}
} catch (error) { } catch (error) {
console.warn( console.warn(
'Storage can be unavailable in private browsing or embedded contexts.', 'Storage can be unavailable in private browsing or embedded contexts.',
error, error
); );
} }
}; };

View file

@ -0,0 +1,19 @@
export const createBindGroupCache = <K1 extends object, K2 extends object>(
factory: (key1: K1, key2: K2) => GPUBindGroup
): ((key1: K1, key2: K2) => GPUBindGroup) => {
const outer = new WeakMap<K1, WeakMap<K2, GPUBindGroup>>();
return (key1, key2) => {
let inner = outer.get(key1);
if (!inner) {
inner = new WeakMap();
outer.set(key1, inner);
}
const cached = inner.get(key2);
if (cached) {
return cached;
}
const bindGroup = factory(key1, key2);
inner.set(key2, bindGroup);
return bindGroup;
};
};

View file

@ -1,17 +0,0 @@
export const getWorkgroupCount = (
invocationCount: number,
workgroupSize: number
): number => {
if (
!Number.isFinite(invocationCount) ||
!Number.isFinite(workgroupSize) ||
invocationCount <= 0 ||
workgroupSize <= 0
) {
throw new Error(
'Invocation count and workgroup size must be positive finite numbers'
);
}
return Math.ceil(invocationCount / workgroupSize);
};

View file

@ -1,8 +1,9 @@
import { appConfig, type VibeId, type VibePreset } from './config'; import { appConfig } from './config';
import { VibeId, type VibePreset } from './config/types';
import { readBrowserStorage } from './utils/browser-storage'; import { readBrowserStorage } from './utils/browser-storage';
export { VibeId } from './config'; export { VibeId };
export type { VibePreset } from './config'; export type { VibePreset };
export const VIBE_PRESETS: Array<VibePreset> = appConfig.vibes.presets; export const VIBE_PRESETS: Array<VibePreset> = appConfig.vibes.presets;
const VIBE_IDS = new Set<VibeId>(VIBE_PRESETS.map((vibe) => vibe.id)); const VIBE_IDS = new Set<VibeId>(VIBE_PRESETS.map((vibe) => vibe.id));