v good
Some checks failed
Deploy to Pages / build (pull_request) Failing after 1m56s

This commit is contained in:
Andras Schmelczer 2026-05-16 13:46:19 +01:00
commit 10a81ba474
45 changed files with 1974 additions and 490 deletions

View file

@ -1,10 +1,11 @@
import { appConfig } from '../config';
import type { GardenAudioEngineConfig } from '../config';
import { clamp, clamp01 } from '../utils/clamp';
import { VibePreset } from '../vibes';
import { GardenAudioConfig } from './garden-audio-config';
import { GardenAudioEnergy } from './garden-audio-energy';
import { GardenAudioGestureState } from './garden-audio-gesture-state';
import { GardenAudioGraph } from './garden-audio-graph';
import { GardenAudioStrokeMetrics, getStrokeMetrics } from './garden-audio-input';
import { getStrokeMetrics } from './garden-audio-input';
import { getVibeProfile, normalizeColorIndex } from './garden-audio-music';
import type {
GardenAudioColorIndex,
@ -29,6 +30,7 @@ export class GardenAudio {
private readonly piano: PianoSampler;
private readonly noise: NoiseBurstPlayer;
private readonly energy: GardenAudioEnergy;
private readonly gestureState: GardenAudioGestureState;
private readonly pianoEngine: GenerativePianoEngine;
private currentVibeId: string | null = null;
@ -41,12 +43,22 @@ export class GardenAudio {
private lastEraserAt = Number.NEGATIVE_INFINITY;
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
public constructor(private readonly config: GardenAudioConfig) {
this.graph = new GardenAudioGraph(config);
this.piano = new PianoSampler(config, this.graph);
this.noise = new NoiseBurstPlayer(this.graph);
this.energy = new GardenAudioEnergy();
this.pianoEngine = new GenerativePianoEngine(config, (note) => this.piano.play(note));
public constructor(
private readonly config: GardenAudioConfig,
private readonly engineConfig: GardenAudioEngineConfig,
private readonly maxMirrorSegmentCount: number
) {
this.graph = new GardenAudioGraph(config, engineConfig);
this.piano = new PianoSampler(config, engineConfig, this.graph);
this.noise = new NoiseBurstPlayer(engineConfig, this.graph);
this.energy = new GardenAudioEnergy(engineConfig);
this.gestureState = new GardenAudioGestureState(
config.rhythm.speedForFullEnergyPixelsPerSecond,
engineConfig.input
);
this.pianoEngine = new GenerativePianoEngine(config, engineConfig, (note) =>
this.piano.play(note)
);
}
public start(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
@ -76,7 +88,7 @@ export class GardenAudio {
this.graph.setMasterGain(
this.config.masterVolume,
options.userGesture === true
? appConfig.audioEngine.muteRampSeconds
? this.engineConfig.muteRampSeconds
: this.config.fadeInSeconds
);
@ -110,8 +122,8 @@ export class GardenAudio {
public setMuted(isMuted: boolean): void {
this.isMuted = isMuted;
this.graph.setMasterGain(
isMuted ? appConfig.audioEngine.muteGain : this.config.masterVolume,
isMuted ? appConfig.audioEngine.muteRampSeconds : this.config.fadeInSeconds
isMuted ? this.engineConfig.muteGain : this.config.masterVolume,
isMuted ? this.engineConfig.muteRampSeconds : this.config.fadeInSeconds
);
}
@ -122,11 +134,13 @@ export class GardenAudio {
}
this.isGestureActive = true;
this.gestureState.beginGesture();
this.energy.beginGesture(context.currentTime);
this.pianoEngine.beginGesture();
}
public endGesture(): void {
this.gestureState.endGesture();
this.isGestureActive = false;
this.energy.endGesture();
this.pianoEngine.endGesture();
@ -146,6 +160,13 @@ export class GardenAudio {
const mirrorAmount = this.getMirrorAmount(touch.mirrorSegmentCount ?? 1);
const pressure = this.getTouchPressure(touch.pressure, touch.pointerType);
const strength = clamp01(0.36 + pressure * 0.34 + mirrorAmount * 0.22);
const frame = this.gestureState.recordTouchDown({
touch,
colorIndex: this.selectedColorIndex,
mirrorAmount,
pressure,
strength,
});
this.energy.recordStroke(strength, context.currentTime);
this.pianoEngine.recordTouchDown({
@ -154,6 +175,13 @@ export class GardenAudio {
strength,
selectedColorIndex: this.selectedColorIndex,
mirrorAmount,
panBias: frame.panBias,
registerBias: frame.registerBias,
brightnessBias: frame.brightnessBias,
contour: frame.contour,
pressureAmount: frame.pressure,
pressureDelta: frame.pressureDelta,
maniaAmount: frame.maniaAmount,
});
}
@ -197,7 +225,8 @@ export class GardenAudio {
const metrics = getStrokeMetrics(
stroke,
this.config.rhythm.speedForFullEnergyPixelsPerSecond,
this.config.input.pressureFallback
this.config.input.pressureFallback,
this.engineConfig.input
);
const now = context.currentTime;
@ -210,7 +239,8 @@ export class GardenAudio {
}
const mirrorAmount = this.getMirrorAmount(stroke.mirrorSegmentCount ?? 1);
const strokeEnergy = this.getStrokeMusicActivity(stroke, metrics, mirrorAmount);
const frame = this.gestureState.recordStroke({ stroke, metrics, mirrorAmount });
const strokeEnergy = frame.activity;
this.energy.recordStroke(strokeEnergy, now);
this.pianoEngine.recordStroke({
vibe: stroke.vibe,
@ -218,6 +248,13 @@ export class GardenAudio {
activity: strokeEnergy,
selectedColorIndex: this.selectedColorIndex,
mirrorAmount,
panBias: frame.panBias,
registerBias: frame.registerBias,
brightnessBias: frame.brightnessBias,
contour: frame.contour,
pressureAmount: frame.pressure,
pressureDelta: frame.pressureDelta,
maniaAmount: frame.maniaAmount,
});
}
@ -227,6 +264,7 @@ export class GardenAudio {
this.piano.reset();
this.energy.reset();
this.gestureState.reset();
this.pianoEngine.reset();
this.currentVibeId = null;
this.hasStarted = false;
@ -246,7 +284,7 @@ export class GardenAudio {
const now = context.currentTime;
if (
now - this.lastVibeStingerAt <
appConfig.audioEngine.vibeChangeStingerMinIntervalSeconds
this.engineConfig.vibeChangeStingerMinIntervalSeconds
) {
return;
}
@ -266,10 +304,10 @@ export class GardenAudio {
}
const sizeAmount = clamp01(
(stroke.eraserSizePixels ?? appConfig.audioEngine.eraser.defaultSizePixels) /
(stroke.eraserSizePixels ?? this.engineConfig.eraser.defaultSizePixels) /
Math.max(
1,
stroke.canvasSize[0] * appConfig.audioEngine.eraser.canvasWidthRatioForFullSize
stroke.canvasSize[0] * this.engineConfig.eraser.canvasWidthRatioForFullSize
)
);
const x = clamp01(stroke.to[0] / Math.max(1, stroke.canvasSize[0]));
@ -277,22 +315,22 @@ export class GardenAudio {
this.config.eraser.filterMinHz +
(this.config.eraser.filterMaxHz - this.config.eraser.filterMinHz) *
clamp01(
speedAmount * appConfig.audioEngine.eraser.filterSpeedWeight +
pressure * appConfig.audioEngine.eraser.filterPressureWeight +
sizeAmount * appConfig.audioEngine.eraser.filterSizeWeight
speedAmount * this.engineConfig.eraser.filterSpeedWeight +
pressure * this.engineConfig.eraser.filterPressureWeight +
sizeAmount * this.engineConfig.eraser.filterSizeWeight
);
if (now - this.lastEraserAt >= this.config.eraser.minIntervalSeconds) {
this.lastEraserAt = now;
this.noise.play({
startTime: now,
durationSeconds: appConfig.audioEngine.eraser.durationSeconds,
durationSeconds: this.engineConfig.eraser.durationSeconds,
gain:
this.config.eraser.noiseGain *
(appConfig.audioEngine.eraser.gainBase +
speedAmount * appConfig.audioEngine.eraser.gainSpeedWeight +
pressure * appConfig.audioEngine.eraser.gainPressureWeight +
sizeAmount * appConfig.audioEngine.eraser.gainSizeWeight),
(this.engineConfig.eraser.gainBase +
speedAmount * this.engineConfig.eraser.gainSpeedWeight +
pressure * this.engineConfig.eraser.gainPressureWeight +
sizeAmount * this.engineConfig.eraser.gainSizeWeight),
filterHz,
pan: clamp(x * 2 - 1, -1, 1),
});
@ -307,7 +345,7 @@ export class GardenAudio {
const profile = getVibeProfile(this.config, snapshot.vibe);
const activity = snapshot.isErasing
? appConfig.audioEngine.delay.erasingActivity
? this.engineConfig.delay.erasingActivity
: this.energy.getLevel();
this.graph.updateDelay(profile, activity);
}
@ -323,7 +361,7 @@ export class GardenAudio {
}
private getMirrorAmount(mirrorSegmentCount: number): number {
const maxMirrorSegmentCount = Math.max(1, appConfig.simulation.maxMirrorSegmentCount);
const maxMirrorSegmentCount = Math.max(1, this.maxMirrorSegmentCount);
const segmentCount = clamp(
Number.isFinite(mirrorSegmentCount) ? mirrorSegmentCount : 1,
1,
@ -337,44 +375,16 @@ export class GardenAudio {
return clamp01((segmentCount - 1) / (maxMirrorSegmentCount - 1));
}
private getStrokeMusicActivity(
stroke: GardenAudioStroke,
metrics: GardenAudioStrokeMetrics,
mirrorAmount: number
): number {
const speedRatio =
(stroke.velocityPixelsPerSecond ?? 0) /
Math.max(1, this.config.rhythm.speedForFullEnergyPixelsPerSecond);
const speedDrive = smoothstep(0.35, 1.1, speedRatio);
const speedOverdrive = smoothstep(1.15, 1.8, speedRatio);
const distanceDrive = smoothstep(10, 90, metrics.distancePixels);
const baseStroke = clamp01(
0.08 + speedDrive * 0.5 + metrics.pressure * 0.2 + distanceDrive * 0.22
);
const mirrorWild = smoothstep(0.45, 0.9, mirrorAmount);
const maniaDrive = speedOverdrive * smoothstep(0.62, 0.82, baseStroke);
const maniaBoost = maniaDrive * (0.18 + mirrorWild * 0.62);
return clamp01(
baseStroke * (0.68 + mirrorAmount * 0.3) +
0.025 +
mirrorAmount * 0.045 +
maniaBoost
);
}
private getTouchPressure(pressure: number | undefined, pointerType?: string): number {
if (pressure !== undefined && Number.isFinite(pressure) && pressure > 0) {
return clamp01(pressure);
}
return pointerType === 'pen'
? Math.max(appConfig.audioEngine.input.penMinPressure, this.config.input.pressureFallback)
? Math.max(
this.engineConfig.input.penMinPressure,
this.config.input.pressureFallback
)
: this.config.input.pressureFallback;
}
}
const smoothstep = (edge0: number, edge1: number, value: number): number => {
const amount = clamp01((value - edge0) / (edge1 - edge0));
return amount * amount * (3 - 2 * amount);
};