This commit is contained in:
Andras Schmelczer 2026-05-13 21:07:10 +01:00
commit 39b0160064
136 changed files with 7129 additions and 1950 deletions

View file

@ -1,71 +1,40 @@
import { appConfig } from '../config';
import { clamp01 } from '../utils/clamp';
import { GardenAudioConfig } from './garden-audio-config';
interface GardenGestureState {
startedAt: number;
lastAt: number;
distancePixels: number;
peakEnergy: number;
isErasing: boolean;
}
interface GestureTail {
startedAt: number;
durationSeconds: number;
level: number;
}
type GardenAudioRhythmConfig = GardenAudioConfig['rhythm'];
const STROKE_IMMEDIATE_ACTIVITY_SCALE = 0.85;
export class GardenAudioEnergy {
private isGestureActive = false;
private energy = 0;
private targetEnergy = 0;
private lastEnergyUpdateAt = 0;
private currentGesture: GardenGestureState | null = null;
private gestureTails: Array<GestureTail> = [];
public constructor(private readonly rhythm: GardenAudioRhythmConfig) {}
public beginGesture(now: number): void {
this.isGestureActive = true;
this.currentGesture = createGesture(now, false);
this.lastEnergyUpdateAt = now;
}
public endGesture(now: number): void {
if (this.currentGesture && !this.currentGesture.isErasing) {
this.addGestureTail(this.currentGesture, now);
}
public endGesture(): void {
this.isGestureActive = false;
this.currentGesture = null;
this.targetEnergy = 0;
}
public recordStroke(distancePixels: number, strokeEnergy: number, now: number): void {
if (!this.currentGesture || this.currentGesture.isErasing) {
this.currentGesture = createGesture(now, false);
public recordStroke(strokeEnergy: number, now: number): void {
const energy = clamp01(strokeEnergy);
this.targetEnergy = Math.max(this.targetEnergy, energy);
if (this.isGestureActive) {
this.energy = Math.max(this.energy, energy * STROKE_IMMEDIATE_ACTIVITY_SCALE);
}
this.currentGesture.lastAt = now;
this.currentGesture.distancePixels += distancePixels;
this.currentGesture.peakEnergy = Math.max(
this.currentGesture.peakEnergy,
strokeEnergy
);
this.lastEnergyUpdateAt ||= now;
}
public recordEraserStroke(now: number): void {
this.currentGesture = this.currentGesture ?? createGesture(now, true);
this.currentGesture.isErasing = true;
}
public raiseTarget(strokeEnergy: number): void {
this.targetEnergy = Math.max(this.targetEnergy, strokeEnergy);
public recordEraserStroke(): void {
this.targetEnergy = 0;
}
public silence(): void {
this.targetEnergy = 0;
this.gestureTails = [];
this.energy = 0;
}
public update(now: number): void {
@ -76,28 +45,31 @@ export class GardenAudioEnergy {
const elapsedSeconds = Math.max(0, now - this.lastEnergyUpdateAt);
this.lastEnergyUpdateAt = now;
this.targetEnergy *= Math.exp(-elapsedSeconds / 0.75);
this.trimGestureTails(now);
this.targetEnergy *= Math.exp(
-elapsedSeconds / appConfig.audioEngine.energy.strokeDecaySeconds
);
const activeGestureFloor =
this.isGestureActive && this.currentGesture && !this.currentGesture.isErasing
? 0.04 + this.getGestureAmount(this.currentGesture, now) * 0.3
: 0;
const target = Math.max(activeGestureFloor, this.targetEnergy);
const timeConstant = target > this.energy ? 0.08 : 0.55;
const target = this.isGestureActive ? this.targetEnergy : 0;
let timeConstant = appConfig.audioEngine.energy.decaySeconds;
if (!this.isGestureActive) {
timeConstant = appConfig.audioEngine.energy.releaseSeconds;
} else if (target > this.energy) {
timeConstant = appConfig.audioEngine.energy.attackSeconds;
}
const amount = 1 - Math.exp(-elapsedSeconds / timeConstant);
this.energy += (target - this.energy) * amount;
}
public getActivityAt(time: number): number {
const activeGesture =
this.isGestureActive && this.currentGesture && !this.currentGesture.isErasing
? 0.08 + this.getGestureAmount(this.currentGesture, time) * 0.34
: 0;
public getActivity(): number {
if (!this.isGestureActive) {
return 0;
}
return clamp01(
this.energy * 0.58 + this.getTailActivityAt(time) * 0.72 + activeGesture
);
return this.getLevel();
}
public getLevel(): number {
return clamp01(this.energy);
}
public reset(): void {
@ -105,72 +77,5 @@ export class GardenAudioEnergy {
this.energy = 0;
this.targetEnergy = 0;
this.lastEnergyUpdateAt = 0;
this.currentGesture = null;
this.gestureTails = [];
}
private addGestureTail(gesture: GardenGestureState, now: number): void {
const durationAmount = clamp01(
(gesture.lastAt - gesture.startedAt) / this.rhythm.tailDurationForMaxSeconds
);
const distanceAmount = clamp01(
gesture.distancePixels / this.rhythm.tailDistanceForMaxPixels
);
const gestureAmount = Math.max(distanceAmount, durationAmount * 0.9);
const tailAmount = clamp01(gestureAmount * (0.74 + gesture.peakEnergy * 0.26));
if (tailAmount <= 0.015) {
return;
}
this.gestureTails.push({
startedAt: now,
durationSeconds:
this.rhythm.minTailSeconds +
(this.rhythm.maxTailSeconds - this.rhythm.minTailSeconds) * tailAmount,
level: (0.05 + tailAmount * 0.5) * (0.72 + gesture.peakEnergy * 0.28),
});
this.trimGestureTails(now);
}
private getGestureAmount(gesture: GardenGestureState, now: number): number {
const durationAmount = clamp01(
(now - gesture.startedAt) / this.rhythm.tailDurationForMaxSeconds
);
const distanceAmount = clamp01(
gesture.distancePixels / this.rhythm.tailDistanceForMaxPixels
);
return clamp01(
distanceAmount * 0.62 + durationAmount * 0.24 + gesture.peakEnergy * 0.14
);
}
private getTailActivityAt(time: number): number {
const decayPower = Math.max(0.2, this.rhythm.tailDecayPower);
return this.gestureTails.reduce((activity, tail) => {
const elapsedSeconds = time - tail.startedAt;
if (elapsedSeconds < 0 || elapsedSeconds >= tail.durationSeconds) {
return activity;
}
const remaining = clamp01(1 - elapsedSeconds / tail.durationSeconds);
return Math.max(activity, tail.level * Math.pow(remaining, decayPower));
}, 0);
}
private trimGestureTails(now: number): void {
this.gestureTails = this.gestureTails.filter(
(tail) => now - tail.startedAt < tail.durationSeconds
);
}
}
const createGesture = (now: number, isErasing: boolean): GardenGestureState => ({
startedAt: now,
lastAt: now,
distancePixels: 0,
peakEnergy: 0,
isErasing,
});