fleeting-garden/src/audio/garden-audio.ts
2026-05-18 08:11:52 +01:00

385 lines
11 KiB
TypeScript

import { ErrorHandler, Severity } from '../utils/error-handler';
import { clamp01 } from '../utils/math';
import type { VibeId, VibePreset } from '../vibes';
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';
import { getStrokeMetrics } from './garden-audio-input';
import { getVibeProfile } from './garden-audio-music';
import type {
GardenAudioSnapshot,
GardenAudioStartOptions,
GardenAudioStroke,
} from './garden-audio-types';
import { GenerativePianoEngine } from './generative-piano';
import { NoiseBurstPlayer } from './noise-burst-player';
import { PianoSampler } from './piano-sampler';
export type {
GardenAudioSnapshot,
GardenAudioStartOptions,
GardenAudioStroke,
} from './garden-audio-types';
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;
private readonly noise: NoiseBurstPlayer;
private readonly energy: GardenAudioEnergy;
private readonly gestureState: GardenAudioGestureState;
private readonly pianoEngine: GenerativePianoEngine;
private currentVibeId: VibeId | null = null;
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;
public constructor(private readonly config: GardenAudioConfig) {
this.masterVolume = clamp01(config.masterVolume);
this.graph = new GardenAudioGraph(config);
this.piano = new PianoSampler(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 {
const isUserGesture = options.userGesture === true;
if (this.lifecycle === 'destroyed') {
return;
}
const context = this.graph.ensureContext(isUserGesture);
if (!context) {
return;
}
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 (!isUserGesture) {
return;
}
resumePromise = context.resume();
}
if (isUserGesture) {
this.graph.unlock();
}
if (resumePromise) {
void resumePromise
.then(() => {
if (this.graph.context === context && this.lifecycle !== 'destroyed') {
this.graph.unlock();
this.completeStart(vibe, {
context,
startupRampSeconds,
});
}
})
.catch((error) => {
ErrorHandler.addException(error, {
fallbackMessage: 'Could not resume audio playback.',
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';
this.applyVibe(vibe);
this.pianoEngine.prime(context.currentTime);
this.graph.setMasterGain(this.masterVolume, startupRampSeconds);
const pianoLoad = this.piano.loadIfIdle(context);
if (pianoLoad) {
void pianoLoad
.then(() => {
if (this.graph.context === context && this.lifecycle !== 'destroyed') {
this.pianoEngine.cue(context.currentTime);
}
})
.catch((error) => {
ErrorHandler.addException(error, {
fallbackMessage: 'Could not load piano samples. Using synthesized audio.',
severity: Severity.WARNING,
});
});
}
}
public changeVibe(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
const previousVibeId = this.currentVibeId;
this.start(vibe, options);
const didChangeVibe = previousVibeId !== null && previousVibeId !== vibe.id;
if (didChangeVibe) {
this.piano.stopAll();
}
const context = this.graph.context;
if (
context &&
(context.state === 'running' || options.userGesture === true) &&
!this.isMuted &&
this.lifecycle !== 'destroyed' &&
didChangeVibe
) {
this.playVibeChangeStinger(vibe);
}
}
public setMuted(isMuted: boolean): void {
if (this.isMuted === isMuted) {
return;
}
this.isMuted = isMuted;
this.graph.setMasterGain(
isMuted ? muteGain : this.masterVolume,
isMuted ? muteRampSeconds : this.config.fadeInSeconds
);
}
public setMasterVolume(masterVolume: number): void {
this.masterVolume = clamp01(masterVolume);
if (!this.isMuted) {
this.graph.setMasterGain(this.masterVolume, this.config.updateRampSeconds);
}
}
public beginGesture(): void {
const context = this.graph.context;
if (!context) {
return;
}
this.isGestureActive = true;
this.isPianoStoppedAfterGesture = false;
this.fadePianoAfter = null;
this.stopPianoAfter = null;
this.gestureState.beginGesture();
this.energy.beginGesture(context.currentTime);
this.pianoEngine.beginGesture();
}
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();
}
public update(snapshot: GardenAudioSnapshot): void {
const context = this.graph.context;
if (this.lifecycle !== 'started' || !context || this.isMuted) {
return;
}
this.applyVibe(snapshot.vibe);
this.energy.update(context.currentTime);
if (snapshot.isErasing) {
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,
activity: snapshot.isErasing
? this.config.eraser.pianoActivity
: this.energy.getLevel(),
});
this.updateDelay(snapshot);
}
public stroke(stroke: GardenAudioStroke): void {
if (this.lifecycle === 'destroyed' || this.isMuted) {
return;
}
this.start(stroke.vibe);
const context = this.graph.context;
if (!context) {
return;
}
if (!this.isGestureActive) {
return;
}
const metrics = getStrokeMetrics(stroke);
const now = context.currentTime;
const frame = this.gestureState.recordStroke({ metrics });
const strokeEnergy = frame.activity;
if (stroke.isErasing) {
this.energy.recordEraserStroke();
this.playEraser(strokeEnergy, now);
return;
}
this.energy.recordStroke(strokeEnergy, now);
this.pianoEngine.recordStroke({
vibe: stroke.vibe,
now,
activity: strokeEnergy,
maniaAmount: frame.maniaAmount,
});
}
public async destroy(): Promise<void> {
this.lifecycle = 'destroyed';
await this.graph.close();
this.piano.reset();
this.energy.reset();
this.gestureState.reset();
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;
}
private playVibeChangeStinger(vibe: VibePreset): void {
const context = this.graph.context;
if (!context) {
return;
}
const now = context.currentTime;
if (now - this.lastVibeStingerAt < vibeChangeStingerMinIntervalSeconds) {
return;
}
this.lastVibeStingerAt = now;
this.pianoEngine.playVibeChangeStinger(vibe, now);
}
private playEraser(activity: number, now: number): void {
if (!this.graph.context) {
return;
}
const distanceActivity = clamp01(activity);
if (distanceActivity <= 0) {
return;
}
const filterHz =
this.config.eraser.filterMinHz +
(this.config.eraser.filterMaxHz - this.config.eraser.filterMinHz) *
distanceActivity;
if (now - this.lastEraserAt >= this.config.eraser.minIntervalSeconds) {
this.lastEraserAt = now;
this.noise.play({
startTime: now,
durationSeconds: this.config.eraser.durationSeconds,
gain: this.config.eraser.noiseGain * distanceActivity,
filterHz,
pan: this.config.eraser.pan,
});
}
}
private updateDelay(snapshot: GardenAudioSnapshot): void {
const context = this.graph.context;
if (!context) {
return;
}
const profile = getVibeProfile(snapshot.vibe);
const activity = snapshot.isErasing
? this.config.delay.erasingActivity
: this.energy.getLevel();
this.graph.updateDelay(profile, activity);
}
private applyVibe(vibe: VibePreset): void {
if (!this.graph.context || this.currentVibeId === vibe.id) {
return;
}
this.currentVibeId = vibe.id;
this.graph.applyDelayProfile(getVibeProfile(vibe));
this.pianoEngine.cue(this.graph.context.currentTime);
}
}