449 lines
13 KiB
TypeScript
449 lines
13 KiB
TypeScript
import { ErrorHandler, Severity } from '../utils/error-handler';
|
|
import { clamp } from '../utils/math';
|
|
import type { VibeId, VibePreset } from '../vibes';
|
|
import {
|
|
MAX_AUDIO_VOLUME,
|
|
SILENT_AUDIO_GAIN,
|
|
type GardenAudioConfig,
|
|
type GardenAudioVibeProfile,
|
|
} 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, GardenAudioStroke } from './garden-audio-types';
|
|
import { GenerativePianoEngine } from './generative-piano';
|
|
import { NoiseBurstPlayer } from './noise-burst-player';
|
|
import { PianoSampler } from './piano-sampler';
|
|
|
|
type AudioLifecycle = 'idle' | 'started' | 'destroyed';
|
|
type PianoReleasePhase =
|
|
| { kind: 'idle' }
|
|
| { kind: 'awaiting-fade' }
|
|
| { kind: 'scheduled-fade'; fadeAt: number }
|
|
| { kind: 'settling'; stopAt: number };
|
|
|
|
const muteRampSeconds = 0.02;
|
|
const brushUpPianoBusFadeSeconds = 2.4;
|
|
const brushUpPianoBusFadeSettleSeconds = 3.2;
|
|
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 currentVibe: VibePreset | null = null;
|
|
private lifecycle: AudioLifecycle = 'idle';
|
|
private pianoReleasePhase: PianoReleasePhase = { kind: 'idle' };
|
|
private isMuted = false;
|
|
private isGestureActive = false;
|
|
private masterVolume: number;
|
|
private lastEraserAt = Number.NEGATIVE_INFINITY;
|
|
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
|
private startRequestId = 0;
|
|
private hasLoadedPiano = false;
|
|
|
|
public constructor(private readonly config: GardenAudioConfig) {
|
|
this.masterVolume = clamp(config.masterVolume, 0, MAX_AUDIO_VOLUME);
|
|
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: { userGesture?: boolean } = {}): void {
|
|
const isUserGesture = options.userGesture === true;
|
|
|
|
if (this.lifecycle === 'destroyed') {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
this.lifecycle === 'started' &&
|
|
this.currentVibeId === vibe.id &&
|
|
this.graph.context?.state === 'running' &&
|
|
this.hasLoadedPiano
|
|
) {
|
|
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';
|
|
const startRequestId = ++this.startRequestId;
|
|
|
|
if (needsResume) {
|
|
if (!isUserGesture) {
|
|
return;
|
|
}
|
|
void context
|
|
.resume()
|
|
.then(() => {
|
|
if (this.graph.context === context && this.lifecycle !== 'destroyed') {
|
|
this.completeStart(vibe, { context, startupRampSeconds, startRequestId });
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
ErrorHandler.addException(error, {
|
|
fallbackMessage: 'Could not resume audio playback.',
|
|
severity: Severity.WARNING,
|
|
});
|
|
});
|
|
return;
|
|
}
|
|
|
|
this.completeStart(vibe, { context, startupRampSeconds, startRequestId });
|
|
}
|
|
|
|
private completeStart(
|
|
vibe: VibePreset,
|
|
{
|
|
context,
|
|
startRequestId,
|
|
startupRampSeconds,
|
|
}: {
|
|
context: AudioContext;
|
|
startRequestId: number;
|
|
startupRampSeconds: number;
|
|
}
|
|
): void {
|
|
if (this.graph.context !== context || this.lifecycle === 'destroyed') {
|
|
return;
|
|
}
|
|
|
|
if (this.isMuted) {
|
|
this.activateMutedStart(vibe, context);
|
|
this.graph.setMasterGain(SILENT_AUDIO_GAIN, muteRampSeconds);
|
|
return;
|
|
}
|
|
|
|
void this.piano
|
|
.load(context)
|
|
.then(() => {
|
|
if (!this.canCompleteStart(context, startRequestId)) {
|
|
return;
|
|
}
|
|
|
|
this.activateStart(vibe, context, startupRampSeconds, true);
|
|
})
|
|
.catch((error) => {
|
|
if (this.canCompleteStart(context, startRequestId)) {
|
|
this.activateStart(vibe, context, startupRampSeconds, false);
|
|
}
|
|
ErrorHandler.addException(error, {
|
|
fallbackMessage: 'Could not load piano samples.',
|
|
severity: Severity.WARNING,
|
|
});
|
|
});
|
|
}
|
|
|
|
private canCompleteStart(context: AudioContext, startRequestId: number): boolean {
|
|
return (
|
|
this.graph.context === context &&
|
|
this.lifecycle !== 'destroyed' &&
|
|
!this.isMuted &&
|
|
this.startRequestId === startRequestId
|
|
);
|
|
}
|
|
|
|
private activateStart(
|
|
vibe: VibePreset,
|
|
context: AudioContext,
|
|
startupRampSeconds: number,
|
|
cuePiano: boolean
|
|
): void {
|
|
this.lifecycle = 'started';
|
|
this.currentVibeId = vibe.id;
|
|
this.currentVibe = vibe;
|
|
const profile = getVibeProfile(vibe);
|
|
this.graph.applyDelayProfile(profile.bpm);
|
|
this.graph.setMasterGain(this.masterVolume, startupRampSeconds);
|
|
|
|
if (cuePiano) {
|
|
this.hasLoadedPiano = true;
|
|
this.pianoEngine.cue(context.currentTime, profile);
|
|
}
|
|
}
|
|
|
|
private activateMutedStart(vibe: VibePreset, context: AudioContext): void {
|
|
this.lifecycle = 'started';
|
|
this.currentVibeId = vibe.id;
|
|
this.currentVibe = vibe;
|
|
this.hasLoadedPiano = false;
|
|
this.graph.applyDelayProfile(getVibeProfile(vibe).bpm);
|
|
if (this.graph.context === context) {
|
|
this.pianoEngine.reset();
|
|
}
|
|
}
|
|
|
|
public changeVibe(vibe: VibePreset, options: { userGesture?: boolean } = {}): void {
|
|
const previousVibeId = this.currentVibeId;
|
|
this.start(vibe, options);
|
|
const didChangeVibe = previousVibeId !== null && previousVibeId !== vibe.id;
|
|
|
|
if (didChangeVibe) {
|
|
this.piano.stopAll();
|
|
this.hasLoadedPiano = false;
|
|
}
|
|
|
|
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 ? SILENT_AUDIO_GAIN : this.masterVolume,
|
|
isMuted ? muteRampSeconds : this.config.fadeInSeconds
|
|
);
|
|
|
|
if (!isMuted && this.currentVibe && !this.hasLoadedPiano) {
|
|
this.start(this.currentVibe);
|
|
}
|
|
}
|
|
|
|
public setMasterVolume(masterVolume: number): void {
|
|
this.masterVolume = clamp(masterVolume, 0, MAX_AUDIO_VOLUME);
|
|
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.pianoReleasePhase = { kind: 'idle' };
|
|
this.graph.setPianoBusGainScale(1, this.config.fadeInSeconds);
|
|
this.gestureState.reset();
|
|
this.energy.beginGesture(context.currentTime);
|
|
this.pianoEngine.beginGesture();
|
|
}
|
|
|
|
public endGesture(): void {
|
|
this.gestureState.reset();
|
|
this.isGestureActive = false;
|
|
this.pianoReleasePhase = { kind: 'awaiting-fade' };
|
|
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);
|
|
const profile = getVibeProfile(snapshot.vibe);
|
|
this.energy.update(context.currentTime, profile);
|
|
|
|
if (snapshot.isErasing) {
|
|
this.energy.silence();
|
|
}
|
|
|
|
if (!this.isGestureActive && this.pianoReleasePhase.kind !== 'idle') {
|
|
this.updatePianoRelease(snapshot.vibe, context.currentTime);
|
|
this.updateDelay(snapshot, profile);
|
|
return;
|
|
}
|
|
|
|
this.pianoEngine.renderLookahead({
|
|
vibe: snapshot.vibe,
|
|
now: context.currentTime,
|
|
activity: snapshot.isErasing
|
|
? this.config.eraser.pianoActivity
|
|
: this.energy.getLevel(),
|
|
});
|
|
this.updateDelay(snapshot, profile);
|
|
}
|
|
|
|
public stroke(stroke: GardenAudioStroke): void {
|
|
if (this.lifecycle !== 'started' || this.isMuted) {
|
|
return;
|
|
}
|
|
|
|
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.playEraser(strokeEnergy, now);
|
|
return;
|
|
}
|
|
|
|
const profile = getVibeProfile(stroke.vibe);
|
|
this.energy.recordStroke(strokeEnergy, profile);
|
|
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.hasLoadedPiano = false;
|
|
this.energy.reset();
|
|
this.gestureState.reset();
|
|
this.pianoEngine.reset();
|
|
this.currentVibeId = null;
|
|
this.currentVibe = null;
|
|
this.isGestureActive = false;
|
|
this.pianoReleasePhase = { kind: 'idle' };
|
|
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 updatePianoRelease(vibe: VibePreset, now: number): void {
|
|
if (this.pianoReleasePhase.kind === 'awaiting-fade') {
|
|
const fadeAt = this.pianoEngine.release(vibe, now);
|
|
if (now < fadeAt) {
|
|
this.pianoReleasePhase = { kind: 'scheduled-fade', fadeAt };
|
|
return;
|
|
}
|
|
|
|
this.graph.setPianoBusGainScale(0, brushUpPianoBusFadeSeconds);
|
|
this.pianoReleasePhase = {
|
|
kind: 'settling',
|
|
stopAt: now + brushUpPianoBusFadeSettleSeconds,
|
|
};
|
|
return;
|
|
}
|
|
|
|
if (
|
|
this.pianoReleasePhase.kind === 'scheduled-fade' &&
|
|
now >= this.pianoReleasePhase.fadeAt
|
|
) {
|
|
this.graph.setPianoBusGainScale(0, brushUpPianoBusFadeSeconds);
|
|
this.pianoReleasePhase = {
|
|
kind: 'settling',
|
|
stopAt: now + brushUpPianoBusFadeSettleSeconds,
|
|
};
|
|
return;
|
|
}
|
|
|
|
if (
|
|
this.pianoReleasePhase.kind === 'settling' &&
|
|
now >= this.pianoReleasePhase.stopAt
|
|
) {
|
|
this.piano.stopAll();
|
|
this.pianoEngine.reset();
|
|
this.hasLoadedPiano = false;
|
|
this.pianoReleasePhase = { kind: 'idle' };
|
|
}
|
|
}
|
|
|
|
private playEraser(activity: number, now: number): void {
|
|
if (!this.graph.context) {
|
|
return;
|
|
}
|
|
|
|
const distanceActivity = clamp(activity, 0, 1);
|
|
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,
|
|
profile: GardenAudioVibeProfile
|
|
): void {
|
|
const context = this.graph.context;
|
|
if (!context) {
|
|
return;
|
|
}
|
|
|
|
const activity = snapshot.isErasing
|
|
? this.config.delay.erasingActivity
|
|
: this.energy.getLevel();
|
|
this.graph.updateDelay(activity, profile.bpm);
|
|
}
|
|
|
|
private applyVibe(vibe: VibePreset): void {
|
|
if (!this.graph.context || this.currentVibeId === vibe.id) {
|
|
return;
|
|
}
|
|
|
|
this.currentVibeId = vibe.id;
|
|
this.currentVibe = vibe;
|
|
const profile = getVibeProfile(vibe);
|
|
this.graph.applyDelayProfile(profile.bpm);
|
|
this.pianoEngine.cue(this.graph.context.currentTime, profile);
|
|
this.hasLoadedPiano = true;
|
|
}
|
|
}
|