Add WIP sound generation

This commit is contained in:
Andras Schmelczer 2026-05-10 15:26:44 +01:00
commit 34ac200437
10 changed files with 1542 additions and 0 deletions

386
src/audio/garden-audio.ts Normal file
View file

@ -0,0 +1,386 @@
import { clamp, clamp01 } from '../utils/clamp';
import { VibePreset } from '../vibes';
import { GardenAudioConfig } from './garden-audio-config';
import { GardenAudioEnergy } from './garden-audio-energy';
import { GardenAudioGraph } from './garden-audio-graph';
import { getStrokeMetrics } from './garden-audio-input';
import { getVibeProfile, normalizeColorIndex } from './garden-audio-music';
import { GardenAudioScore } from './garden-audio-score';
import type {
GardenAudioColorIndex,
GardenAudioSnapshot,
GardenAudioStartOptions,
GardenAudioStroke,
} from './garden-audio-types';
import { NoiseBurstPlayer } from './noise-burst-player';
import { PianoSampler } from './piano-sampler';
export type {
GardenAudioSnapshot,
GardenAudioStartOptions,
GardenAudioStroke,
} from './garden-audio-types';
export class GardenAudio {
private readonly graph: GardenAudioGraph;
private readonly piano: PianoSampler;
private readonly noise: NoiseBurstPlayer;
private readonly energy: GardenAudioEnergy;
private readonly score: GardenAudioScore;
private currentVibeId: string | null = null;
private hasStarted = false;
private isDestroyed = false;
private isMuted = false;
private selectedColorIndex: GardenAudioColorIndex = 0;
private rhythmAnchorTime: number | null = null;
private startedAt: number | null = null;
private nextStepAt = 0;
private stepIndex = 0;
private lastTapAt = Number.NEGATIVE_INFINITY;
private lastEraserAt = Number.NEGATIVE_INFINITY;
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
public constructor(private readonly config: GardenAudioConfig) {
this.graph = new GardenAudioGraph(config);
this.piano = new PianoSampler(config, this.graph);
this.noise = new NoiseBurstPlayer(this.graph);
this.energy = new GardenAudioEnergy(config.rhythm);
this.score = new GardenAudioScore(config, (note) => this.piano.play(note));
}
public start(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
if (!this.config.enabled || this.isDestroyed || this.isMuted) {
return;
}
const context = this.graph.ensureContext(options.userGesture === true);
if (!context) {
return;
}
if (context.state === 'suspended') {
if (options.userGesture !== true) {
return;
}
void context.resume().catch(() => undefined);
}
this.hasStarted = true;
this.rhythmAnchorTime ??= context.currentTime;
this.startedAt ??= context.currentTime;
this.applyVibe(vibe);
if (this.nextStepAt <= 0) {
this.nextStepAt = context.currentTime + 0.02;
}
this.graph.setMasterGain(this.config.masterVolume, this.config.fadeInSeconds);
if (this.config.piano.preloadOnStart) {
void this.piano.load(context);
}
}
public changeVibe(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
const previousVibeId = this.currentVibeId;
this.start(vibe, options);
const context = this.graph.context;
if (
context &&
(context.state === 'running' || options.userGesture === true) &&
!this.isMuted &&
!this.isDestroyed &&
previousVibeId !== null &&
previousVibeId !== vibe.id
) {
this.playVibeChangeStinger(vibe);
}
}
public setMuted(isMuted: boolean): void {
this.isMuted = isMuted;
this.graph.setMasterGain(
isMuted ? 0.0001 : this.config.masterVolume,
isMuted ? 0.02 : this.config.fadeInSeconds
);
}
public beginGesture(): void {
const context = this.graph.context;
if (!context) {
return;
}
this.energy.beginGesture(context.currentTime);
}
public endGesture(): void {
const context = this.graph.context;
if (!context) {
return;
}
this.energy.endGesture(context.currentTime);
}
public rememberColor(colorIndex: number): void {
this.selectedColorIndex = normalizeColorIndex(colorIndex);
}
public update(snapshot: GardenAudioSnapshot): void {
const context = this.graph.context;
if (!this.hasStarted || !context || this.isMuted) {
return;
}
this.applyVibe(snapshot.vibe);
this.selectedColorIndex = normalizeColorIndex(snapshot.selectedColorIndex);
this.energy.update(context.currentTime);
if (snapshot.isErasing) {
this.energy.silence();
this.piano.fadeActive(context.currentTime);
this.updateDelay(snapshot);
return;
}
this.scheduleRhythm(snapshot.vibe);
this.updateDelay(snapshot);
}
public stroke(stroke: GardenAudioStroke): void {
if (!this.config.enabled || this.isDestroyed || this.isMuted) {
return;
}
this.start(stroke.vibe);
const context = this.graph.context;
if (!context) {
return;
}
const metrics = getStrokeMetrics(
stroke,
this.config.rhythm.speedForFullEnergyPixelsPerSecond,
this.config.input.pressureFallback
);
const now = context.currentTime;
this.selectedColorIndex = normalizeColorIndex(stroke.colorIndex);
if (stroke.isErasing) {
this.energy.recordEraserStroke(now);
this.playEraser(stroke, metrics.speedAmount, metrics.pressure, now);
return;
}
const strokeEnergy = metrics.effectiveEnergy * this.getStartupEnergyScale(now);
this.energy.recordStroke(metrics.distancePixels, strokeEnergy, now);
if (metrics.distancePixels >= 2.5) {
this.energy.raiseTarget(strokeEnergy);
}
if (
metrics.distancePixels >= 2.5 &&
now - this.lastTapAt >= this.getTapIntervalSeconds(now)
) {
this.lastTapAt = now;
this.score.playStrokeTap({
stroke,
energy: strokeEnergy,
now,
selectedColorIndex: this.selectedColorIndex,
stepIndex: this.stepIndex,
rhythmAnchorTime: this.getRhythmAnchorTime(now),
stepDurationSeconds: this.getStepDurationSeconds(now),
});
}
}
public async destroy(): Promise<void> {
this.isDestroyed = true;
await this.graph.close();
this.piano.reset();
this.energy.reset();
this.currentVibeId = null;
this.hasStarted = false;
this.selectedColorIndex = 0;
this.rhythmAnchorTime = null;
this.startedAt = null;
this.nextStepAt = 0;
this.stepIndex = 0;
this.lastTapAt = Number.NEGATIVE_INFINITY;
this.lastEraserAt = Number.NEGATIVE_INFINITY;
this.lastVibeStingerAt = Number.NEGATIVE_INFINITY;
}
private scheduleRhythm(vibe: VibePreset): void {
const context = this.graph.context;
if (!context || !this.graph.eventBus) {
return;
}
const now = context.currentTime;
const stepSeconds = this.getStepDurationSeconds(now);
if (this.nextStepAt <= 0 || this.nextStepAt < now - stepSeconds) {
this.nextStepAt = now + 0.02;
}
const lookaheadEnd = now + this.config.rhythm.lookaheadSeconds;
while (this.nextStepAt <= lookaheadEnd) {
const swingOffset =
this.stepIndex % 2 === 1 ? stepSeconds * this.config.rhythm.swing : 0;
const startTime = this.nextStepAt + swingOffset;
this.score.playRhythmStep({
vibe,
stepIndex: this.stepIndex,
startTime,
activity: this.getSettledActivity(
this.energy.getActivityAt(startTime),
startTime
),
selectedColorIndex: this.selectedColorIndex,
});
this.nextStepAt += stepSeconds;
this.stepIndex += 1;
}
}
private playVibeChangeStinger(vibe: VibePreset): void {
const context = this.graph.context;
if (!context) {
return;
}
const now = context.currentTime;
if (now - this.lastVibeStingerAt < 0.45) {
return;
}
this.lastVibeStingerAt = now;
this.score.playVibeChangeStinger(vibe, now);
}
private playEraser(
stroke: GardenAudioStroke,
speedAmount: number,
pressure: number,
now: number
): void {
if (!this.config.eraser.enabled || !this.graph.context) {
return;
}
const sizeAmount = clamp01(
(stroke.eraserSizePixels ?? 96) / Math.max(1, stroke.canvasSize[0] * 0.18)
);
const x = clamp01(stroke.to[0] / Math.max(1, stroke.canvasSize[0]));
const filterHz =
this.config.eraser.filterMinHz +
(this.config.eraser.filterMaxHz - this.config.eraser.filterMinHz) *
clamp01(speedAmount * 0.58 + pressure * 0.26 + sizeAmount * 0.16);
if (now - this.lastEraserAt >= this.config.eraser.minIntervalSeconds) {
this.lastEraserAt = now;
this.noise.play({
startTime: now,
durationSeconds: 0.08,
gain:
this.config.eraser.noiseGain *
(0.45 + speedAmount * 0.38 + pressure * 0.24 + sizeAmount * 0.18),
filterHz,
pan: clamp(x * 2 - 1, -1, 1),
});
}
}
private updateDelay(snapshot: GardenAudioSnapshot): void {
const context = this.graph.context;
if (!context) {
return;
}
const profile = getVibeProfile(this.config, snapshot.vibe);
const activity = snapshot.isErasing
? 0.12
: this.getSettledActivity(
this.energy.getActivityAt(context.currentTime),
context.currentTime
);
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(this.config, vibe));
}
private getStepDurationSeconds(time: number): number {
const baseStepSeconds = 60 / this.config.rhythm.bpm / this.config.rhythm.stepsPerBeat;
return baseStepSeconds * this.getStartupTempoMultiplier(time);
}
private getRhythmAnchorTime(now: number): number {
this.rhythmAnchorTime ??= now;
return this.rhythmAnchorTime;
}
private getTapIntervalSeconds(time: number): number {
return (
this.config.rhythm.minTapIntervalSeconds *
this.getStartupTapIntervalMultiplier(time)
);
}
private getSettledActivity(activity: number, time: number): number {
return Math.min(activity, this.getStartupActivityCeiling(time));
}
private getStartupEnergyScale(time: number): number {
return this.interpolateStartupValue(
this.config.startup.initialEnergyMultiplier,
1,
time
);
}
private getStartupActivityCeiling(time: number): number {
return this.interpolateStartupValue(
this.config.startup.initialActivityCeiling,
1,
time
);
}
private getStartupTempoMultiplier(time: number): number {
return this.interpolateStartupValue(
this.config.startup.initialTempoMultiplier,
1,
time
);
}
private getStartupTapIntervalMultiplier(time: number): number {
return this.interpolateStartupValue(
this.config.startup.initialTapIntervalMultiplier,
1,
time
);
}
private interpolateStartupValue(from: number, to: number, time: number): number {
const durationSeconds = this.config.startup.calmDurationSeconds;
if (this.startedAt === null || durationSeconds <= 0) {
return to;
}
const progress = clamp01((time - this.startedAt) / durationSeconds);
const easedProgress = progress * progress * (3 - 2 * progress);
return from + (to - from) * easedProgress;
}
}