This commit is contained in:
Andras Schmelczer 2026-05-18 08:11:52 +01:00
commit 15e99380b5
84 changed files with 1659 additions and 4726 deletions

View file

@ -1,7 +1,7 @@
import { clamp01 } from '../utils/clamp';
import { ErrorHandler, Severity } from '../utils/error-handler';
import { clamp01 } from '../utils/math';
import type { VibeId, VibePreset } from '../vibes';
import { GardenAudioConfig } from './garden-audio-config';
import type { GardenAudioConfig } from './garden-audio-config';
import { GardenAudioEnergy } from './garden-audio-energy';
import { GardenAudioGestureState } from './garden-audio-gesture-state';
import { GardenAudioGraph } from './garden-audio-graph';
@ -24,6 +24,12 @@ export type {
type AudioLifecycle = 'idle' | 'started' | 'destroyed';
const muteGain = 0.0001;
const muteRampSeconds = 0.02;
const brushUpPianoFinishSeconds = 1.2;
const brushUpPianoFadeSeconds = 1.1;
const vibeChangeStingerMinIntervalSeconds = 0.45;
export class GardenAudio {
private readonly graph: GardenAudioGraph;
private readonly piano: PianoSampler;
@ -36,7 +42,10 @@ export class GardenAudio {
private lifecycle: AudioLifecycle = 'idle';
private isMuted = false;
private isGestureActive = false;
private isPianoStoppedAfterGesture = false;
private fadePianoAfter: number | null = null;
private masterVolume: number;
private stopPianoAfter: number | null = null;
private lastEraserAt = Number.NEGATIVE_INFINITY;
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
@ -44,50 +53,55 @@ export class GardenAudio {
this.masterVolume = clamp01(config.masterVolume);
this.graph = new GardenAudioGraph(config);
this.piano = new PianoSampler(config, this.graph);
this.noise = new NoiseBurstPlayer(config, this.graph);
this.noise = new NoiseBurstPlayer(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 {
if (this.lifecycle === 'destroyed' || this.isMuted) {
const isUserGesture = options.userGesture === true;
if (this.lifecycle === 'destroyed') {
return;
}
const context = this.graph.ensureContext(options.userGesture === true);
const context = this.graph.ensureContext(isUserGesture);
if (!context) {
return;
}
const startupRampSeconds =
options.userGesture === true
? this.config.muteRampSeconds
: this.config.fadeInSeconds;
const startupRampSeconds = isUserGesture
? muteRampSeconds
: this.config.fadeInSeconds;
const needsResume = context.state !== 'running' && context.state !== 'closed';
let resumePromise: Promise<void> | null = null;
if (isUserGesture) {
this.graph.startMediaElementOutput();
this.graph.unlock();
}
if (needsResume) {
if (options.userGesture !== true) {
if (!isUserGesture) {
return;
}
resumePromise = context.resume();
}
if (options.userGesture === true) {
if (isUserGesture) {
this.graph.unlock();
}
if (resumePromise) {
void resumePromise
.then(() => {
if (
this.graph.context === context &&
this.lifecycle !== 'destroyed' &&
!this.isMuted
) {
if (this.graph.context === context && this.lifecycle !== 'destroyed') {
this.graph.unlock();
this.graph.setMasterGain(this.masterVolume, startupRampSeconds);
this.completeStart(vibe, {
context,
startupRampSeconds,
});
}
})
.catch((error) => {
@ -96,6 +110,32 @@ export class GardenAudio {
severity: Severity.WARNING,
});
});
return;
}
this.completeStart(vibe, {
context,
startupRampSeconds,
});
}
private completeStart(
vibe: VibePreset,
{
context,
startupRampSeconds,
}: {
context: AudioContext;
startupRampSeconds: number;
}
): void {
if (this.graph.context !== context || this.lifecycle === 'destroyed') {
return;
}
if (this.isMuted) {
this.graph.setMasterGain(muteGain, muteRampSeconds);
return;
}
this.lifecycle = 'started';
@ -111,7 +151,12 @@ export class GardenAudio {
this.pianoEngine.cue(context.currentTime);
}
})
.catch(() => undefined);
.catch((error) => {
ErrorHandler.addException(error, {
fallbackMessage: 'Could not load piano samples. Using synthesized audio.',
severity: Severity.WARNING,
});
});
}
}
@ -143,8 +188,8 @@ export class GardenAudio {
this.isMuted = isMuted;
this.graph.setMasterGain(
isMuted ? this.config.muteGain : this.masterVolume,
isMuted ? this.config.muteRampSeconds : this.config.fadeInSeconds
isMuted ? muteGain : this.masterVolume,
isMuted ? muteRampSeconds : this.config.fadeInSeconds
);
}
@ -162,6 +207,9 @@ export class GardenAudio {
}
this.isGestureActive = true;
this.isPianoStoppedAfterGesture = false;
this.fadePianoAfter = null;
this.stopPianoAfter = null;
this.gestureState.beginGesture();
this.energy.beginGesture(context.currentTime);
this.pianoEngine.beginGesture();
@ -170,6 +218,12 @@ export class GardenAudio {
public endGesture(): void {
this.gestureState.endGesture();
this.isGestureActive = false;
const context = this.graph.context;
this.isPianoStoppedAfterGesture = true;
this.fadePianoAfter = context
? context.currentTime + brushUpPianoFinishSeconds
: null;
this.stopPianoAfter = null;
this.energy.endGesture();
this.pianoEngine.endGesture();
}
@ -187,6 +241,21 @@ export class GardenAudio {
this.energy.silence();
}
if (!this.isGestureActive && this.isPianoStoppedAfterGesture) {
if (this.fadePianoAfter !== null && context.currentTime >= this.fadePianoAfter) {
this.piano.fadeAll(brushUpPianoFadeSeconds);
this.fadePianoAfter = null;
this.stopPianoAfter = context.currentTime + brushUpPianoFadeSeconds;
}
if (this.stopPianoAfter !== null && context.currentTime >= this.stopPianoAfter) {
this.piano.stopAll();
this.pianoEngine.reset();
this.stopPianoAfter = null;
}
this.updateDelay(snapshot);
return;
}
this.pianoEngine.renderLookahead({
vibe: snapshot.vibe,
now: context.currentTime,
@ -211,7 +280,7 @@ export class GardenAudio {
return;
}
const metrics = getStrokeMetrics(stroke, this.config.input);
const metrics = getStrokeMetrics(stroke);
const now = context.currentTime;
const frame = this.gestureState.recordStroke({ metrics });
@ -242,6 +311,9 @@ export class GardenAudio {
this.pianoEngine.reset();
this.currentVibeId = null;
this.isGestureActive = false;
this.isPianoStoppedAfterGesture = false;
this.fadePianoAfter = null;
this.stopPianoAfter = null;
this.lastEraserAt = Number.NEGATIVE_INFINITY;
this.lastVibeStingerAt = Number.NEGATIVE_INFINITY;
}
@ -253,7 +325,7 @@ export class GardenAudio {
}
const now = context.currentTime;
if (now - this.lastVibeStingerAt < this.config.vibeChangeStingerMinIntervalSeconds) {
if (now - this.lastVibeStingerAt < vibeChangeStingerMinIntervalSeconds) {
return;
}