This commit is contained in:
Andras Schmelczer 2026-05-16 16:15:54 +01:00
commit d2da0d1617
25 changed files with 521 additions and 1026 deletions

View file

@ -1,5 +1,4 @@
import type { GardenAudioEngineConfig } from '../config';
import { clamp, clamp01 } from '../utils/clamp';
import { clamp01 } from '../utils/clamp';
import { ErrorHandler, Severity } from '../utils/error-handler';
import { VibePreset } from '../vibes';
import { GardenAudioConfig } from './garden-audio-config';
@ -44,22 +43,13 @@ export class GardenAudio {
private lastEraserAt = Number.NEGATIVE_INFINITY;
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
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 constructor(private readonly config: GardenAudioConfig) {
this.graph = new GardenAudioGraph(config);
this.piano = new PianoSampler(config, this.graph);
this.noise = new NoiseBurstPlayer(config, this.graph);
this.energy = new GardenAudioEnergy(config);
this.gestureState = new GardenAudioGestureState(config.input);
this.pianoEngine = new GenerativePianoEngine(config, (note) => this.piano.play(note));
}
public start(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
@ -74,7 +64,7 @@ export class GardenAudio {
const startupRampSeconds =
options.userGesture === true
? this.engineConfig.muteRampSeconds
? this.config.muteRampSeconds
: this.config.fadeInSeconds;
const needsResume = context.state !== 'running' && context.state !== 'closed';
let resumePromise: Promise<void> | null = null;
@ -148,8 +138,8 @@ export class GardenAudio {
public setMuted(isMuted: boolean): void {
this.isMuted = isMuted;
this.graph.setMasterGain(
isMuted ? this.engineConfig.muteGain : this.config.masterVolume,
isMuted ? this.engineConfig.muteRampSeconds : this.config.fadeInSeconds
isMuted ? this.config.muteGain : this.config.masterVolume,
isMuted ? this.config.muteRampSeconds : this.config.fadeInSeconds
);
}
@ -183,32 +173,7 @@ export class GardenAudio {
}
this.selectedColorIndex = normalizeColorIndex(touch.colorIndex);
const mirrorAmount = this.getMirrorAmount(touch.mirrorSegmentCount ?? 1);
const pressure = this.getPressure(touch.pressure);
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({
vibe: touch.vibe,
now: context.currentTime,
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,
});
this.gestureState.recordTouchDown();
}
public update(snapshot: GardenAudioSnapshot): void {
@ -248,37 +213,25 @@ export class GardenAudio {
return;
}
const metrics = getStrokeMetrics(
stroke,
this.config.rhythm.speedForFullEnergyPixelsPerSecond,
this.engineConfig.input
);
const metrics = getStrokeMetrics(stroke, this.config.input);
const now = context.currentTime;
this.selectedColorIndex = normalizeColorIndex(stroke.colorIndex);
const frame = this.gestureState.recordStroke({ metrics });
const strokeEnergy = frame.activity;
if (stroke.isErasing) {
this.energy.recordEraserStroke();
this.playEraser(stroke, metrics.speedAmount, metrics.pressure, now);
this.playEraser(strokeEnergy, now);
return;
}
const mirrorAmount = this.getMirrorAmount(stroke.mirrorSegmentCount ?? 1);
const frame = this.gestureState.recordStroke({ stroke, metrics, mirrorAmount });
const strokeEnergy = frame.activity;
this.energy.recordStroke(strokeEnergy, now);
this.pianoEngine.recordStroke({
vibe: stroke.vibe,
now,
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,
});
}
@ -307,10 +260,7 @@ export class GardenAudio {
}
const now = context.currentTime;
if (
now - this.lastVibeStingerAt <
this.engineConfig.vibeChangeStingerMinIntervalSeconds
) {
if (now - this.lastVibeStingerAt < this.config.vibeChangeStingerMinIntervalSeconds) {
return;
}
@ -318,46 +268,29 @@ export class GardenAudio {
this.pianoEngine.playVibeChangeStinger(vibe, now);
}
private playEraser(
stroke: GardenAudioStroke,
speedAmount: number,
pressure: number,
now: number
): void {
private playEraser(activity: number, now: number): void {
if (!this.graph.context) {
return;
}
const sizeAmount = clamp01(
(stroke.eraserSizePixels ?? this.engineConfig.eraser.defaultSizePixels) /
Math.max(
1,
stroke.canvasSize[0] * this.engineConfig.eraser.canvasWidthRatioForFullSize
)
);
const x = clamp01(stroke.to[0] / Math.max(1, stroke.canvasSize[0]));
const distanceActivity = clamp01(activity);
if (distanceActivity <= 0) {
return;
}
const filterHz =
this.config.eraser.filterMinHz +
(this.config.eraser.filterMaxHz - this.config.eraser.filterMinHz) *
clamp01(
speedAmount * this.engineConfig.eraser.filterSpeedWeight +
pressure * this.engineConfig.eraser.filterPressureWeight +
sizeAmount * this.engineConfig.eraser.filterSizeWeight
);
distanceActivity;
if (now - this.lastEraserAt >= this.config.eraser.minIntervalSeconds) {
this.lastEraserAt = now;
this.noise.play({
startTime: now,
durationSeconds: this.engineConfig.eraser.durationSeconds,
gain:
this.config.eraser.noiseGain *
(this.engineConfig.eraser.gainBase +
speedAmount * this.engineConfig.eraser.gainSpeedWeight +
pressure * this.engineConfig.eraser.gainPressureWeight +
sizeAmount * this.engineConfig.eraser.gainSizeWeight),
durationSeconds: this.config.eraser.durationSeconds,
gain: this.config.eraser.noiseGain * distanceActivity,
filterHz,
pan: clamp(x * 2 - 1, -1, 1),
pan: 0,
});
}
}
@ -370,7 +303,7 @@ export class GardenAudio {
const profile = getVibeProfile(this.config, snapshot.vibe);
const activity = snapshot.isErasing
? this.engineConfig.delay.erasingActivity
? this.config.delay.erasingActivity
: this.energy.getLevel();
this.graph.updateDelay(profile, activity);
}
@ -384,27 +317,4 @@ export class GardenAudio {
this.graph.applyDelayProfile(getVibeProfile(this.config, vibe));
this.pianoEngine.cue(this.graph.context.currentTime);
}
private getMirrorAmount(mirrorSegmentCount: number): number {
const maxMirrorSegmentCount = Math.max(1, this.maxMirrorSegmentCount);
const segmentCount = clamp(
Number.isFinite(mirrorSegmentCount) ? mirrorSegmentCount : 1,
1,
maxMirrorSegmentCount
);
if (maxMirrorSegmentCount <= 1) {
return 0;
}
return clamp01((segmentCount - 1) / (maxMirrorSegmentCount - 1));
}
private getPressure(pressure: number | undefined): number {
if (pressure !== undefined && Number.isFinite(pressure) && pressure > 0) {
return clamp01(pressure);
}
return 0;
}
}