more clean up

This commit is contained in:
Andras Schmelczer 2026-05-18 08:11:58 +01:00
parent 15e99380b5
commit ea0304356f
11 changed files with 710 additions and 0 deletions

13
src/app-constants.ts Normal file
View file

@ -0,0 +1,13 @@
export const ENABLED_FLAG_VALUE = '1';
export const DISABLED_FLAG_VALUE = '0';
export const UNIT_INTERVAL_INPUT_MIN = '0';
export const UNIT_INTERVAL_INPUT_MAX = '1';
export const DEFAULT_AUDIO_VOLUME = 0.42;
export const APP_STORAGE_KEYS = {
audioMuted: 'fleeting-garden:audio-muted',
audioVolume: 'fleeting-garden:audio-volume',
vibe: 'fleeting-garden:vibe',
} as const;

View file

@ -0,0 +1,38 @@
import { clamp } from '../utils/math';
import { isIosLike } from './audio-platform';
interface AudioPanNode {
input: AudioNode;
output: AudioNode;
disconnect: () => void;
}
type AudioContextWithOptionalPanner = AudioContext & {
createStereoPanner?: () => StereoPannerNode;
};
export const createAudioPanNode = (
context: AudioContext,
pan: number,
startTime: number
): AudioPanNode => {
const createStereoPanner = (context as AudioContextWithOptionalPanner)
.createStereoPanner;
if (!isIosLike() && typeof createStereoPanner === 'function') {
const panner = createStereoPanner.call(context);
panner.pan.setValueAtTime(clamp(pan, -1, 1), startTime);
return {
input: panner,
output: panner,
disconnect: () => panner.disconnect(),
};
}
const passthrough = context.createGain();
return {
input: passthrough,
output: passthrough,
disconnect: () => passthrough.disconnect(),
};
};

View file

@ -0,0 +1,3 @@
export const isIosLike = (): boolean =>
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);

View file

@ -0,0 +1,3 @@
export const GENERATIVE_LOOKAHEAD_SECONDS = 0.3;
export const GENERATIVE_START_DELAY_SECONDS = 0.02;
export const PIANO_SCHEDULE_AHEAD_SECONDS = 0.002;

View file

@ -0,0 +1,427 @@
export interface GardenAudioRegister {
midiMin: number;
midiMax: number;
preferredMidi: number;
pan: number;
}
export interface GardenAudioStylePool extends GardenAudioRegister {
scaleDegrees: Array<number>;
}
interface GardenAudioStyleVoice {
scaleDegreeOffset: number;
velocityMultiplier: number;
panOffset: number;
}
interface GenerativePianoTuning {
stylePools: [GardenAudioStylePool, GardenAudioStylePool, GardenAudioStylePool];
padRegisters: [GardenAudioRegister, GardenAudioRegister, GardenAudioRegister];
vibeChangeStinger: {
velocities: [number, number, number];
pans: [number, number, number];
delaySends: [number, number, number];
lowpassExpression: number;
};
highActivityExtra: {
barOffset: number;
expressionMultiplier: number;
};
padChord: {
velocities: [number, number, number];
expressionVelocityWeight: number;
delaySend: number;
lowpassExpressionWeight: number;
};
supportNote: {
velocityBase: number;
velocityExpressionWeight: number;
durationBaseSeconds: number;
durationExpressionSeconds: number;
delaySendBase: number;
delaySendExpressionWeight: number;
lowpassExpressionWeight: number;
expressionThreshold: number;
offsetsByStyle: [Array<number>, Array<number>, Array<number>];
};
textureNote: {
velocityBase: number;
velocityExpressionWeight: number;
durationBaseSeconds: number;
durationExpressionSeconds: number;
delaySendBase: number;
delaySendExpressionWeight: number;
idleExpressionThreshold: number;
mediumExpressionThreshold: number;
intenseSpacing: number;
idlePhase: number;
};
gestureAccent: {
rotationStrengthMultiplier: number;
quantizeStepLookahead: number;
velocityBase: number;
velocityStrengthWeight: number;
durationBaseSeconds: number;
durationStrengthSeconds: number;
delaySend: number;
};
touchNote: {
registerBiasManiaAmount: number;
velocityBase: number;
velocityStrengthWeight: number;
durationBaseSeconds: number;
durationStrengthSeconds: number;
delaySend: number;
lowpassBaseExpression: number;
lowpassStrengthWeight: number;
};
brushPhrase: {
initialMotifOffset: number;
energyDecaySeconds: number;
maniaDecaySeconds: number;
fadeMinimumLifetimeSeconds: number;
layerIntensityBase: number;
layerIntensityManiaWeight: number;
frameActivityWeight: number;
frameManiaWeight: number;
};
brushStream: {
inferredManiaThreshold: number;
inferredManiaRange: number;
registerManiaShift: number;
chordToneEverySteps: number;
durationBaseSeconds: number;
durationIntensitySeconds: number;
durationManiaSeconds: number;
durationMinSeconds: number;
durationMaxSeconds: number;
delaySendBase: number;
delaySendIntensityWeight: number;
delaySendManiaWeight: number;
delaySendMin: number;
delaySendMax: number;
velocityBase: number;
velocityIntensityWeight: number;
lowpassBaseExpression: number;
lowpassIntensityWeight: number;
lowpassManiaWeight: number;
manicThreshold: number;
intenseThreshold: number;
activeThreshold: number;
};
brushStreamEcho: {
maniaThreshold: number;
stepModulo: number;
stepRemainder: number;
intensityThreshold: number;
octaveSemitones: number;
maxMidi: number;
velocityBase: number;
velocityIntensityWeight: number;
durationMinSeconds: number;
durationScale: number;
panScale: number;
delaySendMin: number;
delaySendScale: number;
lowpassBaseExpression: number;
lowpassManiaWeight: number;
};
brushMotif: {
highThreshold: number;
mediumThreshold: number;
highOffset: number;
mediumOffset: number;
lowOffset: number;
minOffset: number;
maxOffset: number;
};
registerBias: {
maniaShiftSemitones: number;
midiMin: number;
midiMaxForMin: number;
minimumSpan: number;
midiMax: number;
};
candidateOctaveSearch: {
min: number;
max: number;
};
stylePanOffsetScale: number;
lowpass: {
midiBase: number;
midiRange: number;
midiLiftHz: number;
expressionBase: number;
expressionWeight: number;
};
styleRotationBars: number;
chordBars: number;
supportBarSpacing: number;
supportBarOffset: number;
idleTextureBarSpacing: number;
mediumTextureBarSpacing: number;
textureBeat: number;
highActivityExtraBeat: number;
highActivityExtraThreshold: number;
noteScorePreferenceWeight: number;
noteScoreRegisterWeight: number;
noteScoreChordToneWeight: number;
noteScoreRepeatPenalty: number;
gestureAccentMinIntervalSeconds: number;
strokeAccentMinSteps: number;
strokeAccentThreshold: number;
stingerDurationSeconds: number;
stingerSpacingSeconds: number;
maxBrushPhraseLayers: number;
maxBrushStreamNotesPerBar: number;
brushLayerBaseSeconds: number;
brushLayerEnergySeconds: number;
brushLayerMinIntensity: number;
brushStreamIdleIntervalBeats: number;
brushStreamActiveIntervalBeats: number;
brushStreamIntenseIntervalBeats: number;
brushStreamManicIntervalBeats: number;
brushMotifMaxSteps: number;
brushMotifCanonDelaySeconds: number;
padDurationBarScale: number;
}
export const generativePianoTuning: GenerativePianoTuning = {
stylePools: [
{
midiMin: 48,
midiMax: 67,
preferredMidi: 55,
pan: -0.18,
scaleDegrees: [0, 1, 2, 4],
},
{
midiMin: 55,
midiMax: 74,
preferredMidi: 63,
pan: 0,
scaleDegrees: [1, 2, 3, 5],
},
{
midiMin: 62,
midiMax: 81,
preferredMidi: 72,
pan: 0.18,
scaleDegrees: [2, 3, 4, 6],
},
],
padRegisters: [
{
midiMin: 40,
midiMax: 55,
preferredMidi: 48,
pan: -0.12,
},
{
midiMin: 48,
midiMax: 64,
preferredMidi: 55,
pan: 0.08,
},
{
midiMin: 58,
midiMax: 76,
preferredMidi: 67,
pan: 0.2,
},
],
vibeChangeStinger: {
velocities: [0.1, 0.085, 0.07],
pans: [-0.16, 0, 0.16],
delaySends: [0.012, 0.014, 0.016],
lowpassExpression: 0.35,
},
highActivityExtra: {
barOffset: 1,
expressionMultiplier: 0.9,
},
padChord: {
velocities: [0.052, 0.041, 0.033],
expressionVelocityWeight: 0.02,
delaySend: 0.008,
lowpassExpressionWeight: 0.28,
},
supportNote: {
velocityBase: 0.105,
velocityExpressionWeight: 0.07,
durationBaseSeconds: 1.35,
durationExpressionSeconds: 0.4,
delaySendBase: 0.016,
delaySendExpressionWeight: 0.006,
lowpassExpressionWeight: 0.7,
expressionThreshold: 0.55,
offsetsByStyle: [
[0, 2, 12],
[1, 2, 0, 12],
[2, 12, 3, 13],
],
},
textureNote: {
velocityBase: 0.09,
velocityExpressionWeight: 0.08,
durationBaseSeconds: 0.62,
durationExpressionSeconds: 0.24,
delaySendBase: 0.016,
delaySendExpressionWeight: 0.006,
idleExpressionThreshold: 0.35,
mediumExpressionThreshold: 0.7,
intenseSpacing: 1,
idlePhase: 1,
},
gestureAccent: {
rotationStrengthMultiplier: 3,
quantizeStepLookahead: 1,
velocityBase: 0.12,
velocityStrengthWeight: 0.09,
durationBaseSeconds: 0.48,
durationStrengthSeconds: 0.22,
delaySend: 0.012,
},
touchNote: {
registerBiasManiaAmount: 0,
velocityBase: 0.14,
velocityStrengthWeight: 0.11,
durationBaseSeconds: 0.55,
durationStrengthSeconds: 0.18,
delaySend: 0.006,
lowpassBaseExpression: 0.55,
lowpassStrengthWeight: 0.35,
},
brushPhrase: {
initialMotifOffset: -1,
energyDecaySeconds: 0.72,
maniaDecaySeconds: 0.54,
fadeMinimumLifetimeSeconds: 0.001,
layerIntensityBase: 0.8,
layerIntensityManiaWeight: 0.42,
frameActivityWeight: 0.42,
frameManiaWeight: 0.18,
},
brushStream: {
inferredManiaThreshold: 0.82,
inferredManiaRange: 0.18,
registerManiaShift: 0.45,
chordToneEverySteps: 4,
durationBaseSeconds: 0.48,
durationIntensitySeconds: 0.08,
durationManiaSeconds: 0.34,
durationMinSeconds: 0.14,
durationMaxSeconds: 0.62,
delaySendBase: 0.012,
delaySendIntensityWeight: 0.011,
delaySendManiaWeight: 0.006,
delaySendMin: 0.006,
delaySendMax: 0.032,
velocityBase: 0.1,
velocityIntensityWeight: 0.13,
lowpassBaseExpression: 0.39,
lowpassIntensityWeight: 0.48,
lowpassManiaWeight: 0.18,
manicThreshold: 0.85,
intenseThreshold: 0.62,
activeThreshold: 0.34,
},
brushStreamEcho: {
maniaThreshold: 0.86,
stepModulo: 2,
stepRemainder: 1,
intensityThreshold: 0.95,
octaveSemitones: 12,
maxMidi: 88,
velocityBase: 0.045,
velocityIntensityWeight: 0.05,
durationMinSeconds: 0.11,
durationScale: 0.68,
panScale: -0.75,
delaySendMin: 0.006,
delaySendScale: 0.72,
lowpassBaseExpression: 0.62,
lowpassManiaWeight: 0.24,
},
brushMotif: {
highThreshold: 0.82,
mediumThreshold: 0.55,
highOffset: 1,
mediumOffset: 0,
lowOffset: -1,
minOffset: -3,
maxOffset: 4,
},
registerBias: {
maniaShiftSemitones: 4,
midiMin: 36,
midiMaxForMin: 86,
minimumSpan: 4,
midiMax: 91,
},
candidateOctaveSearch: {
min: -3,
max: 3,
},
stylePanOffsetScale: 0.35,
lowpass: {
midiBase: 48,
midiRange: 33,
midiLiftHz: 720,
expressionBase: 0.58,
expressionWeight: 0.32,
},
styleRotationBars: 2,
chordBars: 4,
supportBarSpacing: 2,
supportBarOffset: 1,
idleTextureBarSpacing: 2,
mediumTextureBarSpacing: 1,
textureBeat: 2,
highActivityExtraBeat: 3,
highActivityExtraThreshold: 0.45,
noteScorePreferenceWeight: 1.8,
noteScoreRegisterWeight: 0.28,
noteScoreChordToneWeight: 0.75,
noteScoreRepeatPenalty: 3.2,
gestureAccentMinIntervalSeconds: 2.5,
strokeAccentMinSteps: 12,
strokeAccentThreshold: 0.58,
stingerSpacingSeconds: 0.08,
stingerDurationSeconds: 1.1,
maxBrushPhraseLayers: 3,
maxBrushStreamNotesPerBar: 9,
brushLayerBaseSeconds: 5.5,
brushLayerEnergySeconds: 2.5,
brushLayerMinIntensity: 0.12,
brushStreamIdleIntervalBeats: 2,
brushStreamActiveIntervalBeats: 1,
brushStreamIntenseIntervalBeats: 0.5,
brushStreamManicIntervalBeats: 0.5,
brushMotifMaxSteps: 8,
brushMotifCanonDelaySeconds: 0.055,
padDurationBarScale: 0.46,
};
export const styleVoices: [
GardenAudioStyleVoice,
GardenAudioStyleVoice,
GardenAudioStyleVoice,
] = [
{
scaleDegreeOffset: 0,
velocityMultiplier: 0.92,
panOffset: -0.14,
},
{
scaleDegreeOffset: 1,
velocityMultiplier: 1,
panOffset: 0,
},
{
scaleDegreeOffset: 2,
velocityMultiplier: 0.86,
panOffset: 0.14,
},
];

View file

@ -0,0 +1,68 @@
const DEV_STATS_REFRESH_MS = 200;
const ZERO_STAT_TEXT = '0';
const ZERO_FRAME_TIME_TEXT = '0ms';
const ZERO_RESOLUTION_TEXT = '0x0';
interface DevStatsSnapshot {
time: DOMHighResTimeStamp;
fps: number;
agentCount: number;
frameTimeMs: number;
renderWidth: number;
renderHeight: number;
}
export class DevStatsOverlay {
private readonly element: HTMLDivElement;
private previousUpdateTime = Number.NEGATIVE_INFINITY;
private previousText = '';
public constructor(parent: HTMLElement) {
this.element = document.createElement('div');
this.element.className = 'dev-stats-overlay';
this.element.setAttribute('aria-hidden', 'true');
parent.append(this.element);
}
public update({
time,
fps,
agentCount,
frameTimeMs,
renderWidth,
renderHeight,
}: DevStatsSnapshot): void {
if (time - this.previousUpdateTime < DEV_STATS_REFRESH_MS) {
return;
}
this.previousUpdateTime = time;
const text = `FPS ${formatFps(fps)}\nAgents ${formatAgentCount(agentCount)}\nFrame ${formatFrameTime(frameTimeMs)}\nResolution ${formatResolution(renderWidth, renderHeight)}`;
if (text !== this.previousText) {
this.element.textContent = text;
this.previousText = text;
}
}
public destroy(): void {
this.element.remove();
}
}
const formatFps = (fps: number): string =>
Number.isFinite(fps) ? Math.max(0, Math.round(fps)).toString() : ZERO_STAT_TEXT;
const formatAgentCount = (agentCount: number): string =>
Number.isFinite(agentCount)
? Math.max(0, Math.round(agentCount)).toLocaleString('en-US')
: ZERO_STAT_TEXT;
const formatFrameTime = (frameTimeMs: number): string =>
Number.isFinite(frameTimeMs)
? `${Math.max(0, frameTimeMs).toFixed(frameTimeMs < 10 ? 1 : 0)}ms`
: ZERO_FRAME_TIME_TEXT;
const formatResolution = (width: number, height: number): string =>
Number.isFinite(width) && Number.isFinite(height)
? `${Math.max(0, Math.round(width))}x${Math.max(0, Math.round(height))}`
: ZERO_RESOLUTION_TEXT;

View file

@ -0,0 +1,53 @@
const MEGAPIXEL = 1_000_000;
export interface InternalRenderSizeOptions {
clientHeight: number;
clientWidth: number;
maxPixelScale: number;
maxTextureDimension: number;
targetAreaMegapixels: number;
}
export interface InternalRenderSize {
height: number;
width: number;
}
const getSafeInternalRenderAreaMegapixels = (targetAreaMegapixels: number): number =>
Number.isFinite(targetAreaMegapixels) && targetAreaMegapixels > 0
? targetAreaMegapixels
: 1;
const getSafeMaxPixelScale = (maxPixelScale: number): number =>
Number.isFinite(maxPixelScale) && maxPixelScale > 0
? maxPixelScale
: Number.POSITIVE_INFINITY;
export const getInternalRenderSize = ({
clientHeight,
clientWidth,
maxPixelScale,
maxTextureDimension,
targetAreaMegapixels,
}: InternalRenderSizeOptions): InternalRenderSize => {
const safeClientWidth = Math.max(1, clientWidth);
const safeClientHeight = Math.max(1, clientHeight);
const safeMaxTextureDimension =
Number.isFinite(maxTextureDimension) && maxTextureDimension > 0
? Math.floor(maxTextureDimension)
: Number.POSITIVE_INFINITY;
const targetArea =
getSafeInternalRenderAreaMegapixels(targetAreaMegapixels) * MEGAPIXEL;
const areaScale = Math.sqrt(targetArea / (safeClientWidth * safeClientHeight));
const dimensionScale = Math.min(
areaScale,
getSafeMaxPixelScale(maxPixelScale),
safeMaxTextureDimension / safeClientWidth,
safeMaxTextureDimension / safeClientHeight
);
return {
height: Math.max(1, Math.round(safeClientHeight * dimensionScale)),
width: Math.max(1, Math.round(safeClientWidth * dimensionScale)),
};
};

View file

@ -0,0 +1,34 @@
export const AGENT_WORKGROUP_SIZE = 64;
export const AGENT_MAX_DISPATCH_WORKGROUPS_PER_DIMENSION = 65_535;
export const AGENT_MAX_DISPATCHABLE_COUNT = 0xffffffff;
export 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) {
return [workgroupCount, 1];
}
const workgroupX = Math.min(
Math.ceil(Math.sqrt(workgroupCount)),
AGENT_MAX_DISPATCH_WORKGROUPS_PER_DIMENSION
);
const workgroupY = Math.ceil(workgroupCount / workgroupX);
if (workgroupY > AGENT_MAX_DISPATCH_WORKGROUPS_PER_DIMENSION) {
throw new Error('Agent count exceeds dispatchable workgroup range');
}
return [workgroupX, workgroupY];
};
export const dispatchAgentWorkgroups = (
passEncoder: GPUComputePassEncoder,
agentCount: number
): void => {
const [workgroupX, workgroupY] = getAgentDispatchWorkgroups(agentCount);
passEncoder.dispatchWorkgroups(workgroupX, workgroupY);
};

12
src/utils/hex-to-rgb.ts Normal file
View file

@ -0,0 +1,12 @@
const HEX_COLOR_PATTERN =
/^#?(?<red>[0-9a-f]{2})(?<green>[0-9a-f]{2})(?<blue>[0-9a-f]{2})$/i;
export const hexToRgb = (hex: string): [number, number, number] => {
const match = HEX_COLOR_PATTERN.exec(hex);
if (!match?.groups) {
return [0, 0, 0];
}
const { red, green, blue } = match.groups;
return [parseInt(red, 16) / 255, parseInt(green, 16) / 255, parseInt(blue, 16) / 255];
};

29
src/utils/math.ts Normal file
View file

@ -0,0 +1,29 @@
export const clamp = (value: number, min: number, max: number): number =>
Math.min(max, Math.max(min, value));
export const clamp01 = (value: number): number => clamp(value, 0, 1);
export const mix = (from: number, to: number, amount: number): number =>
from + (to - from) * amount;
export const mixAngle = (from: number, to: number, amount: number): number => {
const delta = Math.atan2(Math.sin(to - from), Math.cos(to - from));
return from + delta * amount;
};
export const approach = (
current: number,
target: number,
elapsedSeconds: number,
timeConstantSeconds: number
): number => {
const amount = 1 - Math.exp(-elapsedSeconds / Math.max(0.001, timeConstantSeconds));
return mix(current, target, amount);
};
export const smoothstep = (edge0: number, edge1: number, value: number): number => {
const amount = clamp01((value - edge0) / (edge1 - edge0));
return amount * amount * (3 - 2 * amount);
};
export const easeOutQuad = (value: number): number => value * (2 - value);